package ai // THE PLANNER. This file generalizes the G6 loop (the one hardcoded fan out over four fixed intents in // graph_loop.go) into a data driven planner that answers a COMPOUND cross plane question by SELECTING and // running a set of EXISTING closed Registry intents and composing their evidence behind the faithfulness // gate. It is the 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. // // 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; the validator asserts every name is in Registry and drops // anything else. Every param value comes from the same deterministic extractors graphClassify already uses // (bareNAICS, quotedAgency, quotedUUID, heldClearanceCode, quotedUEI, windowMonths, quotedCompartmentList), // so the read only guard, the EEO and protected attribute guards, bench suppression, RLS RunInTenantTx, and // the frozen engine boundary all apply UNCHANGED on every node (each node runs its existing guarded path). // A bench node runs only through the suppressing executor (runBenchIntent), never the raw ExecuteIntent // path (which already hard rejects bench intents). The G6 capture composition runs through // answerCapturePicture. Every other graph or TAG intent runs through ExecuteIntent. The planner adds NO new // query and NO new SQL. Hard caps (maxPlanNodes, no recursion) bound the work; the surviving node set runs // in a deterministic order (Registry order, then name) so the composed answer is byte stable. Self // consistency samples the plan K times and runs only the stable intent set. The plan trace audit carries // only names, kinds, counts, and verdicts, never a value and never PII. // // Iteration 1 (this file) is planned SELECTION with deterministic binding. Data dependency edges (one // node's scalar output binding a downstream node's arg) are a documented iteration 2, explicitly out of // scope here so the first planner is maximally safe. import ( "context" "encoding/json" "sort" "strings" "github.com/missionworkforce/missionmatch/internal/platform/inference" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // 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 } // maxPlanNodes is the hard cap on how many intents one plan may run. It bounds the work the same way a // recursive CTE's depth cap bounds its walk; there is no recursion in the planner. const maxPlanNodes = 6 // planSelfConsistencyK is the number of times the plan is sampled before only the stable (majority) intent // set runs. It mirrors the low temperature the faithfulness gate already uses, three samples. const planSelfConsistencyK = 3 // availableParams is the typed bag of what the question unambiguously carries, extracted by the same // deterministic extractors graphClassify uses. The model supplies none of these; every value here came // from a regex over the original question. A node binds only from this bag, so the model can never inject a // param value. type availableParams struct { 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 // M5 wage band params: the SOC occupation code resolved from a quoted role through the curated crosswalk, // and the OEWS area code (HI or NM) resolved from a named state. Both come from deterministic resolvers over // the question text, never the model. OccupationCode string OccupationOK bool Area string AreaOK bool } // extractAvailableParams runs the existing deterministic extractors and returns the typed bag of what the // question unambiguously carries. It is pure, deterministic, and touches no database. Every value comes from // a regex over the question text, never from the model. WindowMonths defaults to zero so an intent that // takes a window applies its own clamp when the question states no months. func extractAvailableParams(question string) availableParams { lower := strings.ToLower(question) var a availableParams if agency, ok := quotedAgency(question); ok { a.Agency, a.AgencyOK = agency, true } if naics, ok := bareNAICS(question); ok { a.NAICS, a.NAICSOK = naics, true } if id, ok := quotedUUID(question); ok { a.UUID, a.UUIDOK = id, true } if code, ok := heldClearanceCode(lower); ok { a.HeldClearance, a.HeldOK = code, true } if uei, ok := quotedUEI(question); ok { a.UEI, a.UEIOK = uei, true } a.WindowMonths = windowMonths(question) if list, ok := quotedCompartmentList(question); ok { a.Compartments, a.CompartmentsOK = list, true } // M5 wage band: a quoted role resolved to a SOC through the curated crosswalk, and a named state resolved to // a known OEWS area code. Both are conservative: an unresolvable role or an unknown state leaves the slot // unset so the wage intent simply drops rather than binding a wrong occupation or state. if role, ok := quotedRole(question); ok { if occ, resolved := resolveSOC(role); resolved { a.OccupationCode, a.OccupationOK = occ.SOC, true } } if area, ok := resolveArea(question); ok { a.Area, a.AreaOK = area, true } return a } // paramSource names the available bag field a Registry param binds from. It is the FIXED lookup table keyed // on the EXACT param names the Registry uses (read from tag.go and graph_*.go), so a param binds by its // declared name, never by a guess. A param name not in this table never binds, which means its intent is // simply never selectable by the planner, the safe default. type paramSource string const ( srcUUID paramSource = "uuid" // a ParamUUID id binds from avail.UUID srcAgency paramSource = "agency" // an agency or agency_prefix ParamText binds from avail.Agency srcNAICS paramSource = "naics" // a naics ParamText binds from avail.NAICS srcHeldClearance paramSource = "held" // a clearance ParamEnum binds from avail.HeldClearance srcUEI paramSource = "uei" // a partner_uei ParamText binds from avail.UEI srcCompartments paramSource = "compartments" // a compartment list ParamText binds from avail.Compartments srcWindowMonths paramSource = "window_months" // a window ParamInt binds from avail.WindowMonths srcClampZero paramSource = "clamp_zero" // a bounded ParamInt the question never states binds zero, the intent's own clamp srcOccupation paramSource = "occupation" // a SOC occupation_code ParamText binds from avail.OccupationCode srcArea paramSource = "area" // an OEWS area ParamEnum binds from avail.Area ) // paramBindings maps every Registry param NAME to the available bag field it binds from. The keys are the // EXACT names declared in the Registry. A required param whose name is absent here cannot bind, so its // intent drops, which is safe (the planner simply never selects an intent it cannot bind honestly). The // window and clamp sources differ: a window_months reads the stated window (zero applies the intent clamp), // while max_depth and lookback_years always bind zero so the intent applies its own clamp and the planner // never invents a recursion depth or a lookback the user did not state. var paramBindings = map[string]paramSource{ // UUID id params across the planes. "requirement_id": srcUUID, "slate_id": srcUUID, "start_skill_id": srcUUID, "required_skill_id": srcUUID, "opportunity_id": srcUUID, "skill_id": srcUUID, // Agency text params (the buyer key the market and TAG reads use). "agency": srcAgency, "agency_prefix": srcAgency, "customer_agency": srcAgency, // NAICS text param. "naics": srcNAICS, // Clearance enum params (both bind the held clearance code; the executor re checks each declared allowed // set, so a value out of one intent's vocabulary is rejected at execution, not bound blindly). "held_code": srcHeldClearance, "clearance_level": srcHeldClearance, // Partner UEI text param. "partner_uei": srcUEI, // Compartment list text param. "required_compartments": srcCompartments, // Window and clamp integer params. "window_months": srcWindowMonths, "max_depth": srcClampZero, "lookback_years": srcClampZero, // M5 wage band params: the SOC occupation code and the OEWS area code. "occupation_code": srcOccupation, "area": srcArea, } // bindIntentArgs supplies, for each of the intent's Params in order, the matching available value by the // param NAME through the fixed paramBindings table. It returns (args, true) ONLY when every required param // is available, else (nil, false) so the node is dropped. The model supplies no value here: each arg comes // from the available bag (a deterministic extractor) or, for a bounded integer the question never states, // the intent's own clamp (zero). An intent with no params binds an empty arg list and always succeeds. func bindIntentArgs(intent QueryIntent, avail availableParams) ([]any, bool) { if len(intent.Params) == 0 { return nil, true } args := make([]any, 0, len(intent.Params)) for _, p := range intent.Params { src, known := paramBindings[p.Name] if !known { return nil, false // an unmapped param name can never bind: the intent drops, the safe default } switch src { case srcUUID: if !avail.UUIDOK { return nil, false } args = append(args, avail.UUID) case srcAgency: if !avail.AgencyOK { return nil, false } args = append(args, avail.Agency) case srcNAICS: if !avail.NAICSOK { return nil, false } args = append(args, avail.NAICS) case srcHeldClearance: if !avail.HeldOK { return nil, false } args = append(args, avail.HeldClearance) case srcUEI: if !avail.UEIOK { return nil, false } args = append(args, avail.UEI) case srcCompartments: if !avail.CompartmentsOK { return nil, false } args = append(args, avail.Compartments) case srcWindowMonths: // A stated window binds; an unstated window binds zero so the intent applies its own clamp. Either // way the value is the deterministic extractor's, never the model's. args = append(args, avail.WindowMonths) case srcClampZero: // A bounded recursion depth or lookback the question never states binds zero, the intent's own // clamp, so the planner never invents a depth or a lookback. args = append(args, 0) case srcOccupation: if !avail.OccupationOK { return nil, false } args = append(args, avail.OccupationCode) case srcArea: if !avail.AreaOK { return nil, false } args = append(args, avail.Area) default: return nil, false } } return args, true } // proposePlan asks the inference plane to SELECT the relevant intent names for a compound question. The // prompt lists every Registry intent NAME with its one line Description and required param names, states // which params are AVAILABLE for this question, and instructs the model to return ONLY a JSON array of // intent names from that list, at most maxPlanNodes, no prose, no SQL, no new names. It parses the JSON // array and DISCARDS any element that is not an exact IntentByName hit (the closed enum check). On any plane // error or unparseable output it returns an EMPTY selection (the caller falls back to RAG), never a guess. // With a nil client (the no plane posture) it returns empty so the planner degrades to RAG. func (o *Orchestrator) proposePlan(ctx context.Context, question string, avail availableParams) ([]string, error) { if o.client == nil { return nil, nil // no plane: the planner degrades to RAG, never a guess } system := "You are a query PLANNER for a cleared workforce intelligence platform. You select which of a " + "FIXED set of named, read only intents to run to answer a compound question. You author NO SQL and " + "supply NO parameter values: you return ONLY intent names from the list below, the ones that are both " + "relevant to the question AND bindable from the AVAILABLE parameters. Return a JSON array of intent " + "name strings, at most " + itoa(maxPlanNodes) + ", no prose, no SQL, no names that are not in the list. " + "If no intent is relevant and bindable, return an empty array." prompt := "AVAILABLE PARAMETERS: " + describeAvail(avail) + "\n\nINTENTS:\n" + registryCatalog() + "\n\nQUESTION: " + question + "\n\nReturn ONLY the JSON array of intent names." out, err := o.client.Generate(ctx, inference.GenerateRequest{System: system, Prompt: prompt, MaxTokens: 200, Temperature: 0.1}) if err != nil { return nil, nil // a plane error returns an empty selection so the caller falls back to RAG, never a guess } names := parseIntentNames(out.Text) // The closed enum check: keep only names that are an exact registered intent, cap at maxPlanNodes. The // model can never introduce an intent the platform did not author. kept := make([]string, 0, len(names)) seen := map[string]bool{} for _, n := range names { n = strings.TrimSpace(n) if n == "" || seen[n] { continue } if _, ok := IntentByName(n); !ok { continue // not a Registry name: dropped (the closed enum check) } seen[n] = true kept = append(kept, n) if len(kept) >= maxPlanNodes { break } } return kept, nil } // validatePlan turns a proposed name list into a bounded, ordered, fully bound Plan. For each registered // name (in deterministic Registry order, then name) it looks up the intent, binds its args, and keeps the // node ONLY if it binds. A name not in the Registry, or one whose required params are not available, never // reaches the runner. The result is byte stable on a re run: the same proposed set yields the same ordered // Plan. func validatePlan(names []string, avail availableParams) Plan { want := map[string]bool{} for _, n := range names { want[n] = true } var nodes []PlanNode // Walk the Registry in its declared order so the surviving set is deterministically ordered, then sort // the survivors by name as the documented tiebreaker (Registry order is already total, so the name sort is // a stable, explicit secondary key the plan can be reasoned about by). for _, intent := range Registry { if !want[intent.Name] { continue } args, ok := bindIntentArgs(intent, avail) if !ok { continue // unbindable: dropped, never reaches the runner } nodes = append(nodes, PlanNode{Intent: intent.Name, Args: args}) if len(nodes) >= maxPlanNodes { break } } sort.SliceStable(nodes, func(i, j int) bool { return nodes[i].Intent < nodes[j].Intent }) return Plan{Nodes: nodes} } // proposeFn is the proposer signature, injected so the unit tests can drive self consistency with a stub and // no live plane. proposeStablePlan calls it k times. type proposeFn func(ctx context.Context, question string, avail availableParams) ([]string, error) // proposeStablePlan samples the plan k times and runs only the STABLE intent set: the nodes that appear in a // MAJORITY of the k validated plans, in the deterministic order. If the plans disagree entirely (no majority // node), it returns an empty plan so the caller falls back to the deterministic single intent or RAG path. // The proposer is injected so the tests drive it with a stub and no live plane. func proposeStablePlan(ctx context.Context, question string, avail availableParams, k int, propose proposeFn) Plan { if k < 1 { k = 1 } counts := map[string]int{} for i := 0; i < k; i++ { names, err := propose(ctx, question, avail) if err != nil { continue } plan := validatePlan(names, avail) // Count each node once per validated plan (the plan already deduplicates names). for _, node := range plan.Nodes { counts[node.Intent]++ } } majority := (k / 2) + 1 stable := make([]string, 0, len(counts)) for name, c := range counts { if c >= majority { stable = append(stable, name) } } if len(stable) == 0 { return Plan{} // no majority node: fall back to the deterministic single intent or RAG path } return validatePlan(stable, avail) } // describeAvail renders the available parameter KINDS (never the values) for the proposer prompt and is also // the shape the audit records. It tells the model which slots can bind without ever revealing a value (a // requirement id, an agency name, a UEI), so a value can never leak into the prompt or the audit. func describeAvail(avail availableParams) string { kinds := availableKinds(avail) if len(kinds) == 0 { return "none" } return strings.Join(kinds, ", ") } // availableKinds returns the sorted list of param KIND names the question carries (never the values). It is // the proposer prompt's available list and the audit's available_kinds field; it holds no value and no PII. func availableKinds(avail availableParams) []string { var kinds []string if avail.UUIDOK { kinds = append(kinds, "uuid") } if avail.AgencyOK { kinds = append(kinds, "agency") } if avail.NAICSOK { kinds = append(kinds, "naics") } if avail.HeldOK { kinds = append(kinds, "held_clearance") } if avail.UEIOK { kinds = append(kinds, "uei") } if avail.CompartmentsOK { kinds = append(kinds, "compartments") } if avail.WindowMonths > 0 { kinds = append(kinds, "window_months") } if avail.OccupationOK { kinds = append(kinds, "occupation_code") } if avail.AreaOK { kinds = append(kinds, "area") } sort.Strings(kinds) return kinds } // registryCatalog lists every Registry intent NAME, its one line Description, and its required param names // for the proposer prompt, in Registry order. The model sees only names, descriptions, and param names, the // closed menu it selects from; it never sees SQL. func registryCatalog() string { var b strings.Builder for _, it := range Registry { b.WriteString("- ") b.WriteString(it.Name) if len(it.Params) > 0 { names := make([]string, len(it.Params)) for i, p := range it.Params { names[i] = p.Name } b.WriteString(" (params: ") b.WriteString(strings.Join(names, ", ")) b.WriteString(")") } else { b.WriteString(" (no params)") } b.WriteString(": ") b.WriteString(it.Description) b.WriteString("\n") } return b.String() } // parseIntentNames parses the model's output into a list of candidate intent names. It accepts a bare JSON // array of strings (the instructed shape) and tolerates surrounding prose by extracting the first bracketed // array. On any parse failure it returns nil, so proposePlan returns an empty selection and the caller falls // back to RAG. func parseIntentNames(text string) []string { text = strings.TrimSpace(text) start := strings.IndexByte(text, '[') end := strings.LastIndexByte(text, ']') if start < 0 || end < 0 || end <= start { return nil } var names []string if err := json.Unmarshal([]byte(text[start:end+1]), &names); err != nil { return nil } return names } // itoa is a tiny no dependency integer to string for the prompt; strconv would do but this keeps the prompt // builder self contained and avoids an import for one cap value. func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } if neg { i-- buf[i] = '-' } return string(buf[i:]) } // runPlan executes each surviving node through its CORRECT existing path, collects the union of rendered // evidence, and returns the union, the suppressed cell count across bench nodes, and the ordered names that // actually ran. A bench intent runs through the suppressing executor (runBenchIntent) rendered with // RenderCount, so a suppressed cell is the sentinel and never a raw sub k number; the G6 capture composition // runs through answerCapturePicture; every other graph or TAG intent runs through ExecuteIntent rendered // with renderTable. A node that errors is dropped from the union with a logged note (the plan degrades, // never hangs). The planner adds no new query: every node is an existing guarded path. func (o *Orchestrator) runPlan(ctx context.Context, plan Plan) (evidence []string, suppressed int, ran []string, err error) { k := ConfiguredSuppressionThreshold() for _, node := range plan.Nodes { switch { case node.Intent == IntentCapturePictureForRequirement: // The G6 capture composition is itself a person touching bench intent and already composed. Run it // through its existing path and fold its deterministic answer into the union as one evidence line. id, idErr := benchIDArg(node.Args) if idErr != nil { continue // a malformed id drops the node, the plan degrades } resp, cErr := o.answerCapturePicture(ctx, id) if cErr != nil || resp == nil || resp.Refused { continue } evidence = append(evidence, "capture_picture_for_requirement: "+resp.Answer) ran = append(ran, node.Intent) case IsModelIntent(node.Intent): // A MAG (model backed) intent runs through answerModelIntent (NOT the raw ExecuteIntent path, which // hard rejects a model intent). It gathers its features through the guarded feature source intents and // scores a calibrated probability in decimal; its deterministic answer folds into the union as one // evidence line. It computes an opportunity level number, never a per candidate score. A node that // errors or refuses drops from the union, the plan degrades. resp, mErr := o.answerModelIntent(ctx, "", node.Intent, node.Args) if mErr != nil || resp == nil || resp.Refused { continue } evidence = append(evidence, node.Intent+": "+resp.Answer) ran = append(ran, node.Intent) case IsBenchIntent(node.Intent): // A bench intent runs through the suppressing executor. The bench plane reads RLS protected person // data, so it REQUIRES a resolved authenticated tenant context (the same gate answerBench enforces); // without one the node drops rather than relying only on the RLS empty result fail safe. Every cell // renders through RenderCount, so a suppressed cell is the sentinel and no sub k number reaches the // union. if !guardTenantForBench(ctx) { continue } labels, cells, bErr := o.runBenchIntent(ctx, node.Intent, node.Args, k) if bErr != nil { continue // the node drops, the plan degrades, never hangs } columns := []string{"bucket", "headcount"} rows := make([][]any, len(cells)) for i, c := range cells { if c.Suppressed { suppressed++ } rows[i] = []any{labels[i], RenderCount(c)} } evidence = append(evidence, node.Intent+": "+renderTable(node.Intent, columns, rows)) ran = append(ran, node.Intent) default: // Every other graph or TAG intent runs through ExecuteIntent (re guarded, RLS scoped) and renders // with the existing deterministic table rendering. intent, ok := IntentByName(node.Intent) if !ok { continue } res, eErr := ExecuteIntent(ctx, o.db, intent, node.Args) if eErr != nil { continue // the node drops, the plan degrades, never hangs } evidence = append(evidence, node.Intent+": "+renderTable(intent.Description, res.Columns, res.Rows)) ran = append(ran, node.Intent) } } return evidence, suppressed, ran, nil } // AnswerPlan answers a COMPOUND cross plane question by planning over the closed Registry. It runs the EEO // and protected attribute guards FIRST (refuse on either), extracts the available params, gets the stable // plan (sampled planSelfConsistencyK times, only the majority intent set), and falls back to AnswerRAG // unchanged when the plan is empty (so a non compound or unbindable question is byte for byte unchanged). // Otherwise it runs the plan, narrates ONE answer over the union of node evidence through o.generate (state // only what the union supports, report a suppressed bench count as withheld and never guess a number for it, // never compute a match score), and o.finalize behind the faithfulness gate. It emits the counts only // aedt.ai.plan_answered audit: the ordered ran intent NAMES, the available param KINDS (never values), the // node count, the suppressed count, the faithfulness verdict, and the self consistency outcome. The audit // carries NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text, and NO question. func (o *Orchestrator) AnswerPlan(ctx context.Context, question string) (*Response, error) { if ForbidEEO(question) != nil || MentionsProtectedAttribute(question) { return o.refuse(ctx, RoutePlan, "the question references prohibited demographic data"), nil } avail := extractAvailableParams(question) plan := proposeStablePlan(ctx, question, avail, planSelfConsistencyK, o.proposePlan) consistency := "stable" if len(plan.Nodes) == 0 { // No stable plan: a non compound or unbindable question is unchanged, it falls back to RAG. return o.AnswerRAG(ctx, question, 8) } evidence, suppressed, ran, err := o.runPlan(ctx, plan) if err != nil { return nil, err } if len(evidence) == 0 { // Every node dropped (errors or empty results): degrade to RAG rather than narrate over nothing. return o.AnswerRAG(ctx, question, 8) } deterministic := renderPlanUnion(ran, evidence) model, genErr := o.generate(ctx, "State ONE integrated answer using ONLY the listed evidence lines as evidence, one line per intent that ran. "+ "State only what the union of the evidence supports. A headcount shown as "+SuppressionSentinel+" was "+ "withheld to protect individuals: say it was withheld and NEVER guess or state a number for it. Never "+ "compute a match score and never assign a band. Do not invent an agency, a UEI, a dollar figure, a "+ "recompete, or a count the evidence does not contain.", "QUESTION: "+question+"\n\nEVIDENCE:\n"+strings.Join(evidence, "\n")) resp := o.finalize(ctx, RoutePlan, model, genErr, deterministic, evidence) // Counts only plan trace audit: the ordered ran names (the closed set, not PII), the available param KINDS // (not values), the node count, the suppressed cell count, the faithfulness verdict, and the self // consistency outcome. NO param value, NO candidate id, NO raw count, NO requirement id, NO answer text, // NO question. o.audit(ctx, "aedt.ai.plan_answered", map[string]any{ "route": RoutePlan, "intents": ran, "available_kinds": availableKinds(avail), "nodes": len(plan.Nodes), "suppressed": suppressed, "faithful": resp.Faithful, "rationale_source": resp.RationaleSource, "self_consistency": consistency, }) return resp, nil } // renderPlanUnion builds the deterministic union answer the planner ships when the model prose is withheld: // one line per intent that ran, in the deterministic plan order, each line already its node's suppressed // safe rendering. It names no candidate and computes no score; every bench count it contains already came // through RenderCount, so the deterministic fallback is itself suppression safe. func renderPlanUnion(ran []string, evidence []string) string { var b strings.Builder b.WriteString("Integrated plan answer across the planes. ") b.WriteString(strings.Join(evidence, " ")) return b.String() } // benchPlaneWords are the conservative bench plane signal: a question that names one of these is asking about // the caller's own bench (the suppressed person touching plane). Pairing a bench word with a market word is // the compound signal that routes to the planner. var benchPlaneWords = []string{"bench", "our staff", "our people", "who can staff", "clearance coverage", "structural gap"} // marketPlaneWords are the conservative market plane signal: a question that names one of these is asking // about the public market plane (agencies, awards, incumbents, recompetes, competitors, forecasts). var marketPlaneWords = []string{"market", "incumbent", "competitor", "recompete", "award", "agency", "forecast", "vendor", "teaming", "buying pattern", "spend"} // isCompoundQuestion is the CONSERVATIVE compound signal: a question is compound when it names a bench plane // word AND a market plane word (it spans two planes), so the single shot keyword router cannot answer it with // one intent. It is deliberately narrow: when unsure it returns false so the question falls through to the // existing single shot path and a working route is never regressed. It never refuses; the EEO guards run in // AnswerPlan. func isCompoundQuestion(question string) bool { q := strings.ToLower(question) return containsAny(q, benchPlaneWords) && containsAny(q, marketPlaneWords) } // guardTenantForBench reports whether the context carries a resolved authenticated tenant, the gate a bench // node needs before it runs a person touching read. The runner drops a bench node without one so the plan // path keeps the same auth posture answerBench enforces, rather than relying only on the RLS empty result // fail safe. The G6 capture composition checks the same gate inside answerCapturePicture. func guardTenantForBench(ctx context.Context) bool { _, ok := tenant.OrgFromContext(ctx) return ok }