# The Planner: Bounded Plan and Execute over the Closed Registry > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. **Goal:** Generalize G6's one hardcoded composition into a data driven planner that answers a COMPOUND cross plane question by selecting and running a set of EXISTING closed Registry intents, composing their evidence behind the faithfulness gate. The single biggest unlock on the ANI roadmap: it turns the single shot keyword router (which drops every compound capture question to RAG narration) into a true multi step reasoner, while the model authors ZERO SQL and ZERO param values. **Architecture (iteration 1, planned selection with deterministic binding):** The orchestrator runs the existing deterministic extractors on the question to get the AVAILABLE params (agency, naics, uuid, held clearance, uei, window months, compartments). The inference plane proposes a PLAN = an ordered list of intent NAMES drawn ONLY from the closed Registry enum (it provides no param values, no SQL, no new intents). A deterministic Go validator rejects any name not in the closed set and drops any selected intent whose required params are not available. The Go runner executes each surviving node through its CORRECT existing path (a bench intent through its suppressing executor, a graph or TAG intent through ExecuteIntent or its public executor), collects the union of evidence, narrates once behind the faithfulness gate over that union, and audits a counts only plan trace. Self consistency: the plan is sampled K times and only the stable intent set runs, else the deterministic fallback. Data dependency edges (one node's output binding another's arg) are a documented iteration 2, explicitly out of scope here so the first planner is maximally safe. **Tech Stack:** Go 1.26, pgx/v5, shopspring/decimal, FIPS, no Python. **Branch:** `graphrag-foundation`. NO migration (reuses the Registry and the inference plane). ModelVersion stays v1.4.0. Validate on M4 `brain_staging`. --- ## Why this is safe by construction The model NEVER authors SQL and NEVER supplies a param value. It selects from a CLOSED enum of intent names. Every param value comes from the same deterministic extractors graphClassify already uses, so: - `GuardReadOnlyQuery`, the EEO and protected attribute guards, bench `Suppress`, RLS `RunInTenantTx`, and the frozen engine boundary all apply UNCHANGED on every node (each node runs the existing path). - An unbindable or out of set selection is impossible to execute: the validator drops it. - Hard caps (max nodes, no recursion) bound the work the same way `max_depth` bounds the recursive CTEs. - The bench plane stays suppressed and auth gated: a bench node runs only through the suppressing executor, never the raw path (already enforced by `ExecuteIntent` rejecting bench intents). ## Hard invariants 1. **The model authors nothing executable.** It returns intent names from the closed Registry only. The validator asserts every name is in `Registry`; any other token is dropped and logged. No SQL, no param value, no new intent ever originates from the model. 2. **Every node runs its existing guarded path.** A bench intent dispatches to `answerBench`/`runBenchIntent` (suppressed, auth gated); a graph or TAG intent runs through `ExecuteIntent` (re guarded, RLS); a public market intent uses its existing executor. The planner adds NO new query and NO new SQL. 3. **Bounded and deterministic.** A hard cap on node count (for example 6) and NO recursion. The surviving node set runs in a deterministic order (Registry order, then name) so the composed answer is byte stable. Self consistency runs the plan K times and executes only the stable intent set. 4. **Faithfulness over the union.** The narrated answer is verified against the UNION of every executed node's evidence; on a gate miss or a disagreement the deterministic rendering of the union ships (a 200, rationale source deterministic), never a hang and never an unverified claim. 5. **EEO unreachable, frozen engine untouched, no score.** The planner composes structural and market evidence and the suppressed bench counts; it computes no match score. `ForbidEEO` and `MentionsProtectedAttribute` run on the question FIRST. The eeo schema is never referenced. `internal/modules/matching` is byte identical. 6. **Counts only plan trace audit.** One append only `aedt.ai.plan_answered` event per planned answer: the ordered intent NAMES (the closed set, not PII), the available param KINDS (not values), the node count, the suppressed cell count across bench nodes, the faithfulness verdict, and the self consistency outcome. NO param value, NO candidate id, NO raw count, NO answer text, NO question. 7. Standard rules: no Python; no dashes in written output except URLs, CLI flags, import paths, identifiers; both cloud; FIPS. --- ## File Structure - Create `internal/modules/ai/planner.go` (+ test): the plan types, `extractAvailableParams`, `proposePlan`, `validatePlan`, `runPlan`, `AnswerPlan`, the plan trace. - Modify `internal/modules/ai/orchestrator.go`: route a COMPOUND question (one that names more than one plane, or that the single intent classifier could not bind) to `AnswerPlan` BEFORE the RAG fallback, so a compound question that today drops to RAG now plans. The existing single intent fast paths (a question that binds exactly one graph or TAG intent) are unchanged: the planner is the fallback for compound questions, not a replacement for the single shot routes. - Modify `internal/modules/ai/graph_route.go` only if a shared extractor needs exposing (reuse `bareNAICS`, `quotedAgency`, `quotedUUID`, `heldClearanceCode`, `quotedUEI`, `windowMonths`, `quotedCompartmentList` as is). - Create `internal/integration/planner_integration_test.go`: the live compound question proof. --- ## Phase P.A: the available params and the plan grammar ### Task A1: extract available params and define the closed plan grammar **Files:** - Create: `internal/modules/ai/planner.go` - Test: `internal/modules/ai/planner_test.go` - [ ] **Step 1: Define the grammar.** ```go // PlanNode is one selected intent in a plan: a name that MUST be in the closed Registry, plus the args the // validator bound for it from the available params. The model supplies only the Intent name; Args are bound // deterministically by the orchestrator, never by the model. type PlanNode struct { Intent string Args []any } // Plan is the validated, bounded set of intents to run for one compound question. It carries no SQL and no // model authored value; every Intent is a Registry name and every Arg came from a deterministic extractor. type Plan struct { Nodes []PlanNode } const maxPlanNodes = 6 ``` - [ ] **Step 2: Write `extractAvailableParams(question)`** that runs the existing extractors and returns a typed bag of what the question unambiguously carries: `{Agency string; AgencyOK bool; NAICS string; NAICSOK bool; UUID string; UUIDOK bool; HeldClearance string; HeldOK bool; UEI string; UEIOK bool; WindowMonths int; Compartments string; CompartmentsOK bool}`. Reuse `bareNAICS`, `quotedAgency`, `quotedUUID`, `heldClearanceCode`, `quotedUEI`, `windowMonths`, `quotedCompartmentList` verbatim. Pure, deterministic, no DB. - [ ] **Step 3: Write `bindIntentArgs(intent QueryIntent, avail availableParams) ([]any, bool)`** that, for each of the intent's `Params`, supplies the matching available value by the param's role (a `ParamUUID` param named like an id binds from `avail.UUID`; an `agency` `ParamText` binds from `avail.Agency`; a `naics` `ParamText` from `avail.NAICS`; a `held_code`/clearance `ParamEnum` from `avail.HeldClearance`; a window `ParamInt` from `avail.WindowMonths` defaulting to the intent clamp; a compartment list from `avail.Compartments`). Returns `(args, true)` only when EVERY required param is available, else `(nil, false)` so the node is dropped. Map a param to its source by the param Name (a small fixed lookup table keyed on the param names the Registry actually uses), never by guessing. Unit test every Registry intent: either it binds from a representative avail bag or it is correctly dropped. - [ ] **Step 4: Commit.** --- ## Phase P.B: propose, validate, run ### Task B1: propose the plan from the inference plane **Files:** - Modify: `internal/modules/ai/planner.go` - Test: `internal/modules/ai/planner_test.go` - [ ] **Step 1: Write `proposePlan(ctx, question, avail) ([]string, error)`** that asks the inference plane to SELECT the relevant intent names. The prompt lists every Registry intent name and its one line Description and required params, states which params are available for THIS question, and instructs the model to return ONLY a JSON array of intent names from that list that are both relevant and bindable, at most `maxPlanNodes`, no prose, no SQL, no new names. Parse the JSON array; discard any element that is not an exact Registry name (the closed enum check); cap at `maxPlanNodes`. If the model plane errors or returns nothing parseable, return an empty selection (the caller falls back to RAG), never a guess. - [ ] **Step 2: Write `validatePlan(names []string, avail) Plan`** that, for each proposed name in Registry order then name (deterministic), looks up the intent, calls `bindIntentArgs`, and keeps the node only if it binds; drops the rest. Returns the bounded, ordered, fully bound `Plan`. A name not in the Registry, or one that does not bind, never reaches the runner. - [ ] **Step 3: Self consistency.** Write `proposeStablePlan(ctx, question, avail, k)` that calls `proposePlan` k times (k=3, the low temperature the gate already uses), validates each, and returns the set of nodes that appears in a MAJORITY of the k validated plans (stable selection), in the deterministic order. If the plans disagree entirely (no majority node), return an empty plan so the caller falls back to the deterministic single intent or RAG path. Unit test stability with a stubbed proposer. - [ ] **Step 4: Commit.** ### Task B2: run the plan and compose **Files:** - Modify: `internal/modules/ai/planner.go`, `internal/modules/ai/orchestrator.go` - Test: `internal/modules/ai/planner_test.go` - [ ] **Step 1: Write `runPlan(ctx, plan) (evidence []string, suppressed int, ran []string, err error)`** that executes each node through its CORRECT existing path: - a bench intent (`IsBenchIntent`) runs through the suppressing executor via the existing `runBenchIntent` and is rendered with `RenderCount` (suppressed safe), accumulating the suppressed cell count; - the G6 capture composition, if selected, runs through `answerCapturePicture` (it is itself a bench intent and already composed); - a public graph or TAG intent runs through `ExecuteIntent` (re guarded, RLS) and is rendered with the existing deterministic table rendering. Collect each node's rendered evidence into the union. A node that errors is dropped from the union with a logged note (the plan degrades, never hangs). Return the union, the suppressed count, and the ordered names that actually ran. - [ ] **Step 2: Write `AnswerPlan(ctx, question) (*Response, error)`** that: runs `ForbidEEO`/`MentionsProtectedAttribute` first (refuse on either); extracts avail; gets the stable plan; if the plan is empty falls back to `AnswerRAG` (the existing behavior, so a non compound or unbindable question is unchanged); else runs the plan, narrates ONE answer through `o.generate` (instructed to state only what the union evidence supports, to report a suppressed bench count as withheld and never guess a number for it, and never to compute a match score) and `o.finalize` over the union; then emits the counts only `aedt.ai.plan_answered` audit (the ordered ran names, the available param KINDS, the node count, the suppressed count, the faithfulness verdict, the self consistency outcome). - [ ] **Step 3: Route compound questions to the planner.** In `Answer`, AFTER the single intent fast paths (a named intent, then `graphClassify` binding exactly one intent) and BEFORE the `Classify` RAG fallback, add: if the question is COMPOUND (a cheap deterministic signal: it mentions more than one plane keyword, for example a bench word AND a market word, or it contains a conjunction joining two plane phrases) route to `AnswerPlan`. A non compound question is byte for byte unchanged. Keep the signal conservative: when unsure, fall through to the existing path, never regress a working single shot route. - [ ] **Step 4: Commit.** --- ## Phase P.C: live proof on staging ### Task C1: the planner answers a compound cross plane question **Files:** - Create: `internal/integration/planner_integration_test.go` - [ ] **Step 1: Seed** a per run org with a requirement, a bench exercising the gap, and a market segment (an agency and an award, as in the G6 test). Pose a COMPOUND question that names both a market plane and the bench plane and carries the bindable params (a quoted agency, a bare naics, and the requirement uuid). - [ ] **Step 2: Assert** the answer (a) carries route graph or a new route plan, (b) composes BOTH a market line (the competitor or recompete from the seeded segment) AND a suppressed bench line (the sentinel for a sub k cell, a real count when safe), (c) the bench counts come through `RenderCount` (no raw sub k number leaks), (d) computes no score, (e) the `aedt.ai.plan_answered` audit holds only the ran intent NAMES, the param KINDS, the counts, and the verdicts, with NO param value, NO candidate id, NO raw count, NO requirement id. Assert a non compound single intent question still takes the unchanged single shot path (a regression guard). Assert an EEO laced compound question refuses. - [ ] **Step 3: Run on staging**, then the full graph suite for no regression, then commit. ```bash GOFIPS140=v1.0.0 CGO_ENABLED=0 go test -tags=integration -count=1 -run "TestPlan|TestPlanner" ./internal/integration/... -v ``` --- ## Self review checklist 1. **No model authored execution:** the model returns only Registry names; every name is asserted in the closed set; every param value comes from a deterministic extractor; no SQL or param value originates from the model. 2. **Every node guarded:** bench nodes run suppressed and auth gated through the existing executor; graph and TAG nodes run through ExecuteIntent; the planner adds no query. 3. **Bounded and deterministic:** max node cap, no recursion, deterministic order, self consistency over the stable set; byte stable on a re run. 4. **Union faithfulness:** the answer is verified against the union of node evidence; a miss ships the deterministic union rendering. 5. **EEO, score, suppression, RLS:** ForbidEEO first; no score; bench counts via RenderCount; every node RLS scoped; the eeo schema unreferenced; the frozen engine untouched. 6. **Counts only trace:** the plan_answered audit holds names, kinds, counts, and verdicts only, no value or PII. 7. **No regression:** a non compound question takes the unchanged single shot path. ## Documented iteration 2 (out of scope here) Data dependency edges: a node's scalar output binding a downstream node's arg (for example resolve the incumbent UEI of an opportunity, then run the partner or competitor ratio on that UEI). This needs a typed edge {from node, from column, to param} validated against the downstream `ParamKind`, and a runner that extracts a single scalar from an upstream `TAGResult`. It is strictly additive to iteration 1 and inherits every safety property (the scalar still binds a typed param, no SQL is authored). Ship iteration 1 first; it already generalizes G6 to any compound bindable question.