From a37dd8d092684334285aab23c9c8008e027fa00b Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 21:25:50 -0400 Subject: [PATCH 1/4] docs(product): per-agent model, role, and instruction overrides design (RIG-2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design record for per-agent {model, role, instruction_slice} overrides in Compass, so model-eval per-role recommendations (RIG-2562) can roll out incrementally within one agent tree (implementor ladder on a candidate model while Managers/designers stay on the known-good tier). Two-layer override contract, frozen with the harness lane (RIG-2935): Layer 1 is the shared portable tuple (bounded SELECTION against server-provisioned config, no free-text injection surface); Layer 2 is the Compass-only operator escape hatch (free-text AUTHORING on the server-authoritative provision path, never agent-facing). Drafted at the design tier and design-critic red-teamed (0 blockers, 6 should-fix + 3 consider, all folded). One load-bearing policy fork routed to Matt as RIG-2937 (may a supervisor agent compose its subtree's overrides). Status: Draft. Merge-freeze held pending the harness RIG-2935 frozen-Interfaces confirm + model-evals confirm, per cross-lane sequencing. Ledger-impact: deferred to freeze — 5 DL rows staged in the record's Ledger delta section, applied with the Draft to Active flip on merge-freeze. Co-authored-by: Matt Wilkinson --- .../compass-per-agent-overrides/design.md | 827 ++++++++++++++++++ 1 file changed, 827 insertions(+) create mode 100644 docs/designs/product/compass-per-agent-overrides/design.md diff --git a/docs/designs/product/compass-per-agent-overrides/design.md b/docs/designs/product/compass-per-agent-overrides/design.md new file mode 100644 index 000000000..f8a2d7a2b --- /dev/null +++ b/docs/designs/product/compass-per-agent-overrides/design.md @@ -0,0 +1,827 @@ +# Compass per-agent model, role, and instruction overrides + +Status: Draft + +Refs: RIG-2936 (this record). Sibling: RIG-2935 (harness/OMP wave-spawn — owns +the frozen shared override tuple this record consumes). Builds on: RIG-2673 +(`compass-agent-org-mgmt-tools/design.md`, the parent-choosable `role`/`persona` +spawn fields), the frozen spawn/despawn record +(`compass-agent-spawn-despawn/design.md`), and the model-eval suite (RIG-2562) +whose per-role recommendations this rollout mechanism deploys. + +## Problem / Intent + +The model-eval suite (RIG-2562) produces per-role model recommendations +(Manager / implementor / designer / design-critic / reviewer, each at a chosen +thinking level), but Compass cannot deploy a different model + role + +instruction set per agent within its tree: the model is Runner-global, role and +persona are server-authoritative from the store with no agent-facing setter on +the shipped wire, and there is no per-agent instruction hook at all. This +record makes the {model, thinking-level, role, instruction-slice} tuple +configurable per agent, parent-choosable at spawn, so eval output can roll out +incrementally (implementor lane first on a candidate model, Managers and +designers staying on the known-good tier). + +## Approach + +### Grounding: what is per-agent today, and what is not + +- **Model is Runner-global.** `AgentModel` is "the model selector handed to + every agent this Runner starts (the agent's COMPASS_MODEL)" + (`go/internal/runner/runner.go:48-51`), threaded to + `AgentHostConfig.AgentModel` (`go/internal/runner/host.go:117-120`) and + applied identically to every container: "The model is Runner-wide config; + everything else is per-container" (`host.go:875-877`), via `agentEnv`: + + ```go + // go/internal/runner/host.go:878-887 + func (h *agentHost) agentEnv(handle *runtime.AgentHandle) AgentEnv { + return AgentEnv{ + UID: handle.WorkspaceUID(), + HomeDir: handle.HomeDir(), + Workdir: handle.CheckoutDir(), + Model: h.model, + Persona: handle.Persona(), + Role: handle.Role(), + } + } + ``` + + The exec injects it omitted-when-empty: `if e.Model != "" { + spec.Env["COMPASS_MODEL"] = e.Model }` + (`go/internal/runner/agent_exec.go:83-85`), and the in-container entrypoint + reads it opaquely — `resolveModelSelector(env)` returns + `env.COMPASS_MODEL?.trim()` "as an opaque pattern string for + `createAgentSession` to resolve against its own model registry" + (`packages/compass-agent/src/cli.ts:135-149`), forwarded as `modelPattern` + (`cli.ts:850`). + +- **Role and persona are per-agent but server-authoritative.** + `ProvisionAgentWorkspaceRequest.persona`/`.role` are documented + "SERVER-AUTHORITATIVE: the Server is expected to populate this by reading + AgentAccount.role from the store on the provision path and to overwrite any + client-supplied value, so a caller cannot inject a role prompt" + (`proto/compass/v1/compass.proto:589-600`; persona at `:579-588`). The + overwrite is live code on the operator provision path: + + ```go + // go/server/service.go:159-165 + if acc.IsAgent() { + req.Msg.Persona = acc.Agent.Persona + req.Msg.Role = acc.Agent.Role + } else { + req.Msg.Persona = "" + req.Msg.Role = "" + } + ``` + + pinned by tests (`go/server/service_placement_pgtest_test.go:330-366` + persona, `:404-441` role: "the server must overwrite the client value"). On + the agent-initiated spawn path the values are hardcoded empty at creation: + "Persona and role are server-authoritative and empty on spawn + (SpawnPeerRequest carries neither)" with `Persona: "", Role: ""` in the + `store.CreateAgent` literal (`go/server/lifecycle.go:180-189`), then + threaded from the **created store account**, never the request: + `l.provisionAndStart(ctx, created.ID, created.Agent.Persona, + created.Agent.Role, req)` (`lifecycle.go:197`). + +- **The agent-facing spawn tool cannot configure a peer.** The shipped + `SpawnPeerRequest` carries only `handle = 1`, `display_name = 2`, + `reserved 3; reserved "initial_prompt";`, `client_request_id = 4` + (`proto/compass/v1/agent_gateway.proto:167-173`), and the tool schema is + handle + optional display_name + (`packages/compass-agent/src/lifecycle.ts:77-88`). Field 3 + (`initial_prompt`) is **reserved, not reclaimable**: DL-187 removed it + "from the whole contract … numbers AND names reserved" + (`docs/designs/DECISIONS.md` DL-187) because it was dead plumbing the + Runner never delivered + (`docs/designs/platform/compass-initial-prompt-removal.md:14-16`), replaced + by the channel-message first turn. New fields take fresh numbers. + +- **The instruction surfaces are the config mount.** The role's block-0 comes + from `prompts//SYSTEM.md` read by `readMountedRolePrompt` + (traversal-guarded, `packages/compass-agent/src/config-reader.ts:370-390`), + injected as `customSystemPrompt` (REPLACES block-0) while persona appends + after (`cli.ts:905-929`). Fleet rules load from the mount's `rules/` and + pass as the SDK `rules` array (`config-reader.ts:404-408`, + `cli.ts:892-902`). The model registry loads from the mount's top-level + `models.yml`, symlinked into `$HOME/.omp/agent/models.yml` for the SDK's + ModelRegistry (`config-reader.ts:392-401`, `cli.ts:788`) — "no + `createAgentSession` object seam for the ModelRegistry yet (flagged gap)" + (`config-reader.ts:486-489`). The mount itself is server-provisioned: + bundles flow store door → Runner `ConfigMaterializer` unpack → read-only + container mount with atomic `current` flips + (`go/internal/runner/config_materialize.go:3-11`). + +### The two-layer override contract (frozen with RIG-2935) + +The contract is expressed as **two layers with these exact labels** — the same +structure the harness RIG-2935 record freezes, so both records read +identically. + +#### Layer 1 — shared portable tuple + +The frozen cross-lane contract. Harness owns the canonical definition in +RIG-2935's Interfaces section; Compass consumes and references it verbatim; +**this is what eval output maps to**, portable across the Compass spawn path +and the OMP/zellij wave-spawn without translation. The frozen shape, +byte-identical to RIG-2935's block: + +```ts +type AgentOverride = { + model?: string; // "provider/model:thinking" selector, common grammar both lanes resolve + role?: string; // modelRoles/persona tier label (plain string; Compass applies its own policy) + instruction_slice?: string; // NAMED ref, never inline text; APPEND-scoped; keyed by role-or-model +}; +``` + +- `model` uses the existing opaque `COMPASS_MODEL` selector grammar (the + entrypoint "deliberately does not parse provider/id itself", + `cli.ts:137-139`), thinking level riding the selector suffix + (e.g. `litellm/claude-opus:xhigh`), resolved against `models.yml`. +- `role` is the role label / modelRoles tier — on Compass the + `prompts//SYSTEM.md` block-0 selector already shipped by the + role pipeline. +- `instruction_slice` is a **NAMED REF, APPEND-scoped**. No inline text, no + mode field, no `replace` in the portable tuple. Each lane resolves + ref → text via its own surface: on Compass the ref resolves against the + server-provisioned config mount (`config-reader.ts`), composed at the + append surface beside the role prompt. +- **Key property: Layer 1 has NO prompt-injection surface BY CONSTRUCTION.** + A ref names a mount/version-controlled slice file the operator/server + provisioned; an agent-caller populating `instruction_slice` can only POINT + AT a pre-provisioned slice, never inject free text. The agent SELECTS; it + never AUTHORS. The anti-injection invariant is preserved by the shape + itself, not by a consumer-side restriction. +- Layer 1 is what the agent-facing spawn path (`agents_spawn_peer` / + `SpawnPeerRequest`) carries: model + role + instruction_slice as bounded + selections. + +#### Layer 2 — Compass per-lane escape hatch + +NOT portable, NOT eval-driven, operator/server-authoritative path ONLY. + +**The boundary between the layers is AUTHORING vs SELECTION, not append vs +replace.** Layer 1 is bounded SELECTION: a field names a +server-provisioned artifact and the agent picks from a pre-vetted set. That +set may include replace-composed content — `role` selects +`prompts//SYSTEM.md`, which REPLACES block-0 as `customSystemPrompt` +(`packages/compass-agent/src/cli.ts:918-921`) — or append-composed content +(`instruction_slice`, appended after the fleet rules). Replace-scoped +SELECTION therefore already lives in Layer 1, and after this record composes +with RIG-2673's `role = 5` field, the agent-facing spawn path DOES invoke a +replace-composed prompt (via role). What is walled off from the agent-facing +path is free-text AUTHORING, in either composition mode: + +- Inline instruction text (a caller supplying prompt bytes rather than a ref) + and an inline block-0 swap (replace-mode composition from caller-supplied + text rather than a role label) live in Layer 2 only. +- On Compass these ride the `ProvisionAgentWorkspaceRequest` path, which is + ALREADY server-authoritative today for role/persona — "the Server … will + overwrite any client-supplied value" + (`packages/compass-agent/src/gen/compass/v1/compass_pb.ts:1072-1102`; proto + source `compass.proto:579-600`; live overwrite `service.go:159-165`). +- The agent-facing spawn path NEVER lets a caller AUTHOR prompt text. Layer 2 + is the Compass operator/server superset, explicitly out of the shared + tuple — matching this design's rejection of full-corpus forks in the + portable path. + +### 1. Per-agent model + +The parent names the child's model at spawn; the value is **stored at +creation** and threaded store → provision → runner → container env, following +exactly the persona/role pipeline so store-as-source-of-record is preserved: + +- `SpawnPeerRequest` gains `string model = 7` (fields 5/6 are claimed by + RIG-2673's `role`/`persona`; 3 is reserved). Optional: empty = no per-agent + pin, the child falls back to the Runner-global default. This fallback IS + acceptance criterion (a): implementors ladder onto a candidate model via an + explicit per-spawn selector while Managers/designers spawn with `model` + empty and stay on the known-good Runner-configured tier. +- `store.NewAgent` gains `Model string` beside `Persona`/`Role` + (`go/internal/store/inputs.go:20-37`); `agent_accounts` gains a `model` + column; `SpawnAsAccount` writes `Model: req.GetModel()` at `CreateAgent` + and `provisionAndStart` threads `created.Agent.Model` — the request value + is never forwarded directly, matching `lifecycle.go:197`. +- `ProvisionAgentWorkspaceRequest` gains `string model = 5`, + SERVER-AUTHORITATIVE with the same overwrite-from-store contract as + persona/role: `service.go:159-165` grows `req.Msg.Model = acc.Agent.Model` + (and `""` for non-agent accounts), and the pgtest overwrite pair + (`service_placement_pgtest_test.go:330-366`/`:404-441`) gains a model + sibling. +- `runtime.AgentSpec` gains `Model string` beside `Persona`/`Role` + (`go/internal/runtime/agent.go:44-51`) with an `AgentHandle.Model()` + accessor (mirroring `agent.go:84-90`); the Runner's `SpecBuilder.BuildSpec` + copies it from the request. +- `agentEnv` (`host.go:878-887`) becomes per-agent-first with Runner-global + fallback: + + ```go + model := handle.Model() + if model == "" { + model = h.model + } + ``` + + `AgentEnv.Model` and the `execSpec` omitted-when-empty injection + (`agent_exec.go:83-85`) are unchanged — the container still just reads + `COMPASS_MODEL`. +- **Registry resolution is unchanged by construction.** The per-agent + selector lands in the same `COMPASS_MODEL` env var and flows to + `modelPattern: resolveModelSelector(env)` (`cli.ts:850`), resolving against + the same mount-symlinked `models.yml` registry (`cli.ts:788`). A selector + absent from the registry fails exactly as a bad Runner-global selector does + today (the RIG-2654 boot-model failure surface) — no new failure mode, but + the spawn tool description must tell the parent the selector must exist in + the fleet `models.yml`. + +### 2. Per-agent role + +Parent-choosable role at spawn is **already designed** by RIG-2673 +(`compass-agent-org-mgmt-tools/design.md:107-134`): `role = 5` / `persona = 6` +on `SpawnPeerRequest`, both required at the tool schema, with `SpawnAsAccount` +dropping its `Persona: ""` / `Role: ""` hardcodes and the store keeping +source-of-record (its T4, `design.md:258-265`). This record does not redesign +that seam; it composes with it: + +- The precedent posture was ruled by Matt on RIG-2673 (its OQ-1, comment + `5f7a13b3`, 2026-08-24): **no role allowlist** — "agent acts as owning + user, ACLs later"; the role label only selects + `config/prompts//SYSTEM.md` under the caller's own owner, "so the + blast radius is prompt selection, not privilege escalation" + (`design.md:302`). Role is a bounded selection — a Layer-1 field. +- Set-at-creation-only semantics carry over verbatim (`design.md:215`): a + spawn resolving to an existing handle is idempotent success under the + **stored** role/model/slice values, ignoring the request's — both + non-create paths already thread `existing.Agent.*` + (`lifecycle.go:322-331`). +- Letting a parent agent set the child's role is still a POLICY change from + today's server-authoritative-from-AgentAccount model + (`compass_pb.ts:1090-1094`) — that policy question is OQ-1 below, not + silently decided here. + +### 3. Instruction-slice hook + +The mechanism is **mount-delivered instruction slices, selected per agent at +spawn by reference (Layer 1), with inline text and replace-mode as the +operator-only Layer-2 escape hatch**: + +- The fleet config bundle gains a top-level `instructions/` dir beside + `skills/`, `rules/`, and `agents/`: flat `instructions/.md` files, + each one vetted instruction variant, shipped through the existing bundle + pipeline (store door validate → `ConfigMaterializer` unpack → read-only + container mount). The bundle top-dir whitelist is a STRUCTURAL TWIN kept + "textually in lockstep": `instructions` must be added to BOTH the store + door (`go/internal/store/agent_config.go:35-64` — the `topDir*` constants, + the `configBundleTopDirs` map, and the `configMemberParts` rejection string + at `:492-494`) AND the runner (`go/internal/runner/config_materialize.go:61-78` + — the twin constants + `configTopDirs` map). The store door additionally + needs a flat-member validation arm mirroring `rules` + (`agent_config.go:520-527`: `instructions/.md`, len==2, `.md` only) + and its operator info-view bucket — without the STORE-side change a bundle + carrying `instructions/` is rejected at upload before any container sees it, + so criterion (c) is unshippable (this is a compass-server task, T6's + cross-lane note below). Variants are distinct slice names + (`planning-evidence-strict.md`, `planning-evidence-relaxed.md`), keyed by + role or model per the eval's dial — the eval provisions the variant files + once; every agent shares the same corpus and **no agent forks it**. +- `SpawnPeerRequest` gains `string instruction_slice = 8` — the Layer-1 + named ref, nothing more. No mode, no text on the agent-facing wire. +- Stored at creation (`agent_accounts.instruction_slice`), threaded store → + provision → runner env like role: `ProvisionAgentWorkspaceRequest. + instruction_slice = 6` (server-authoritative, overwrite-from-store), an + `AgentSpec` field, and env var `COMPASS_INSTRUCTION_SLICE`, + omitted-when-empty in `execSpec` like the four existing vars + (`agent_exec.go:83-94`). +- In-container consumption composes with the existing append seam: + `config-reader.ts` gains `readMountedInstructionSlice` mirroring + `readMountedRolePrompt`'s tolerant, traversal-guarded shape + (`config-reader.ts:370-390` — same `/[/\\]|\.\./` label guard); the slice + body joins the SDK `rules` array `main()` already passes + (`cli.ts:892-902`), landing as one more always-apply rule appended after + the fleet rules. Unset or unresolvable ref → today's behavior exactly. +- **Layer 2 (escape hatch, out of the shared tuple) is free-text AUTHORING, + not the replace COMPOSITION mode.** Replace-scoped SELECTION already lives + in Layer 1 — `role` selects `prompts//SYSTEM.md`, which replaces + block-0 as `customSystemPrompt` (`cli.ts:918-921`) — so the agent-facing + path does invoke replace-composed prompts via role. Layer 2 is only what a + caller would AUTHOR: inline instruction text, and an inline block-0 swap + built from caller-supplied text rather than a role label. Those ride only + the operator/server-authoritative provision path. An inline-text provision + field is deferred until an operator need materializes (OQ-3); nothing + agent-facing ever lets a caller author prompt bytes. +- **Acceptance criterion (c) is satisfied by construction**: the + planning-evidence dial (or message-origin tagging, or the cross-family + review-of-high-severity-claims instruction) ships as + `instructions/.md` variants in the one shared bundle; two sibling + implementors on different candidate models run different slices purely by + each spawn's `instruction_slice` ref — zero corpus fork. + +### 4. Shared schema with the harness (RIG-2935) + +The Layer-1 tuple is the settled, frozen shared contract (harness confirmed; +their RIG-2935 record freezes it in its Interfaces section). Division of +labor: + +- **Harness (RIG-2935)** owns the tuple definition and its wave-spawn + consumer (operator-keystroke launch). +- **Compass (this record)** owns the consumer mapping: tuple `model` → + `SpawnPeerRequest.model` → `COMPASS_MODEL`; tuple `role` → + `SpawnPeerRequest.role` (RIG-2673) → `COMPASS_ROLE`; tuple + `instruction_slice` → `SpawnPeerRequest.instruction_slice` → + `COMPASS_INSTRUCTION_SLICE` → mount resolution — plus the Layer-2 + escape-hatch surface, which is Compass-only and out of the tuple. + +An eval rollout artifact (role → Layer-1 tuple map) is therefore consumable +by both surfaces without translation. + +### 5. Cross-family review constraint + +`Reviewer.family ≠ Implementor.family` and `Design-critic.family ≠ +Designer.family` are **wave-level composition config, NOT a per-agent tuple +field** — no `SpawnPeerRequest` field carries them. On Compass, "wave +composition" = the supervisor agent's tree-building (its spawn choices), so +the constraint is modeled as a tree-composition concern that consumes +harness's wave-composition rule: + +- The family is a pure function of the Layer-1 `model` selector (the + `provider/model` prefix resolved against `models.yml`), so any holder of a + role → tuple map can check the constraint statically before spawning. +- Harness (RIG-2935) owns the wave-composition rule + the rollout-artifact + lint; on Compass the supervisor's spawn guidance (the Manager prompt/skill + copy in the RIG-2673 T6 lane) states the same rule as a standing + tree-composition constraint: when composing a review pair, spawn the + reviewer with a `model` whose family differs from the implementor's, ditto + design-critic vs designer. +- **Enforcement strength differs per lane, and this is a deliberate + weakening on Compass.** On the harness side the rollout-artifact lint is a + MACHINE check. On Compass the "tree-composition layer" is the supervisor + agent's spawn guidance — a PROMPT given to the very agent OQ-1's threat + model treats as potentially compromised — so criterion (b) is delivered as + ADVISORY, not enforced, and no Compass component runs the check. Criterion + (b) ("cross-family constraint expressible") is met by the letter; whether + advisory-only is acceptable on Compass is entangled with OQ-1 (a supervisor + composing its own review pair is exactly where the constraint matters most + and enforcement is softest — see the composition attack in OQ-1). +- **Why not a server-side spawn check** (correcting the earlier rationale): a + server check IS expressible on this record's own data — after RIG-2673 the + server knows the child's `role` (wire, `role = 5`) and `model` (wire, + `model = 7`), the spawner (`ParentAgentID: caller` set at creation, + `go/server/lifecycle.go:190-193`), and the parent's `model` (a store column + per T2) — so `family(child.model) ≠ family(parent.model) when child.role ∈ + {reviewer, design-critic}` computes with data already present. It is + rejected as the ENFORCEMENT point not because the relation is missing but + because parent-of is an UNRELIABLE PROXY for reviewer-of: a Manager may + spawn a reviewer for a sibling implementor, where the parent-family + comparison is the wrong comparand and would false-positive. A non-blocking + WARN-level family-match flag on reviewer-role spawns is a cheap additive + backstop, deferred (OQ-2). + +## Alternatives considered + +- **Keep the model Runner-global and roll out per-Runner.** Rejected: one + Runner hosts the whole tree in the single-Runner deployment, so per-Runner + granularity cannot express "implementor on candidate, Manager on + known-good" within one tree — the exact eval rollout shape RIG-2936 needs. +- **A parallel per-agent config RPC (operator sets overrides on the account, + spawn stays bare).** Rejected as the primary path: it forks the creation + path RIG-2673 unifies (spawn is "the single agent-facing agent-creation + path", its DL-new-2), forces a two-step spawn-then-configure, and + contradicts the Manager-builds-the-tree model (DL-253: no human-facing + create/start control). It survives only as OQ-1's alternative + (operator-composed waves). +- **Reclaim field 3 (`initial_prompt`) for the new payload.** Rejected: + `reserved 3; reserved "initial_prompt";` (`agent_gateway.proto:170-171`) + exists precisely so the number/name are never reused (DL-187); new fields + take 7/8. +- **Inline instruction text in the portable tuple (v0 shape, `{mode, slice, + text}`).** Rejected during the RIG-2935 alignment: inline text from a + parent agent is a prompt-injection surface that would reverse the + server-authoritative anti-injection invariant. The frozen v1 tuple carries + a named ref only — the injection surface is removed by the contract shape, + and inline text moves to the Compass-only Layer-2 operator path. +- **Free-text `replace` (caller-authored block-0) in the portable tuple.** + Rejected (settled with harness): a caller supplying block-0 bytes is + free-text authoring, a per-lane operator escape hatch deliberately out of + the portable eval-driven contract. Replace-scoped SELECTION (a `role` label + choosing a server-provisioned `SYSTEM.md`) is a different thing and stays in + Layer 1 — the wall is authoring vs selection, not append vs replace (§3). +- **Operator-policy-bounded selection (the middle between full agent + authority and operator-composed-waves-only).** The tuple stays + agent-settable at spawn (preserving per-tree, no-human-clicks rollout — the + reason the operator-only path is rejected), but the SELECTABLE SET is an + operator artifact: e.g. a `selectable:` policy in the fleet bundle (which + already ships `models.yml` through the exact same store-door → mount + pipeline, `agent_config.go:487-490` / `config-reader.ts:392-401`) mapping + role → allowed models/slices, checked server-side at spawn or agent-side at + the tool. This directly answers the OQ-1 composition attack — a supervisor + cannot pair a reviewer role with a model/slice not vetted FOR review — at + near-zero wire cost (no new fields; one bundle file + one check) and it + degrades gracefully: an absent policy file = the full-authority posture, so + it can be a follow-up rather than a blocker. Carried into OQ-1 as a rider on + YES, not a separate pole. +- **A per-agent materialized instruction copy (resolve ref → text at + provision, deliver per-container).** Rejected: the config mount already + delivers versioned, atomically-flipped instruction files to every container + (`config_materialize.go:3-11`); a per-agent copy adds a second delivery + path for the same bytes and breaks the no-fork auditability of the shared + corpus. + +## Global Constraints + +- **Additive to the frozen spawn/despawn wire contract.** No wire-breaking + change to `SpawnPeerRequest` existing fields (`handle = 1`, + `display_name = 2`, `client_request_id = 4`); field 3 is + `reserved`/`initial_prompt` (DL-187) and is NOT reclaimed; new fields take + fresh numbers (`role = 5`/`persona = 6` per RIG-2673, `model = 7`, + `instruction_slice = 8`). +- **Owner inheritance invariant preserved.** Owner is caller-derived, never a + request field: "Spawn creates the new peer under the CALLER'S OWNER — never + the caller agent itself, never the bootstrap admin" + (`go/server/lifecycle.go:16-18`; spawn-despawn record §Identity and authz). + Nothing in this record adds an owner-shaped field. +- **The anti-injection posture is explicitly accounted for — but "no + injection" is not "no harm".** What changes: today a caller cannot + influence a peer's prompt at all (server-authoritative from store, + `compass.proto:581-600`, enforced at `service.go:159-165`); after this + record a parent agent SELECTS the child's role prompt, model, and + instruction slice. What is preserved against INJECTION: every Layer-1 field + is a bounded selection resolving against server-provisioned config — a ref + can point at, never author, prompt text — so no free-text injection surface + opens; the caller-owner fence bounds the new peer to the caller's own owner; + store-as-source-of-record is kept (provision always threads the STORE + values; the operator-path overwrite tests stay green). What is NOT bounded + by that fence: the SELECTABLE SET itself. The config bundle is + FLEET-GLOBAL — one bundle materialized into every container — and + `instructions/`/`prompts/` are flat fleet-wide namespaces, so a caller can + select ANY vetted slice/role/model in the fleet for its child, including one + the operator vetted for a different context (a relaxed-evidence slice + intended for a throwaway lane, selected for a production reviewer). The + mechanism enforces no keying between slice and role/model. This is the + composition surface behind OQ-1's oversight-degradation attack; it is a + POLICY change (parent-agent composition authority), Matt's call, and the + operator-policy-bounded-selection alternative (§Alternatives) is its cheap + mitigation. Inline free text (Layer 2) remains operator/server-authoritative + throughout. +- **Set-at-creation-only.** All override fields follow RIG-2673's semantics + rider: an idempotent re-spawn or resume runs under the STORED values, never + the retry request's (`lifecycle.go:322-331`). +- **Server-authoritative provision threading.** Every new + `ProvisionAgentWorkspaceRequest` field carries the same + SERVER-AUTHORITATIVE overwrite-from-store contract and test shape as + persona/role (`service_placement_pgtest_test.go:330-366`, `:404-441`). +- **Selector grammar is opaque end to end.** The model selector (including + thinking suffix) is never parsed outside the SDK ModelRegistry + (`cli.ts:137-139`); no Compass component splits provider/model/thinking. +- **Traversal-guarded labels.** Every by-reference label used as a path + segment (role, slice) passes the `readMountedRolePrompt` guard shape + (`config-reader.ts:374-381`): reject `/`, `\`, `..`; a rejected or absent + label reads as "no override", never an error path with partial injection. +- **Proto discipline.** Regen via the repo's buf lanes (`buf.gen.yaml`, + `buf.gen.internal-go.yaml`, `buf.gen.agent-ts.yaml`); generated code is + never hand-edited. +- **Naming.** The managed offering is described as "private, + commercially-licensed"; the private monorepo is never named. + +## Plan + +Dependency order: T1 (proto + regen) unblocks everything; T2/T3 (server) and +T4 (runner) build on T1; T5/T6 (agent) build on T1 and land last; T7 +(harness) is alignment-only. Each task is the smallest unit carrying its own +test cycle and becomes a filed impl issue after freeze. **Sibling-record +serialization:** RIG-2673 lands `role = 5`/`persona = 6` and rewrites the +same `SpawnAsAccount` literal, the same "caller cannot inject" comment, the +same `provisionAndStart` signature, and the same pgtest overwrite pairs this +record's T1/T3 touch — so T1+T3 here must serialize AFTER (or merge with) +RIG-2673's T1+T4, not just the proto file. If RIG-2673 has not landed first, +this record's T1 carries all four `SpawnPeerRequest` fields. + +### T1 — Proto: spawn + provision override fields, regen + +Owner: compass-server + +Add to `proto/compass/v1/agent_gateway.proto` on `SpawnPeerRequest`: +`string model = 7` and `string instruction_slice = 8` (after RIG-2673's +`role = 5`/`persona = 6`; if RIG-2673 has not landed first, this task carries +all four fields and the two records' T1s merge). Add to +`proto/compass/v1/compass.proto` on `ProvisionAgentWorkspaceRequest`: +`string model = 5` and `string instruction_slice = 6`, each with the +SERVER-AUTHORITATIVE doc contract mirroring `:579-600`. Regen all three buf +lanes. + +Interfaces: + +- Produces: `SpawnPeerRequest.GetModel() string`, + `SpawnPeerRequest.GetInstructionSlice() string`, + `ProvisionAgentWorkspaceRequest.GetModel() string`, + `ProvisionAgentWorkspaceRequest.GetInstructionSlice() string`, and the TS + mirrors in `packages/compass-agent/src/gen/compass/v1/`. +- Consumes: existing message shapes (`agent_gateway.proto:167-173`, + `compass.proto:563-601`). +- Test: generated code compiles in both languages; buf lint passes + (additive). + +### T2 — Store: override columns + creation threading + +Owner: compass-server + +`agent_accounts` gains `model TEXT NOT NULL DEFAULT ''` and +`instruction_slice TEXT NOT NULL DEFAULT ''`. `store.NewAgent` +(`go/internal/store/inputs.go:20-37`) gains `Model string` and +`InstructionSlice string`; the `CreateAgent` INSERT and the `AgentAccount` +read model thread them verbatim (the store stores, never synthesizes — +matching the Persona comment at `inputs.go:23-26`). + +Interfaces: + +- Produces: `store.NewAgent{Model, InstructionSlice string}`; + `AgentAccount.Agent.Model` / `.InstructionSlice` on reads. +- Consumes: existing `CreateAgent(ctx, ownerUserID, NewAgent)`. +- Test: pgtest round-trip — create with both set, read back verbatim; empty + defaults for a bare create. + +### T3 — Server: spawn threading + provision overwrite + +Owner: compass-server + +In `SpawnAsAccount` (`go/server/lifecycle.go:185-194`): populate +`Model: req.GetModel()` and `InstructionSlice: req.GetInstructionSlice()` in +the `store.CreateAgent` literal; rewrite the "caller cannot inject" comment +to the bounded-selection rationale (a ref/label selects server-provisioned +config; it cannot author text). Thread `created.Agent.Model` / +`.InstructionSlice` through `provisionAndStart` onto +`ProvisionAgentWorkspaceRequest`; both non-create arms +(`lifecycle.go:322-331`) thread `existing.Agent.*` — extend the field set. +To avoid a four-positional-same-typed-string signature (`persona, role, +model, instructionSlice`, where a transposition compiles clean and silently +swaps a child's role prompt for its slice ref), pass a +`store.AgentOverrides{Persona, Role, Model, InstructionSlice}` struct instead. +In the operator provision handler (`service.go:159-165`): extend the +overwrite-from-store block with `req.Msg.Model = acc.Agent.Model` and +`req.Msg.InstructionSlice = acc.Agent.InstructionSlice` (clears for +non-agent accounts), and **correct the stale field-number comment** at +`service.go:146` ("persona=6 … role=7" — the real proto is persona=3/role=4, +`compass.proto:588,600`; an implementor trusting it would mis-number the new +model=5/slice=6 fields) in the same diff. + +Interfaces: + +- Consumes: T1 getters, T2 store fields. +- Produces: extended `provisionAndStart(ctx, agentID string, ov + store.AgentOverrides, req *compassv1internal.SpawnPeerRequest)`. +- Test: pgtest — spawn with `model`/`instruction_slice` set → + `agent_accounts` row values AND Provision wire carries them (pattern: + `service_placement_pgtest_test.go:957-976` + `provisionPersona`/`provisionRole` accessors; add `provisionModel` / + `provisionInstructionSlice`); operator-provision overwrite test pair for + both new fields (client sends bogus value, Runner receives store value — + mirroring `:330-366`); empty model → Provision carries empty + (Runner-global fallback preserved); idempotent re-spawn keeps stored + values. + +### T4 — Runner: per-agent model + instruction-slice env + +Owner: compass-runner + +`runtime.AgentSpec` (`go/internal/runtime/agent.go:44-51`) gains +`Model string` and `InstructionSlice string`, with `AgentHandle` accessors +mirroring `Persona()`/`Role()` (`agent.go:84-90`). The Runner's +`SpecBuilder.BuildSpec` copies them from `ProvisionAgentWorkspaceRequest`. +`agentEnv` (`host.go:878-887`) resolves per-agent-first: `handle.Model()` +non-empty wins, else `h.model` (the Runner-global `AgentModel` flag stays as +the fleet default). `AgentEnv` (`agent_exec.go:45-68`) gains +`InstructionSlice`; `execSpec` injects `COMPASS_INSTRUCTION_SLICE` +omitted-when-empty, matching `agent_exec.go:83-94`. + +Interfaces: + +- Consumes: T1 provision fields. +- Produces: `AgentHandle.Model() string`, + `AgentHandle.InstructionSlice() string`; `AgentEnv{…, Model, + InstructionSlice}`; env contract `COMPASS_MODEL` (now per-agent value with + Runner-global fallback), `COMPASS_INSTRUCTION_SLICE`. +- Test: `agentenv_test.go` pattern (`:60-140`) — per-agent model present ⇒ + env carries it; empty per-agent + Runner-global set ⇒ env carries the + global; both empty ⇒ key omitted; `COMPASS_INSTRUCTION_SLICE` + omitted-when-empty. `host_test.go` — `agentEnv` fallback precedence. + **Spec-survival case:** a handle reconstructed on the reattach/recovery + path (`host.go:293-295`, `runner.go:63-69`) must still carry + `Model`/`InstructionSlice` — else a per-agent-pinned child silently + downgrades to the Runner-global model on reload, the exact + candidate-reverts-mid-lane failure the rollout cannot tolerate. State (with + file+line) whether reattach always rebuilds the spec via provision from the + store; if it does not, this is a blocker for T4, not a test note. + +### T5 — Agent: spawn-tool Layer-1 params + +Owner: compass-agent + +`spawnParameters` (`packages/compass-agent/src/lifecycle.ts:77-88`) gains +`"model?"` (opaque selector string; description states it must resolve +against the fleet `models.yml` and that empty = fleet default) and +`"instruction_slice?"` (named ref; description states it names a +mount-provisioned `instructions/.md` and is append-scoped). No mode, +no text key — the tool carries exactly the Layer-1 tuple. The execute body +copies them onto `SpawnPeerRequestSchema` (`lifecycle.ts:151-160`). Tool +description documents set-at-creation-only semantics. + +Interfaces: + +- Consumes: T1 TS gen (`SpawnPeerRequestSchema` new fields). +- Produces: `agents_spawn_peer` args `model?`, `instruction_slice?`. +- Test: `lifecycle.test.ts` — wire request carries the params verbatim; + omitted params encode empty (no field); the schema exposes no + text/mode-shaped key. + +### T6 — Agent: instruction-slice consumption in the entrypoint + +Owner: compass-agent + +`config-reader.ts` gains `readMountedInstructionSlice(currentDir, slice)` +mirroring `readMountedRolePrompt` (`:370-390`): same traversal guard, path +`instructions/.md`, tolerant absent/empty → `undefined`. `cli.ts` +gains `resolveInstructionSlice(env)` beside `resolveRole` (`:181-186`) +reading `COMPASS_INSTRUCTION_SLICE`; `main()` appends the resolved slice body +to the `rules` array passed at `cli.ts:902` (one more always-apply rule, +after the fleet rules). Unset or unresolvable → today's behavior exactly. +Cross-lane note (the `instructions/` bundle top-dir is a STRUCTURAL TWIN — +it must be added to BOTH whitelists, and the store door is more than a +constant): a `[compass-server]` change adds `topDirInstructions` to the +store-door constants + `configBundleTopDirs` map + `configMemberParts` +rejection string (`go/internal/store/agent_config.go:35-64`, `:492-494`), a +flat-member validation arm mirroring `rules` +(`agent_config.go:513-527`: `instructions/.md`, len==2, `.md` only), +and the operator info-view bucket; plus the twin runner constant +(`go/internal/runner/config_materialize.go:61-78`). Without the store-side +arm a bundle carrying `instructions/` is rejected at upload before any +container sees it (criterion (c) unshippable). Store-door pgtest: bundle with +`instructions/` accepted; nested or non-`.md` instructions member rejected. + +Interfaces: + +- Consumes: T4 env contract; mount layout `instructions/.md`. +- Produces: `readMountedInstructionSlice(currentDir: string, slice: string): + Promise`; `resolveInstructionSlice(env): + string | undefined`. +- Test: `cli.test.ts` createSession-spy pattern (`:930-957`) — slice present + ⇒ rules array gains the slice body after the fleet rules; absent file ⇒ + unchanged rules; traversal label ⇒ ignored; no slice ⇒ options identical + to today. + +### T7 — Harness alignment (RIG-2935) + +Owner: harness + +The Layer-1 tuple's frozen definition lives in RIG-2935's Interfaces section; +harness owns the wave-spawn consumer and the wave-composition cross-family +rule + rollout-artifact lint (§5). Alignment settled (driver-confirmed); this +task is the pointer, not new design. + +Interfaces: + +- Consumes: the Layer-1 tuple (§The two-layer override contract). +- Produces: RIG-2935's Interfaces section as the tuple's home; the + cross-family wave-composition rule Compass's tree-composition guidance + references. + +## Tasks + +- [ ] T1 `[compass-server]` proto: `SpawnPeerRequest.model=7` + + `instruction_slice=8`, `ProvisionAgentWorkspaceRequest.model=5` + + `instruction_slice=6`, regen (3 buf lanes) +- [ ] T2 `[compass-server]` store: `agent_accounts` `model` + + `instruction_slice` columns + `NewAgent` fields + round-trip pgtest +- [ ] T3 `[compass-server]` spawn threading + provision overwrite-from-store + extension (`AgentOverrides` struct, stale field-number comment fix) + + pgtests +- [ ] T3s `[compass-server]` `instructions/` store-door top-dir: constants + + `configBundleTopDirs` + `configMemberParts` rejection string + flat-member + validation arm + info-view bucket + store-door pgtest (blocks criterion (c)) +- [ ] T4 `[compass-runner]` `AgentSpec`/`AgentEnv` fields, per-agent-first + model fallback in `agentEnv`, `COMPASS_INSTRUCTION_SLICE` injection + + spec-survival-on-reattach test + tests +- [ ] T5 `[compass-agent]` `agents_spawn_peer` `model?` + + `instruction_slice?` (Layer-1 only, no text/mode) + wire tests +- [ ] T6 `[compass-agent]` `readMountedInstructionSlice` + + `resolveInstructionSlice` + `main()` rules-append composition + + `instructions/` runner top-dir twin + tests +- [ ] T7 `[harness]` RIG-2935 Layer-1 tuple freeze + wave-composition + cross-family rule (alignment pointer) + +## Open Questions + +- **OQ-1 (LOAD-BEARING — subtree composition authority).** May the + agent-facing spawn path let a supervisor agent compose its subtree's + Layer-1 config — model + role + instruction_slice as bounded selections, + each resolving against server-provisioned config the operator controls? + Layer 1 has no free-text INJECTION surface by construction (a ref selects, + never authors), but "no injection" is not "no harm", and letting a parent + AGENT set a child's model/role is a POLICY change from today: role is + server-authoritative-from-AgentAccount (`compass_pb.ts:1090-1094`; enforced + `service.go:159-165`) and the model is operator-set Runner config + (`runner.go:48-51`). + - **The attack Matt must weigh — OVERSIGHT DEGRADATION BY COMPOSITION.** + The config bundle is fleet-global, so the caller-owner fence bounds the + child ACCOUNT, not the SELECTABLE SET: a supervisor can select any vetted + slice/role/model in the fleet for its child. A compromised or misaligned + supervisor can therefore spawn the reviewer that reviews its own subtree's + output on the weakest registry model, same family as the implementor, with + the most relaxed evidence slice — every step a legitimate bounded + selection, the harm being adversarial COMPOSITION of individually-vetted + parts. This is softest exactly where it matters most (a supervisor + composing its own review pair) because on Compass the cross-family + constraint is advisory-only (§5). Related unweighed authority: cost (a + supervisor pinning every child to the most expensive selector; no quota + modeled). + - **Recommendation (driver): YES, with the operator-policy-bounded-selection + rider.** It is RIG-2936's whole premise — a supervisor deploys the + implementor ladder in its own tree per the eval migration order + (Implementor → Reviewer → Manager/Designer) — and no free-text injection + opens; selection is owner-fenced and set-at-creation-only. It extends + Matt's RIG-2673 ruling (no role allowlist; "agent acts as owning user, + ACLs later") to the model + slice fields. The rider closes the composition + attack cheaply: make the SELECTABLE SET an operator policy artifact (a + `selectable:` block in the fleet bundle mapping role → allowed + models/slices, §Alternatives), additive and absent-file = full authority, + so YES-now / policy-bound-later is available. + - **Alternative: operator-composed waves only** — keep role and instruction + fully server-authoritative-from-AgentAccount as today (optionally letting + only `model` be agent-settable). Preserves the current policy line exactly + but defeats the per-tree incremental rollout the issue asks for: every + candidate-model agent needs a human touch, contradicting no-human-clicks + (RIG-2673 DL-new-5). +- **OQ-2 (non-load-bearing, decided in-record — flag if disagreed).** + Cross-family enforcement point: wave-level/tree-composition config + (harness's machine lint + Compass supervisor spawn guidance, ADVISORY on + Compass), NOT a per-agent tuple field. A server-side check IS expressible on + this record's data (child role + child/parent model + parent-of edge, §5) + but rejected as the enforcement point because parent-of is an unreliable + proxy for reviewer-of; a non-blocking WARN-level family-match flag on + reviewer-role spawns is a cheap additive backstop, deferred. +- **OQ-3 (non-load-bearing deferral).** The Layer-2 inline-text provision + field (operator-path free text beyond what `AgentAccount.persona` already + carries) is deferred until an operator need materializes; Layer 2 today is + the existing role/persona server-authoritative surface itself. Nothing in + Layer 1 depends on it. +- **OQ-4 (mechanical, driver resolves).** Ledger row numbers below are + `DL-`; the driver assigns concrete numbers at submit against the + live `docs/designs/DECISIONS.md` head (DL-278 at drafting time). +- **OQ-5 (should Matt see now — tuple MUTATION vs set-at-creation-only).** + Every override field is set-at-creation-only: despawn preserves identity + (`lifecycle.go:205-210`) and re-spawn resumes under STORED values + (`:322-343`), so an agent's model/role/slice is immutable through every + agent-facing path for its handle's lifetime. But eval rollout by nature + needs tuple CHANGE (ladder an implementor candidate A → B; promote a + candidate to the Manager tier), and this record gives it no path except a + new handle per revision (burning the handle continuity / home channel / + history despawn deliberately preserves) or an out-of-band store write + (contradicting no-human-clicks). Position (driver): "tuple change = new + handle" is acceptable for the initial rollout (each ladder rung is a fresh + lane), and a mutable-override path is deferred — but it reopens OQ-1's + authority question for MUTATION, a strictly scarier surface than + set-at-creation, so Matt should see it now rather than discover it at + rollout time. + +## Ledger delta + +Draft rows for `docs/designs/DECISIONS.md` (driver applies numbers + the flip +at submit, per the same-PR ledger-delta rule): + +- **DL-next-1:** The per-agent override tuple {model, role, + instruction_slice} is parent-choosable at spawn as additive + `SpawnPeerRequest` fields (`model = 7`, `instruction_slice = 8`; + `role = 5`/`persona = 6` per RIG-2673), stored at creation and threaded + store → provision → runner env — the model becoming per-agent-first with + the Runner-global `AgentModel` as fleet default fallback (supersedes the + model-is-Runner-wide posture of `runner.go:48-51` as a default, not a + contract). +- **DL-next-2:** The cross-lane override contract is two-layered, and the + boundary is AUTHORING vs SELECTION (not append vs replace): Layer 1, the + shared portable tuple {model: `provider/model:thinking`, role, + instruction_slice(named ref, append-scoped)} owned by the harness lane + (RIG-2935) and consumed by Compass, is bounded SELECTION against + server-provisioned config (a ref selects, never authors — no free-text + injection surface) and may select replace-composed content (a `role` label + selecting `SYSTEM.md`, which replaces block-0) or append-composed content + (a slice); Layer 2, the Compass-only escape hatch, is caller-AUTHORED free + text (inline instruction text, or an inline block-0 swap from + caller-supplied bytes), rides the already-server-authoritative provision + path, and is never agent-facing. +- **DL-next-3:** Instruction slices are mount-delivered + (`instructions/.md` in the fleet config bundle, admitted at BOTH the + store-door and runner top-dir whitelists — structural twins), selected per + agent by reference at spawn and composed at the rules-append surface — + instruction variation per agent/model never forks the shared corpus. +- **DL-next-4:** The cross-family review constraint (Reviewer.family ≠ + Implementor.family; Design-critic.family ≠ Designer.family) is wave-level + composition config — expressible against the Layer-1 tuple map (family = + registry function of the model selector), MACHINE-enforced on the harness + lane (rollout-artifact lint) and ADVISORY on Compass (supervisor spawn + guidance), never a `SpawnPeerRequest` field or blocking server-side spawn + check (parent-of is an unreliable proxy for reviewer-of). +- **DL-next-5:** Per-agent overrides are set-at-creation-only (re-spawn and + resume run under STORED values); tuple MUTATION (re-laddering a live agent's + model/role/slice) is out of scope for this record — the initial eval + rollout ladders by fresh handle per rung — and a mutable-override path, + which reopens the composition-authority question for mutation, is deferred + (OQ-5). From 539e5194a150646098fd61f21264f7f94609e669 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 21:43:16 -0400 Subject: [PATCH 2/4] docs(product): instruction_slice keep-or-cut OQ + review-round-2 nits (RIG-2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive commit atop the review-round-1 fold: - Add OQ-6 (LOAD-BEARING): instruction_slice's premise is under Matt review (harness-owned OQ9 — model-eval lane flagged the per-agent instruction dial may assume a capability no harness ships). Designed against KEEP; documents the exact Compass cut surface if Matt cuts (Layer 1 collapses to {model?, role?}, drop the field + T3s + T6 runner-twin + T5 param + the config-reader resolver). model + role stay frozen/stable. - Annotate the instruction_slice Layer-1 field with the OQ-6 pending-ruling note so it is not read as settled. - Scope the 'both records read identically' claim to the Layer-1 tuple (byte-identical) + the two-layer structure; each lane keeps its own boundary-framing headline (harness dropped its authoring-vs-selection reword after we agreed it is not a divergence). - Review round-2 low: nudge the host.go reattach citation to host.go:299 (the registry.Resolve call). The agent_config.go citations were verified correct at source and kept. Round 2 verdict was CLEAR TO FREEZE; Status stays Draft, merge-freeze held pending Matt's OQ-6 ruling + the harness frozen-tuple ping. Ledger-impact: deferred to freeze — DL rows staged in the record's Ledger delta section, applied with the Draft to Active flip on merge-freeze. Co-authored-by: Matt Wilkinson --- .../compass-per-agent-overrides/design.md | 188 +++++++++++++----- 1 file changed, 138 insertions(+), 50 deletions(-) diff --git a/docs/designs/product/compass-per-agent-overrides/design.md b/docs/designs/product/compass-per-agent-overrides/design.md index f8a2d7a2b..4f39a1817 100644 --- a/docs/designs/product/compass-per-agent-overrides/design.md +++ b/docs/designs/product/compass-per-agent-overrides/design.md @@ -3,9 +3,10 @@ Status: Draft Refs: RIG-2936 (this record). Sibling: RIG-2935 (harness/OMP wave-spawn — owns -the frozen shared override tuple this record consumes). Builds on: RIG-2673 -(`compass-agent-org-mgmt-tools/design.md`, the parent-choosable `role`/`persona` -spawn fields), the frozen spawn/despawn record +the frozen shared override tuple this record consumes). Load-bearing fork +routed to Matt: RIG-2937 (subtree composition authority — OQ-1 below). Builds +on: RIG-2673 (`compass-agent-org-mgmt-tools/design.md`, the parent-choosable +`role`/`persona` spawn fields), the frozen spawn/despawn record (`compass-agent-spawn-despawn/design.md`), and the model-eval suite (RIG-2562) whose per-role recommendations this rollout mechanism deploys. @@ -115,9 +116,12 @@ designers staying on the known-good tier). ### The two-layer override contract (frozen with RIG-2935) -The contract is expressed as **two layers with these exact labels** — the same -structure the harness RIG-2935 record freezes, so both records read -identically. +The contract is expressed as **two layers** — the same two-layer structure +and the same byte-identical Layer-1 tuple the harness RIG-2935 record freezes, +so the shared contract reads identically across both records. Each lane keeps +its own headline for the layer boundary (Compass frames it as authoring vs +selection, below; the harness record frames the same substance in its own +terms) — the tuple and the structure are the shared, identical part. #### Layer 1 — shared portable tuple @@ -146,7 +150,13 @@ type AgentOverride = { mode field, no `replace` in the portable tuple. Each lane resolves ref → text via its own surface: on Compass the ref resolves against the server-provisioned config mount (`config-reader.ts`), composed at the - append surface beside the role prompt. + append surface beside the role prompt. **⚠️ This field's PREMISE is a live + load-bearing Open Question (OQ-6) pending Matt's keep-or-cut ruling** — the + model-eval lane flagged the per-agent instruction dial may assume a + capability no harness ships by default. `model` and `role` are frozen and + stable; `instruction_slice` is designed here against KEEP, and if Matt cuts + it Layer 1 collapses to `{ model?, role? }` (see OQ-6 for the exact cut + surface). The field is NOT frozen into the shared tuple until Matt rules. - **Key property: Layer 1 has NO prompt-injection surface BY CONSTRUCTION.** A ref names a mount/version-controlled slice file the operator/server provisioned; an agent-caller populating `instruction_slice` can only POINT @@ -486,9 +496,17 @@ harness's wave-composition rule: ## Plan Dependency order: T1 (proto + regen) unblocks everything; T2/T3 (server) and -T4 (runner) build on T1; T5/T6 (agent) build on T1 and land last; T7 -(harness) is alignment-only. Each task is the smallest unit carrying its own -test cycle and becomes a filed impl issue after freeze. **Sibling-record +T4 (runner) build on T1; T3s (server store-door `instructions/` whitelist) +is independent of T1 and gates criterion (c); T5/T6 (agent) build on T1 and +land last. **T3s must land before or with T6** (the runner-twin whitelist + +agent consumption): a store door that accepts an `instructions/` bundle +before the runner twin admits it would let a store-accepted bundle be +runner-rejected at materialize, wedging the atomic config flip for every +container — so no `instructions/`-carrying bundle is published until both +whitelists are live. T7 (harness) is alignment-only. Each task is the +smallest unit carrying its own test cycle and becomes a filed impl issue +after freeze. +**Sibling-record serialization:** RIG-2673 lands `role = 5`/`persona = 6` and rewrites the same `SpawnAsAccount` literal, the same "caller cannot inject" comment, the same `provisionAndStart` signature, and the same pgtest overwrite pairs this @@ -526,7 +544,16 @@ Interfaces: Owner: compass-server `agent_accounts` gains `model TEXT NOT NULL DEFAULT ''` and -`instruction_slice TEXT NOT NULL DEFAULT ''`. `store.NewAgent` +`instruction_slice TEXT NOT NULL DEFAULT ''`, added directly to the collapsed +`go/internal/store/migrations/0001_init.sql` `CREATE TABLE agent_accounts` +(`:74`, beside the existing `persona`/`role` `NOT NULL DEFAULT ''` columns at +`:78`/`:80`) — **NOT a new numbered migration.** The store is a single +squashed init under a seed-forward posture ("the same reasoning folds each +later migration in as it accretes", `0001_init.sql:14-15`; RD-2 +recreate-on-schema-change, pre-dogfood zero deployed DBs), and a hard +migration-contiguity guard refuses to serve on a version gap +(`store.go:289-290`, `ErrSchemaVersion`), so a stray `0002` both violates the +collapse convention and risks wedging `Open()`. `store.NewAgent` (`go/internal/store/inputs.go:20-37`) gains `Model string` and `InstructionSlice string`; the `CreateAgent` INSERT and the `AgentAccount` read model thread them verbatim (the store stores, never synthesizes — @@ -579,6 +606,35 @@ Interfaces: (Runner-global fallback preserved); idempotent re-spawn keeps stored values. +### T3s — Server: `instructions/` store-door top-dir whitelist + +Owner: compass-server + +The config bundle's top-dir whitelist is a STRUCTURAL TWIN — the store door +validates and rejects a bundle at upload, and the runner validates again at +materialize (T6 adds the runner twin). This task is the store-door half, and +it GATES criterion (c): without it a bundle carrying `instructions/` is +rejected at upload before any container sees a slice. Add `topDirInstructions += "instructions"` to the store-door constants and the `configBundleTopDirs` +map (`go/internal/store/agent_config.go:35-64`), extend the +`configMemberParts` rejection string (`:492-494`), add a flat-member +validation arm mirroring `rules` (`:520-527`: `instructions/.md`, +`len(parts) == 2`, `.md` only, grammar-valid ``), and add the +operator info-view bucket (`:288-302`, the top-dir switch that builds the +`AgentConfigInfoResult` member-name sets). + +Interfaces: + +- Consumes: the existing bundle-validation seam + (`agent_config.go:473-535` — `configMemberParts` + `validateRegularMember`; + info-view at `:243-309`). +- Produces: `topDirInstructions` constant + `configBundleTopDirs` entry + + `configMemberParts` rejection-string update + a flat `instructions/.md` + validation arm + the info-view bucket. (Runner twin is T6.) +- Test: store-door pgtest / unit — a bundle carrying `instructions/.md` + is ACCEPTED; a nested `instructions//.md` or a non-`.md` + `instructions/` member is REJECTED (mirroring the `rules` arm's tests). + ### T4 — Runner: per-agent model + instruction-slice env Owner: compass-runner @@ -605,12 +661,17 @@ Interfaces: global; both empty ⇒ key omitted; `COMPASS_INSTRUCTION_SLICE` omitted-when-empty. `host_test.go` — `agentEnv` fallback precedence. **Spec-survival case:** a handle reconstructed on the reattach/recovery - path (`host.go:293-295`, `runner.go:63-69`) must still carry - `Model`/`InstructionSlice` — else a per-agent-pinned child silently - downgrades to the Runner-global model on reload, the exact - candidate-reverts-mid-lane failure the rollout cannot tolerate. State (with - file+line) whether reattach always rebuilds the spec via provision from the - store; if it does not, this is a blocker for T4, not a test note. + path must still carry `Model`/`InstructionSlice`, else a per-agent-pinned + child silently downgrades to the Runner-global model on reload — the exact + candidate-reverts-mid-lane failure the rollout cannot tolerate. Concretely: + the reattach path recovers a handle via `h.registry.Resolve(name)` + (`host.go:299`) rather than re-provisioning, and `agentEnv` reads + `handle.Model()`/`.Role()` off that resolved handle — so the implementor + MUST trace registry-handle population on the `Resolve` path and confirm the + `AgentHandle` carries `Model`/`InstructionSlice` after a PROCESS-RESTART + reattach, not only an in-session reload. If registry rehydration does not + re-thread the store values (e.g. it does not rebuild the spec via provision + from the store), this is a BLOCKER for T4, not a test note. ### T5 — Agent: spawn-tool Layer-1 params @@ -644,18 +705,12 @@ gains `resolveInstructionSlice(env)` beside `resolveRole` (`:181-186`) reading `COMPASS_INSTRUCTION_SLICE`; `main()` appends the resolved slice body to the `rules` array passed at `cli.ts:902` (one more always-apply rule, after the fleet rules). Unset or unresolvable → today's behavior exactly. -Cross-lane note (the `instructions/` bundle top-dir is a STRUCTURAL TWIN — -it must be added to BOTH whitelists, and the store door is more than a -constant): a `[compass-server]` change adds `topDirInstructions` to the -store-door constants + `configBundleTopDirs` map + `configMemberParts` -rejection string (`go/internal/store/agent_config.go:35-64`, `:492-494`), a -flat-member validation arm mirroring `rules` -(`agent_config.go:513-527`: `instructions/.md`, len==2, `.md` only), -and the operator info-view bucket; plus the twin runner constant -(`go/internal/runner/config_materialize.go:61-78`). Without the store-side -arm a bundle carrying `instructions/` is rejected at upload before any -container sees it (criterion (c) unshippable). Store-door pgtest: bundle with -`instructions/` accepted; nested or non-`.md` instructions member rejected. +Cross-lane note: the runner half of the `instructions/` STRUCTURAL TWIN lands +here — add the twin `topDirInstructions` constant + `configTopDirs` entry to +`go/internal/runner/config_materialize.go:61-78` (kept textually in lockstep +with the store door). The store-door half is T3s; per the Plan ordering T3s +lands before or with this task so a store-accepted `instructions/` bundle is +never runner-rejected at materialize. Interfaces: @@ -710,16 +765,23 @@ Interfaces: ## Open Questions -- **OQ-1 (LOAD-BEARING — subtree composition authority).** May the - agent-facing spawn path let a supervisor agent compose its subtree's - Layer-1 config — model + role + instruction_slice as bounded selections, - each resolving against server-provisioned config the operator controls? - Layer 1 has no free-text INJECTION surface by construction (a ref selects, - never authors), but "no injection" is not "no harm", and letting a parent - AGENT set a child's model/role is a POLICY change from today: role is +- **OQ-1 (LOAD-BEARING — routed to Matt as RIG-2937 — subtree composition + authority).** May the agent-facing spawn path let a supervisor agent + compose its subtree's Layer-1 config — model + role + instruction_slice as + bounded selections, each resolving against server-provisioned config the + operator controls? This is the record's one load-bearing question; it has a + durable decision home in **RIG-2937** (assigned Matt, options + driver + recommendation), distinct from the driver recommendation below. Layer 1 has + no free-text INJECTION surface by construction (a ref selects, never + authors), but "no injection" is not "no harm", and letting a parent AGENT + set a child's model/role is a POLICY change from today: role is server-authoritative-from-AgentAccount (`compass_pb.ts:1090-1094`; enforced `service.go:159-165`) and the model is operator-set Runner config - (`runner.go:48-51`). + (`runner.go:48-51`). **Sub-fork (mutation authority):** the same + composition-authority question extends to tuple MUTATION on a live agent + (re-laddering a running handle's model/role/slice) — a strictly scarier + surface than set-at-creation. This record scopes mutation OUT (see OQ-5); + RIG-2937 is the home if Matt wants the mutation path in scope now. - **The attack Matt must weigh — OVERSIGHT DEGRADATION BY COMPOSITION.** The config bundle is fleet-global, so the caller-owner fence bounds the child ACCOUNT, not the SELECTABLE SET: a supervisor can select any vetted @@ -766,21 +828,47 @@ Interfaces: - **OQ-4 (mechanical, driver resolves).** Ledger row numbers below are `DL-`; the driver assigns concrete numbers at submit against the live `docs/designs/DECISIONS.md` head (DL-278 at drafting time). -- **OQ-5 (should Matt see now — tuple MUTATION vs set-at-creation-only).** - Every override field is set-at-creation-only: despawn preserves identity +- **OQ-5 (non-load-bearing deferral — tuple MUTATION scoped out).** Every + override field is set-at-creation-only: despawn preserves identity (`lifecycle.go:205-210`) and re-spawn resumes under STORED values (`:322-343`), so an agent's model/role/slice is immutable through every - agent-facing path for its handle's lifetime. But eval rollout by nature - needs tuple CHANGE (ladder an implementor candidate A → B; promote a - candidate to the Manager tier), and this record gives it no path except a - new handle per revision (burning the handle continuity / home channel / - history despawn deliberately preserves) or an out-of-band store write - (contradicting no-human-clicks). Position (driver): "tuple change = new - handle" is acceptable for the initial rollout (each ladder rung is a fresh - lane), and a mutable-override path is deferred — but it reopens OQ-1's - authority question for MUTATION, a strictly scarier surface than - set-at-creation, so Matt should see it now rather than discover it at - rollout time. + agent-facing path for its handle's lifetime. Decision: for the initial + rollout, **tuple change = a new handle** (each ladder rung — implementor + candidate A → B, or a promote to the Manager tier — is a fresh lane), which + the eval rollout is served by without a mutable-override path. A mutable + path (re-laddering a live handle) is deferred: it is additive and reopens + the composition-authority question for MUTATION, which is why its + pre-freeze visibility rides OQ-1's RIG-2937 home as an explicit sub-fork + (above) rather than a second load-bearing question here. The record is + correct and shippable without it. +- **OQ-6 (LOAD-BEARING — instruction_slice keep-or-cut, harness-owned OQ9, + Matt to rule).** The per-agent instruction-slice dial's PREMISE is under + review: the model-eval lane flagged that the eval's instructions-first lever + may assume a capability no harness ships by default, and is running + OSS-harness research to bring Matt a keep-or-cut recommendation. This is + owned on the harness side (RIG-2935 OQ9) because the shared Layer-1 tuple is + harness-canonical; Compass consumes the outcome. It blocks this record's + freeze (a frozen tuple must not carry a field whose premise is unsettled) — + the merge-freeze is already held for it. + - **If KEEP (design-against default):** proceed exactly as designed — + `instruction_slice` stays the third Layer-1 field, with T3s (store-door + whitelist), T5's `instruction_slice?` tool param, and T6 + (`readMountedInstructionSlice` + runner twin + rules-append) all as + specified. Criterion (c) is satisfied by the slice mechanism. + - **If CUT:** Layer 1 collapses to `{ model?, role? }`. The exact Compass + cut surface: drop the `instruction_slice` tuple field and its + `SpawnPeerRequest`/`ProvisionAgentWorkspaceRequest` wire fields + store + column (from T1/T2/T3), drop T3s and the T6 runner-twin whole, drop T5's + `instruction_slice?` param, and drop `readMountedInstructionSlice` + + `resolveInstructionSlice` + the rules-append composition from T6 — the + record simplifies to per-agent model + role only. The ref-only-append + anti-injection property (§Layer 1) simply has nothing to attach to and is + removed with the field; it opens no new surface either way. Criterion (c) + would need re-scoping with the eval lane (it is the only acceptance + criterion the cut touches; (a) and (b) are unaffected). + - **Independent of OQ-1/RIG-2937:** the role-security composition-authority + fork stands whether the field is kept or cut; OQ-6 changes only whether + the third field exists. ## Ledger delta From 2098a94d7cd2c8b5c9806d5c08ae465d7c9adb5c Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 22:34:13 -0400 Subject: [PATCH 3/4] =?UTF-8?q?docs(product):=20fold=20instruction=5Fslice?= =?UTF-8?q?=20CUT=20ruling=20=E2=80=94=20Layer=201=20is=20{model,=20role}?= =?UTF-8?q?=20(RIG-2936)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matt ruled CUT on the per-agent instruction-slice dial (2026-08-27, via model-evals; relayed by the harness lane). This folds the conditional-cut path the prior OQ-6 designed against: - Layer 1 collapses to the byte-identical frozen tuple { model?, role? } (re-mirrored from the harness RIG-2935 block verbatim). - Drop the instruction_slice field end to end: the SpawnPeerRequest / ProvisionAgentWorkspaceRequest wire fields, the agent_accounts column, the instructions/ store-door + runner top-dir whitelists (the structural twin), and the in-container consumption (readMountedInstructionSlice / resolveInstructionSlice / rules-append). T3s and the T6 runner-twin are removed; the old T6 agent-consumption task is gone; harness alignment is now T6. Tasks renumber T1-T6. - Retitle the record 'per-agent model and role overrides'; rescope Problem/Intent; drop acceptance criterion (c) with the field (only (a) and (b) remain). - OQ-6 becomes RESOLVED-CUT, documenting Matt's rationale: NOT a capability gap (harnesses do ship model-keyed prompts by default), a deliberate shared-corpus-first choice — one hardened shared prompt over a per-model variant matrix. A revived dial re-enters as an additive Layer-1 field via a fresh record. - With OQ-6 resolved the record has exactly one load-bearing question (OQ-1 / RIG-2937). Ledger delta: DL-next-5 records the cut; the two-layer, cross-family, and set-at-creation rows drop their slice mentions. Gates green (scrub, design-ledger-gate:check 245 rows/98 headers, markdownlint 0). Status stays Draft; merge-freeze now gated only on Matt's RIG-2937 ruling + the harness frozen-tuple ping (harness confirmed Layer 1 is otherwise stable and locks now). Ledger-impact: deferred to freeze — DL rows staged in the record's Ledger delta section, applied with the Draft to Active flip on merge-freeze. Co-authored-by: Matt Wilkinson --- .../compass-per-agent-overrides/design.md | 612 +++++++----------- 1 file changed, 217 insertions(+), 395 deletions(-) diff --git a/docs/designs/product/compass-per-agent-overrides/design.md b/docs/designs/product/compass-per-agent-overrides/design.md index 4f39a1817..c453081f7 100644 --- a/docs/designs/product/compass-per-agent-overrides/design.md +++ b/docs/designs/product/compass-per-agent-overrides/design.md @@ -1,4 +1,4 @@ -# Compass per-agent model, role, and instruction overrides +# Compass per-agent model and role overrides Status: Draft @@ -14,14 +14,16 @@ whose per-role recommendations this rollout mechanism deploys. The model-eval suite (RIG-2562) produces per-role model recommendations (Manager / implementor / designer / design-critic / reviewer, each at a chosen -thinking level), but Compass cannot deploy a different model + role + -instruction set per agent within its tree: the model is Runner-global, role and -persona are server-authoritative from the store with no agent-facing setter on -the shipped wire, and there is no per-agent instruction hook at all. This -record makes the {model, thinking-level, role, instruction-slice} tuple -configurable per agent, parent-choosable at spawn, so eval output can roll out +thinking level), but Compass cannot deploy a different model + role per agent +within its tree: the model is Runner-global, and role and persona are +server-authoritative from the store with no agent-facing setter on the shipped +wire. This record makes the {model, thinking-level, role} tuple configurable +per agent, parent-choosable at spawn, so eval output can roll out incrementally (implementor lane first on a candidate model, Managers and -designers staying on the known-good tier). +designers staying on the known-good tier). Per-agent instruction-SET variation +(a separate instruction dial per agent) is deliberately OUT of scope — a +shared-corpus-first choice, not a capability gap; see OQ-6 for Matt's ruling +and its rationale. ## Approach @@ -133,9 +135,10 @@ byte-identical to RIG-2935's block: ```ts type AgentOverride = { - model?: string; // "provider/model:thinking" selector, common grammar both lanes resolve - role?: string; // modelRoles/persona tier label (plain string; Compass applies its own policy) - instruction_slice?: string; // NAMED ref, never inline text; APPEND-scoped; keyed by role-or-model + /** models.yml selector 'provider/model:thinking'; wins over role. */ + model?: string; + /** Which modelRoles tier the agent runs as (role label). */ + role?: string; }; ``` @@ -146,26 +149,16 @@ type AgentOverride = { - `role` is the role label / modelRoles tier — on Compass the `prompts//SYSTEM.md` block-0 selector already shipped by the role pipeline. -- `instruction_slice` is a **NAMED REF, APPEND-scoped**. No inline text, no - mode field, no `replace` in the portable tuple. Each lane resolves - ref → text via its own surface: on Compass the ref resolves against the - server-provisioned config mount (`config-reader.ts`), composed at the - append surface beside the role prompt. **⚠️ This field's PREMISE is a live - load-bearing Open Question (OQ-6) pending Matt's keep-or-cut ruling** — the - model-eval lane flagged the per-agent instruction dial may assume a - capability no harness ships by default. `model` and `role` are frozen and - stable; `instruction_slice` is designed here against KEEP, and if Matt cuts - it Layer 1 collapses to `{ model?, role? }` (see OQ-6 for the exact cut - surface). The field is NOT frozen into the shared tuple until Matt rules. - **Key property: Layer 1 has NO prompt-injection surface BY CONSTRUCTION.** - A ref names a mount/version-controlled slice file the operator/server - provisioned; an agent-caller populating `instruction_slice` can only POINT - AT a pre-provisioned slice, never inject free text. The agent SELECTS; it - never AUTHORS. The anti-injection invariant is preserved by the shape - itself, not by a consumer-side restriction. + Every field is a bounded SELECTION: `role` names a mount/version-controlled + `prompts//SYSTEM.md` the operator/server provisioned, and `model` + names an entry in the server-provisioned `models.yml` registry. An + agent-caller populating either field can only POINT AT pre-provisioned + config, never inject free text. The agent SELECTS; it never AUTHORS. The + anti-injection invariant is preserved by the shape itself, not by a + consumer-side restriction. - Layer 1 is what the agent-facing spawn path (`agents_spawn_peer` / - `SpawnPeerRequest`) carries: model + role + instruction_slice as bounded - selections. + `SpawnPeerRequest`) carries: model + role as bounded selections. #### Layer 2 — Compass per-lane escape hatch @@ -176,17 +169,15 @@ replace.** Layer 1 is bounded SELECTION: a field names a server-provisioned artifact and the agent picks from a pre-vetted set. That set may include replace-composed content — `role` selects `prompts//SYSTEM.md`, which REPLACES block-0 as `customSystemPrompt` -(`packages/compass-agent/src/cli.ts:918-921`) — or append-composed content -(`instruction_slice`, appended after the fleet rules). Replace-scoped -SELECTION therefore already lives in Layer 1, and after this record composes -with RIG-2673's `role = 5` field, the agent-facing spawn path DOES invoke a +(`packages/compass-agent/src/cli.ts:918-921`). Replace-scoped SELECTION +therefore already lives in Layer 1, and after this record composes with +RIG-2673's `role = 5` field, the agent-facing spawn path DOES invoke a replace-composed prompt (via role). What is walled off from the agent-facing -path is free-text AUTHORING, in either composition mode: +path is free-text AUTHORING: -- Inline instruction text (a caller supplying prompt bytes rather than a ref) - and an inline block-0 swap (replace-mode composition from caller-supplied - text rather than a role label) live in Layer 2 only. -- On Compass these ride the `ProvisionAgentWorkspaceRequest` path, which is +- An inline block-0 swap (replace-mode composition from caller-supplied text + rather than a role label) lives in Layer 2 only. +- On Compass this rides the `ProvisionAgentWorkspaceRequest` path, which is ALREADY server-authoritative today for role/persona — "the Server … will overwrite any client-supplied value" (`packages/compass-agent/src/gen/compass/v1/compass_pb.ts:1072-1102`; proto @@ -262,7 +253,7 @@ that seam; it composes with it: (`design.md:302`). Role is a bounded selection — a Layer-1 field. - Set-at-creation-only semantics carry over verbatim (`design.md:215`): a spawn resolving to an existing handle is idempotent success under the - **stored** role/model/slice values, ignoring the request's — both + **stored** role/model values, ignoring the request's — both non-create paths already thread `existing.Agent.*` (`lifecycle.go:322-331`). - Letting a parent agent set the child's role is still a POLICY change from @@ -270,64 +261,7 @@ that seam; it composes with it: (`compass_pb.ts:1090-1094`) — that policy question is OQ-1 below, not silently decided here. -### 3. Instruction-slice hook - -The mechanism is **mount-delivered instruction slices, selected per agent at -spawn by reference (Layer 1), with inline text and replace-mode as the -operator-only Layer-2 escape hatch**: - -- The fleet config bundle gains a top-level `instructions/` dir beside - `skills/`, `rules/`, and `agents/`: flat `instructions/.md` files, - each one vetted instruction variant, shipped through the existing bundle - pipeline (store door validate → `ConfigMaterializer` unpack → read-only - container mount). The bundle top-dir whitelist is a STRUCTURAL TWIN kept - "textually in lockstep": `instructions` must be added to BOTH the store - door (`go/internal/store/agent_config.go:35-64` — the `topDir*` constants, - the `configBundleTopDirs` map, and the `configMemberParts` rejection string - at `:492-494`) AND the runner (`go/internal/runner/config_materialize.go:61-78` - — the twin constants + `configTopDirs` map). The store door additionally - needs a flat-member validation arm mirroring `rules` - (`agent_config.go:520-527`: `instructions/.md`, len==2, `.md` only) - and its operator info-view bucket — without the STORE-side change a bundle - carrying `instructions/` is rejected at upload before any container sees it, - so criterion (c) is unshippable (this is a compass-server task, T6's - cross-lane note below). Variants are distinct slice names - (`planning-evidence-strict.md`, `planning-evidence-relaxed.md`), keyed by - role or model per the eval's dial — the eval provisions the variant files - once; every agent shares the same corpus and **no agent forks it**. -- `SpawnPeerRequest` gains `string instruction_slice = 8` — the Layer-1 - named ref, nothing more. No mode, no text on the agent-facing wire. -- Stored at creation (`agent_accounts.instruction_slice`), threaded store → - provision → runner env like role: `ProvisionAgentWorkspaceRequest. - instruction_slice = 6` (server-authoritative, overwrite-from-store), an - `AgentSpec` field, and env var `COMPASS_INSTRUCTION_SLICE`, - omitted-when-empty in `execSpec` like the four existing vars - (`agent_exec.go:83-94`). -- In-container consumption composes with the existing append seam: - `config-reader.ts` gains `readMountedInstructionSlice` mirroring - `readMountedRolePrompt`'s tolerant, traversal-guarded shape - (`config-reader.ts:370-390` — same `/[/\\]|\.\./` label guard); the slice - body joins the SDK `rules` array `main()` already passes - (`cli.ts:892-902`), landing as one more always-apply rule appended after - the fleet rules. Unset or unresolvable ref → today's behavior exactly. -- **Layer 2 (escape hatch, out of the shared tuple) is free-text AUTHORING, - not the replace COMPOSITION mode.** Replace-scoped SELECTION already lives - in Layer 1 — `role` selects `prompts//SYSTEM.md`, which replaces - block-0 as `customSystemPrompt` (`cli.ts:918-921`) — so the agent-facing - path does invoke replace-composed prompts via role. Layer 2 is only what a - caller would AUTHOR: inline instruction text, and an inline block-0 swap - built from caller-supplied text rather than a role label. Those ride only - the operator/server-authoritative provision path. An inline-text provision - field is deferred until an operator need materializes (OQ-3); nothing - agent-facing ever lets a caller author prompt bytes. -- **Acceptance criterion (c) is satisfied by construction**: the - planning-evidence dial (or message-origin tagging, or the cross-family - review-of-high-severity-claims instruction) ships as - `instructions/.md` variants in the one shared bundle; two sibling - implementors on different candidate models run different slices purely by - each spawn's `instruction_slice` ref — zero corpus fork. - -### 4. Shared schema with the harness (RIG-2935) +### 3. Shared schema with the harness (RIG-2935) The Layer-1 tuple is the settled, frozen shared contract (harness confirmed; their RIG-2935 record freezes it in its Interfaces section). Division of @@ -337,15 +271,14 @@ labor: consumer (operator-keystroke launch). - **Compass (this record)** owns the consumer mapping: tuple `model` → `SpawnPeerRequest.model` → `COMPASS_MODEL`; tuple `role` → - `SpawnPeerRequest.role` (RIG-2673) → `COMPASS_ROLE`; tuple - `instruction_slice` → `SpawnPeerRequest.instruction_slice` → - `COMPASS_INSTRUCTION_SLICE` → mount resolution — plus the Layer-2 - escape-hatch surface, which is Compass-only and out of the tuple. + `SpawnPeerRequest.role` (RIG-2673) → `COMPASS_ROLE` → mount resolution — + plus the Layer-2 escape-hatch surface, which is Compass-only and out of the + tuple. An eval rollout artifact (role → Layer-1 tuple map) is therefore consumable by both surfaces without translation. -### 5. Cross-family review constraint +### 4. Cross-family review constraint `Reviewer.family ≠ Implementor.family` and `Design-critic.family ≠ Designer.family` are **wave-level composition config, NOT a per-agent tuple @@ -402,20 +335,15 @@ harness's wave-composition rule: (operator-composed waves). - **Reclaim field 3 (`initial_prompt`) for the new payload.** Rejected: `reserved 3; reserved "initial_prompt";` (`agent_gateway.proto:170-171`) - exists precisely so the number/name are never reused (DL-187); new fields - take 7/8. -- **Inline instruction text in the portable tuple (v0 shape, `{mode, slice, - text}`).** Rejected during the RIG-2935 alignment: inline text from a - parent agent is a prompt-injection surface that would reverse the - server-authoritative anti-injection invariant. The frozen v1 tuple carries - a named ref only — the injection surface is removed by the contract shape, - and inline text moves to the Compass-only Layer-2 operator path. + exists precisely so the number/name are never reused (DL-187); the new + `model` field takes a fresh number (7). - **Free-text `replace` (caller-authored block-0) in the portable tuple.** Rejected (settled with harness): a caller supplying block-0 bytes is free-text authoring, a per-lane operator escape hatch deliberately out of the portable eval-driven contract. Replace-scoped SELECTION (a `role` label choosing a server-provisioned `SYSTEM.md`) is a different thing and stays in - Layer 1 — the wall is authoring vs selection, not append vs replace (§3). + Layer 1 — the wall is authoring vs selection, not append vs replace (§The + two-layer override contract). - **Operator-policy-bounded selection (the middle between full agent authority and operator-composed-waves-only).** The tuple stays agent-settable at spawn (preserving per-tree, no-human-clicks rollout — the @@ -423,19 +351,13 @@ harness's wave-composition rule: operator artifact: e.g. a `selectable:` policy in the fleet bundle (which already ships `models.yml` through the exact same store-door → mount pipeline, `agent_config.go:487-490` / `config-reader.ts:392-401`) mapping - role → allowed models/slices, checked server-side at spawn or agent-side at - the tool. This directly answers the OQ-1 composition attack — a supervisor - cannot pair a reviewer role with a model/slice not vetted FOR review — at + role → allowed models, checked server-side at spawn or agent-side at the + tool. This directly answers the OQ-1 composition attack — a supervisor + cannot pair a reviewer role with a model not vetted FOR review — at near-zero wire cost (no new fields; one bundle file + one check) and it degrades gracefully: an absent policy file = the full-authority posture, so it can be a follow-up rather than a blocker. Carried into OQ-1 as a rider on YES, not a separate pole. -- **A per-agent materialized instruction copy (resolve ref → text at - provision, deliver per-container).** Rejected: the config mount already - delivers versioned, atomically-flipped instruction files to every container - (`config_materialize.go:3-11`); a per-agent copy adds a second delivery - path for the same bytes and breaks the no-fork auditability of the shared - corpus. ## Global Constraints @@ -443,8 +365,7 @@ harness's wave-composition rule: change to `SpawnPeerRequest` existing fields (`handle = 1`, `display_name = 2`, `client_request_id = 4`); field 3 is `reserved`/`initial_prompt` (DL-187) and is NOT reclaimed; new fields take - fresh numbers (`role = 5`/`persona = 6` per RIG-2673, `model = 7`, - `instruction_slice = 8`). + fresh numbers (`role = 5`/`persona = 6` per RIG-2673, `model = 7`). - **Owner inheritance invariant preserved.** Owner is caller-derived, never a request field: "Spawn creates the new peer under the CALLER'S OWNER — never the caller agent itself, never the bootstrap admin" @@ -454,22 +375,22 @@ harness's wave-composition rule: injection" is not "no harm".** What changes: today a caller cannot influence a peer's prompt at all (server-authoritative from store, `compass.proto:581-600`, enforced at `service.go:159-165`); after this - record a parent agent SELECTS the child's role prompt, model, and - instruction slice. What is preserved against INJECTION: every Layer-1 field - is a bounded selection resolving against server-provisioned config — a ref - can point at, never author, prompt text — so no free-text injection surface - opens; the caller-owner fence bounds the new peer to the caller's own owner; + record a parent agent SELECTS the child's role prompt and model. What is + preserved against INJECTION: every Layer-1 field is a bounded selection + resolving against server-provisioned config — a label/selector can point + at, never author, prompt text — so no free-text injection surface opens; + the caller-owner fence bounds the new peer to the caller's own owner; store-as-source-of-record is kept (provision always threads the STORE values; the operator-path overwrite tests stay green). What is NOT bounded by that fence: the SELECTABLE SET itself. The config bundle is FLEET-GLOBAL — one bundle materialized into every container — and - `instructions/`/`prompts/` are flat fleet-wide namespaces, so a caller can - select ANY vetted slice/role/model in the fleet for its child, including one - the operator vetted for a different context (a relaxed-evidence slice - intended for a throwaway lane, selected for a production reviewer). The - mechanism enforces no keying between slice and role/model. This is the - composition surface behind OQ-1's oversight-degradation attack; it is a - POLICY change (parent-agent composition authority), Matt's call, and the + `prompts/`/`models.yml` are flat fleet-wide namespaces, so a caller can + select ANY vetted role/model in the fleet for its child, including one the + operator vetted for a different context (a weak model registered for a + throwaway lane, selected for a production reviewer). The mechanism enforces + no keying between role and model. This is the composition surface behind + OQ-1's oversight-degradation attack; it is a POLICY change (parent-agent + composition authority), Matt's call, and the operator-policy-bounded-selection alternative (§Alternatives) is its cheap mitigation. Inline free text (Layer 2) remains operator/server-authoritative throughout. @@ -484,7 +405,7 @@ harness's wave-composition rule: thinking suffix) is never parsed outside the SDK ModelRegistry (`cli.ts:137-139`); no Compass component splits provider/model/thinking. - **Traversal-guarded labels.** Every by-reference label used as a path - segment (role, slice) passes the `readMountedRolePrompt` guard shape + segment (role) passes the `readMountedRolePrompt` guard shape (`config-reader.ts:374-381`): reject `/`, `\`, `..`; a rejected or absent label reads as "no override", never an error path with partial injection. - **Proto discipline.** Regen via the repo's buf lanes (`buf.gen.yaml`, @@ -496,44 +417,33 @@ harness's wave-composition rule: ## Plan Dependency order: T1 (proto + regen) unblocks everything; T2/T3 (server) and -T4 (runner) build on T1; T3s (server store-door `instructions/` whitelist) -is independent of T1 and gates criterion (c); T5/T6 (agent) build on T1 and -land last. **T3s must land before or with T6** (the runner-twin whitelist + -agent consumption): a store door that accepts an `instructions/` bundle -before the runner twin admits it would let a store-accepted bundle be -runner-rejected at materialize, wedging the atomic config flip for every -container — so no `instructions/`-carrying bundle is published until both -whitelists are live. T7 (harness) is alignment-only. Each task is the -smallest unit carrying its own test cycle and becomes a filed impl issue -after freeze. -**Sibling-record -serialization:** RIG-2673 lands `role = 5`/`persona = 6` and rewrites the -same `SpawnAsAccount` literal, the same "caller cannot inject" comment, the -same `provisionAndStart` signature, and the same pgtest overwrite pairs this -record's T1/T3 touch — so T1+T3 here must serialize AFTER (or merge with) -RIG-2673's T1+T4, not just the proto file. If RIG-2673 has not landed first, -this record's T1 carries all four `SpawnPeerRequest` fields. +T4 (runner) build on T1; T5 (agent) builds on T1 and lands last. T6 (harness) +is alignment-only. Each task is the smallest unit carrying its own test cycle +and becomes a filed impl issue after freeze. +**Sibling-record serialization:** RIG-2673 lands `role = 5`/`persona = 6` and +rewrites the same `SpawnAsAccount` literal, the same "caller cannot inject" +comment, the same `provisionAndStart` signature, and the same pgtest overwrite +pairs this record's T1/T3 touch — so T1+T3 here must serialize AFTER (or merge +with) RIG-2673's T1+T4, not just the proto file. If RIG-2673 has not landed +first, this record's T1 carries all three `SpawnPeerRequest` fields. ### T1 — Proto: spawn + provision override fields, regen Owner: compass-server Add to `proto/compass/v1/agent_gateway.proto` on `SpawnPeerRequest`: -`string model = 7` and `string instruction_slice = 8` (after RIG-2673's -`role = 5`/`persona = 6`; if RIG-2673 has not landed first, this task carries -all four fields and the two records' T1s merge). Add to -`proto/compass/v1/compass.proto` on `ProvisionAgentWorkspaceRequest`: -`string model = 5` and `string instruction_slice = 6`, each with the +`string model = 7` (after RIG-2673's `role = 5`/`persona = 6`; if RIG-2673 has +not landed first, this task carries all three fields and the two records' T1s +merge). Add to `proto/compass/v1/compass.proto` on +`ProvisionAgentWorkspaceRequest`: `string model = 5`, with the SERVER-AUTHORITATIVE doc contract mirroring `:579-600`. Regen all three buf lanes. Interfaces: - Produces: `SpawnPeerRequest.GetModel() string`, - `SpawnPeerRequest.GetInstructionSlice() string`, - `ProvisionAgentWorkspaceRequest.GetModel() string`, - `ProvisionAgentWorkspaceRequest.GetInstructionSlice() string`, and the TS - mirrors in `packages/compass-agent/src/gen/compass/v1/`. + `ProvisionAgentWorkspaceRequest.GetModel() string`, and the TS mirrors in + `packages/compass-agent/src/gen/compass/v1/`. - Consumes: existing message shapes (`agent_gateway.proto:167-173`, `compass.proto:563-601`). - Test: generated code compiles in both languages; buf lint passes @@ -543,135 +453,98 @@ Interfaces: Owner: compass-server -`agent_accounts` gains `model TEXT NOT NULL DEFAULT ''` and -`instruction_slice TEXT NOT NULL DEFAULT ''`, added directly to the collapsed -`go/internal/store/migrations/0001_init.sql` `CREATE TABLE agent_accounts` -(`:74`, beside the existing `persona`/`role` `NOT NULL DEFAULT ''` columns at -`:78`/`:80`) — **NOT a new numbered migration.** The store is a single -squashed init under a seed-forward posture ("the same reasoning folds each -later migration in as it accretes", `0001_init.sql:14-15`; RD-2 +`agent_accounts` gains `model TEXT NOT NULL DEFAULT ''`, added directly to the +collapsed `go/internal/store/migrations/0001_init.sql` `CREATE TABLE +agent_accounts` (`:74`, beside the existing `persona`/`role` `NOT NULL DEFAULT +''` columns at `:78`/`:80`) — **NOT a new numbered migration.** The store is a +single squashed init under a seed-forward posture ("the same reasoning folds +each later migration in as it accretes", `0001_init.sql:14-15`; RD-2 recreate-on-schema-change, pre-dogfood zero deployed DBs), and a hard migration-contiguity guard refuses to serve on a version gap (`store.go:289-290`, `ErrSchemaVersion`), so a stray `0002` both violates the collapse convention and risks wedging `Open()`. `store.NewAgent` -(`go/internal/store/inputs.go:20-37`) gains `Model string` and -`InstructionSlice string`; the `CreateAgent` INSERT and the `AgentAccount` -read model thread them verbatim (the store stores, never synthesizes — -matching the Persona comment at `inputs.go:23-26`). +(`go/internal/store/inputs.go:20-37`) gains `Model string`; the `CreateAgent` +INSERT and the `AgentAccount` read model thread it verbatim (the store stores, +never synthesizes — matching the Persona comment at `inputs.go:23-26`). Interfaces: -- Produces: `store.NewAgent{Model, InstructionSlice string}`; - `AgentAccount.Agent.Model` / `.InstructionSlice` on reads. +- Produces: `store.NewAgent{Model string}`; `AgentAccount.Agent.Model` on + reads. - Consumes: existing `CreateAgent(ctx, ownerUserID, NewAgent)`. -- Test: pgtest round-trip — create with both set, read back verbatim; empty - defaults for a bare create. +- Test: pgtest round-trip — create with `model` set, read back verbatim; empty + default for a bare create. ### T3 — Server: spawn threading + provision overwrite Owner: compass-server In `SpawnAsAccount` (`go/server/lifecycle.go:185-194`): populate -`Model: req.GetModel()` and `InstructionSlice: req.GetInstructionSlice()` in -the `store.CreateAgent` literal; rewrite the "caller cannot inject" comment -to the bounded-selection rationale (a ref/label selects server-provisioned -config; it cannot author text). Thread `created.Agent.Model` / -`.InstructionSlice` through `provisionAndStart` onto +`Model: req.GetModel()` in the `store.CreateAgent` literal; rewrite the +"caller cannot inject" comment to the bounded-selection rationale (a +ref/label selects server-provisioned config; it cannot author text). Thread +`created.Agent.Model` through `provisionAndStart` onto `ProvisionAgentWorkspaceRequest`; both non-create arms (`lifecycle.go:322-331`) thread `existing.Agent.*` — extend the field set. -To avoid a four-positional-same-typed-string signature (`persona, role, -model, instructionSlice`, where a transposition compiles clean and silently -swaps a child's role prompt for its slice ref), pass a -`store.AgentOverrides{Persona, Role, Model, InstructionSlice}` struct instead. -In the operator provision handler (`service.go:159-165`): extend the -overwrite-from-store block with `req.Msg.Model = acc.Agent.Model` and -`req.Msg.InstructionSlice = acc.Agent.InstructionSlice` (clears for -non-agent accounts), and **correct the stale field-number comment** at -`service.go:146` ("persona=6 … role=7" — the real proto is persona=3/role=4, +To avoid a three-positional-same-typed-string signature (`persona, role, +model`, where a transposition compiles clean and silently swaps a child's role +prompt for its model selector), pass a +`store.AgentOverrides{Persona, Role, Model}` struct instead. In the operator +provision handler (`service.go:159-165`): extend the overwrite-from-store +block with `req.Msg.Model = acc.Agent.Model` (clears for non-agent accounts), +and **correct the stale field-number comment** at `service.go:146` +("persona=6 … role=7" — the real proto is persona=3/role=4, `compass.proto:588,600`; an implementor trusting it would mis-number the new -model=5/slice=6 fields) in the same diff. +`model=5` field) in the same diff. Interfaces: - Consumes: T1 getters, T2 store fields. - Produces: extended `provisionAndStart(ctx, agentID string, ov store.AgentOverrides, req *compassv1internal.SpawnPeerRequest)`. -- Test: pgtest — spawn with `model`/`instruction_slice` set → - `agent_accounts` row values AND Provision wire carries them (pattern: +- Test: pgtest — spawn with `model` set → `agent_accounts` row value AND + Provision wire carries it (pattern: `service_placement_pgtest_test.go:957-976` - `provisionPersona`/`provisionRole` accessors; add `provisionModel` / - `provisionInstructionSlice`); operator-provision overwrite test pair for - both new fields (client sends bogus value, Runner receives store value — - mirroring `:330-366`); empty model → Provision carries empty - (Runner-global fallback preserved); idempotent re-spawn keeps stored - values. + `provisionPersona`/`provisionRole` accessors; add `provisionModel`); + operator-provision overwrite test pair for the new field (client sends bogus + value, Runner receives store value — mirroring `:330-366`); empty model → + Provision carries empty (Runner-global fallback preserved); idempotent + re-spawn keeps stored values. -### T3s — Server: `instructions/` store-door top-dir whitelist - -Owner: compass-server - -The config bundle's top-dir whitelist is a STRUCTURAL TWIN — the store door -validates and rejects a bundle at upload, and the runner validates again at -materialize (T6 adds the runner twin). This task is the store-door half, and -it GATES criterion (c): without it a bundle carrying `instructions/` is -rejected at upload before any container sees a slice. Add `topDirInstructions -= "instructions"` to the store-door constants and the `configBundleTopDirs` -map (`go/internal/store/agent_config.go:35-64`), extend the -`configMemberParts` rejection string (`:492-494`), add a flat-member -validation arm mirroring `rules` (`:520-527`: `instructions/.md`, -`len(parts) == 2`, `.md` only, grammar-valid ``), and add the -operator info-view bucket (`:288-302`, the top-dir switch that builds the -`AgentConfigInfoResult` member-name sets). - -Interfaces: - -- Consumes: the existing bundle-validation seam - (`agent_config.go:473-535` — `configMemberParts` + `validateRegularMember`; - info-view at `:243-309`). -- Produces: `topDirInstructions` constant + `configBundleTopDirs` entry + - `configMemberParts` rejection-string update + a flat `instructions/.md` - validation arm + the info-view bucket. (Runner twin is T6.) -- Test: store-door pgtest / unit — a bundle carrying `instructions/.md` - is ACCEPTED; a nested `instructions//.md` or a non-`.md` - `instructions/` member is REJECTED (mirroring the `rules` arm's tests). - -### T4 — Runner: per-agent model + instruction-slice env +### T4 — Runner: per-agent model env Owner: compass-runner `runtime.AgentSpec` (`go/internal/runtime/agent.go:44-51`) gains -`Model string` and `InstructionSlice string`, with `AgentHandle` accessors -mirroring `Persona()`/`Role()` (`agent.go:84-90`). The Runner's -`SpecBuilder.BuildSpec` copies them from `ProvisionAgentWorkspaceRequest`. -`agentEnv` (`host.go:878-887`) resolves per-agent-first: `handle.Model()` -non-empty wins, else `h.model` (the Runner-global `AgentModel` flag stays as -the fleet default). `AgentEnv` (`agent_exec.go:45-68`) gains -`InstructionSlice`; `execSpec` injects `COMPASS_INSTRUCTION_SLICE` -omitted-when-empty, matching `agent_exec.go:83-94`. +`Model string`, with an `AgentHandle` accessor mirroring `Persona()`/`Role()` +(`agent.go:84-90`). The Runner's `SpecBuilder.BuildSpec` copies it from +`ProvisionAgentWorkspaceRequest`. `agentEnv` (`host.go:878-887`) resolves +per-agent-first: `handle.Model()` non-empty wins, else `h.model` (the +Runner-global `AgentModel` flag stays as the fleet default). `AgentEnv.Model` +and the `execSpec` omitted-when-empty injection (`agent_exec.go:83-85`) are +unchanged — the container still just reads `COMPASS_MODEL`. Interfaces: - Consumes: T1 provision fields. -- Produces: `AgentHandle.Model() string`, - `AgentHandle.InstructionSlice() string`; `AgentEnv{…, Model, - InstructionSlice}`; env contract `COMPASS_MODEL` (now per-agent value with - Runner-global fallback), `COMPASS_INSTRUCTION_SLICE`. +- Produces: `AgentHandle.Model() string`; env contract `COMPASS_MODEL` (now a + per-agent value with Runner-global fallback). - Test: `agentenv_test.go` pattern (`:60-140`) — per-agent model present ⇒ env carries it; empty per-agent + Runner-global set ⇒ env carries the - global; both empty ⇒ key omitted; `COMPASS_INSTRUCTION_SLICE` - omitted-when-empty. `host_test.go` — `agentEnv` fallback precedence. + global; both empty ⇒ key omitted. `host_test.go` — `agentEnv` fallback + precedence. **Spec-survival case:** a handle reconstructed on the reattach/recovery - path must still carry `Model`/`InstructionSlice`, else a per-agent-pinned - child silently downgrades to the Runner-global model on reload — the exact + path must still carry `Model`, else a per-agent-pinned child silently + downgrades to the Runner-global model on reload — the exact candidate-reverts-mid-lane failure the rollout cannot tolerate. Concretely: the reattach path recovers a handle via `h.registry.Resolve(name)` (`host.go:299`) rather than re-provisioning, and `agentEnv` reads `handle.Model()`/`.Role()` off that resolved handle — so the implementor MUST trace registry-handle population on the `Resolve` path and confirm the - `AgentHandle` carries `Model`/`InstructionSlice` after a PROCESS-RESTART - reattach, not only an in-session reload. If registry rehydration does not - re-thread the store values (e.g. it does not rebuild the spec via provision - from the store), this is a BLOCKER for T4, not a test note. + `AgentHandle` carries `Model` after a PROCESS-RESTART reattach, not only an + in-session reload. If registry rehydration does not re-thread the store + values (e.g. it does not rebuild the spec via provision from the store), + this is a BLOCKER for T4, not a test note. ### T5 — Agent: spawn-tool Layer-1 params @@ -679,57 +552,27 @@ Owner: compass-agent `spawnParameters` (`packages/compass-agent/src/lifecycle.ts:77-88`) gains `"model?"` (opaque selector string; description states it must resolve -against the fleet `models.yml` and that empty = fleet default) and -`"instruction_slice?"` (named ref; description states it names a -mount-provisioned `instructions/.md` and is append-scoped). No mode, -no text key — the tool carries exactly the Layer-1 tuple. The execute body -copies them onto `SpawnPeerRequestSchema` (`lifecycle.ts:151-160`). Tool -description documents set-at-creation-only semantics. +against the fleet `models.yml` and that empty = fleet default). No text/mode +key — the tool carries exactly the Layer-1 tuple (model + the `role` RIG-2673 +already adds). The execute body copies it onto `SpawnPeerRequestSchema` +(`lifecycle.ts:151-160`). Tool description documents set-at-creation-only +semantics. Interfaces: -- Consumes: T1 TS gen (`SpawnPeerRequestSchema` new fields). -- Produces: `agents_spawn_peer` args `model?`, `instruction_slice?`. -- Test: `lifecycle.test.ts` — wire request carries the params verbatim; - omitted params encode empty (no field); the schema exposes no +- Consumes: T1 TS gen (`SpawnPeerRequestSchema` new field). +- Produces: `agents_spawn_peer` arg `model?`. +- Test: `lifecycle.test.ts` — wire request carries the param verbatim; + omitted param encodes empty (no field); the schema exposes no text/mode-shaped key. -### T6 — Agent: instruction-slice consumption in the entrypoint - -Owner: compass-agent - -`config-reader.ts` gains `readMountedInstructionSlice(currentDir, slice)` -mirroring `readMountedRolePrompt` (`:370-390`): same traversal guard, path -`instructions/.md`, tolerant absent/empty → `undefined`. `cli.ts` -gains `resolveInstructionSlice(env)` beside `resolveRole` (`:181-186`) -reading `COMPASS_INSTRUCTION_SLICE`; `main()` appends the resolved slice body -to the `rules` array passed at `cli.ts:902` (one more always-apply rule, -after the fleet rules). Unset or unresolvable → today's behavior exactly. -Cross-lane note: the runner half of the `instructions/` STRUCTURAL TWIN lands -here — add the twin `topDirInstructions` constant + `configTopDirs` entry to -`go/internal/runner/config_materialize.go:61-78` (kept textually in lockstep -with the store door). The store-door half is T3s; per the Plan ordering T3s -lands before or with this task so a store-accepted `instructions/` bundle is -never runner-rejected at materialize. - -Interfaces: - -- Consumes: T4 env contract; mount layout `instructions/.md`. -- Produces: `readMountedInstructionSlice(currentDir: string, slice: string): - Promise`; `resolveInstructionSlice(env): - string | undefined`. -- Test: `cli.test.ts` createSession-spy pattern (`:930-957`) — slice present - ⇒ rules array gains the slice body after the fleet rules; absent file ⇒ - unchanged rules; traversal label ⇒ ignored; no slice ⇒ options identical - to today. - -### T7 — Harness alignment (RIG-2935) +### T6 — Harness alignment (RIG-2935) Owner: harness The Layer-1 tuple's frozen definition lives in RIG-2935's Interfaces section; harness owns the wave-spawn consumer and the wave-composition cross-family -rule + rollout-artifact lint (§5). Alignment settled (driver-confirmed); this +rule + rollout-artifact lint (§4). Alignment settled (driver-confirmed); this task is the pointer, not new design. Interfaces: @@ -741,97 +584,88 @@ Interfaces: ## Tasks -- [ ] T1 `[compass-server]` proto: `SpawnPeerRequest.model=7` + - `instruction_slice=8`, `ProvisionAgentWorkspaceRequest.model=5` + - `instruction_slice=6`, regen (3 buf lanes) -- [ ] T2 `[compass-server]` store: `agent_accounts` `model` + - `instruction_slice` columns + `NewAgent` fields + round-trip pgtest +- [ ] T1 `[compass-server]` proto: `SpawnPeerRequest.model=7`, + `ProvisionAgentWorkspaceRequest.model=5`, regen (3 buf lanes) +- [ ] T2 `[compass-server]` store: `agent_accounts` `model` column + + `NewAgent` field + round-trip pgtest - [ ] T3 `[compass-server]` spawn threading + provision overwrite-from-store extension (`AgentOverrides` struct, stale field-number comment fix) + pgtests -- [ ] T3s `[compass-server]` `instructions/` store-door top-dir: constants + - `configBundleTopDirs` + `configMemberParts` rejection string + flat-member - validation arm + info-view bucket + store-door pgtest (blocks criterion (c)) -- [ ] T4 `[compass-runner]` `AgentSpec`/`AgentEnv` fields, per-agent-first - model fallback in `agentEnv`, `COMPASS_INSTRUCTION_SLICE` injection + - spec-survival-on-reattach test + tests -- [ ] T5 `[compass-agent]` `agents_spawn_peer` `model?` + - `instruction_slice?` (Layer-1 only, no text/mode) + wire tests -- [ ] T6 `[compass-agent]` `readMountedInstructionSlice` + - `resolveInstructionSlice` + `main()` rules-append composition + - `instructions/` runner top-dir twin + tests -- [ ] T7 `[harness]` RIG-2935 Layer-1 tuple freeze + wave-composition +- [ ] T4 `[compass-runner]` `AgentSpec`/`AgentEnv` model field, per-agent-first + model fallback in `agentEnv` + spec-survival-on-reattach test + tests +- [ ] T5 `[compass-agent]` `agents_spawn_peer` `model?` (Layer-1 only, no + text/mode) + wire tests +- [ ] T6 `[harness]` RIG-2935 Layer-1 tuple freeze + wave-composition cross-family rule (alignment pointer) ## Open Questions - **OQ-1 (LOAD-BEARING — routed to Matt as RIG-2937 — subtree composition authority).** May the agent-facing spawn path let a supervisor agent - compose its subtree's Layer-1 config — model + role + instruction_slice as - bounded selections, each resolving against server-provisioned config the - operator controls? This is the record's one load-bearing question; it has a - durable decision home in **RIG-2937** (assigned Matt, options + driver - recommendation), distinct from the driver recommendation below. Layer 1 has - no free-text INJECTION surface by construction (a ref selects, never - authors), but "no injection" is not "no harm", and letting a parent AGENT - set a child's model/role is a POLICY change from today: role is + compose its subtree's Layer-1 config — model + role as bounded selections, + each resolving against server-provisioned config the operator controls? + This is the record's one load-bearing question; it has a durable decision + home in **RIG-2937** (assigned Matt, options + driver recommendation), + distinct from the driver recommendation below. Layer 1 has no free-text + INJECTION surface by construction (a label/selector selects, never authors), + but "no injection" is not "no harm", and letting a parent AGENT set a + child's model/role is a POLICY change from today: role is server-authoritative-from-AgentAccount (`compass_pb.ts:1090-1094`; enforced `service.go:159-165`) and the model is operator-set Runner config (`runner.go:48-51`). **Sub-fork (mutation authority):** the same composition-authority question extends to tuple MUTATION on a live agent - (re-laddering a running handle's model/role/slice) — a strictly scarier + (re-laddering a running handle's model/role) — a strictly scarier surface than set-at-creation. This record scopes mutation OUT (see OQ-5); RIG-2937 is the home if Matt wants the mutation path in scope now. - **The attack Matt must weigh — OVERSIGHT DEGRADATION BY COMPOSITION.** The config bundle is fleet-global, so the caller-owner fence bounds the child ACCOUNT, not the SELECTABLE SET: a supervisor can select any vetted - slice/role/model in the fleet for its child. A compromised or misaligned + role/model in the fleet for its child. A compromised or misaligned supervisor can therefore spawn the reviewer that reviews its own subtree's - output on the weakest registry model, same family as the implementor, with - the most relaxed evidence slice — every step a legitimate bounded - selection, the harm being adversarial COMPOSITION of individually-vetted - parts. This is softest exactly where it matters most (a supervisor - composing its own review pair) because on Compass the cross-family - constraint is advisory-only (§5). Related unweighed authority: cost (a - supervisor pinning every child to the most expensive selector; no quota - modeled). + output on the weakest registry model, same family as the implementor — + every step a legitimate bounded selection, the harm being adversarial + COMPOSITION of individually-vetted parts. This is softest exactly where it + matters most (a supervisor composing its own review pair) because on + Compass the cross-family constraint is advisory-only (§4). Related + unweighed authority: cost (a supervisor pinning every child to the most + expensive selector; no quota modeled). - **Recommendation (driver): YES, with the operator-policy-bounded-selection rider.** It is RIG-2936's whole premise — a supervisor deploys the implementor ladder in its own tree per the eval migration order (Implementor → Reviewer → Manager/Designer) — and no free-text injection opens; selection is owner-fenced and set-at-creation-only. It extends Matt's RIG-2673 ruling (no role allowlist; "agent acts as owning user, - ACLs later") to the model + slice fields. The rider closes the composition + ACLs later") to the model field. The rider closes the composition attack cheaply: make the SELECTABLE SET an operator policy artifact (a - `selectable:` block in the fleet bundle mapping role → allowed - models/slices, §Alternatives), additive and absent-file = full authority, - so YES-now / policy-bound-later is available. - - **Alternative: operator-composed waves only** — keep role and instruction - fully server-authoritative-from-AgentAccount as today (optionally letting - only `model` be agent-settable). Preserves the current policy line exactly - but defeats the per-tree incremental rollout the issue asks for: every + `selectable:` block in the fleet bundle mapping role → allowed models, + §Alternatives), additive and absent-file = full authority, so YES-now / + policy-bound-later is available. + - **Alternative: operator-composed waves only** — keep role fully + server-authoritative-from-AgentAccount as today (optionally letting only + `model` be agent-settable). Preserves the current policy line exactly but + defeats the per-tree incremental rollout the issue asks for: every candidate-model agent needs a human touch, contradicting no-human-clicks (RIG-2673 DL-new-5). - **OQ-2 (non-load-bearing, decided in-record — flag if disagreed).** Cross-family enforcement point: wave-level/tree-composition config (harness's machine lint + Compass supervisor spawn guidance, ADVISORY on Compass), NOT a per-agent tuple field. A server-side check IS expressible on - this record's data (child role + child/parent model + parent-of edge, §5) + this record's data (child role + child/parent model + parent-of edge, §4) but rejected as the enforcement point because parent-of is an unreliable proxy for reviewer-of; a non-blocking WARN-level family-match flag on reviewer-role spawns is a cheap additive backstop, deferred. -- **OQ-3 (non-load-bearing deferral).** The Layer-2 inline-text provision - field (operator-path free text beyond what `AgentAccount.persona` already - carries) is deferred until an operator need materializes; Layer 2 today is - the existing role/persona server-authoritative surface itself. Nothing in - Layer 1 depends on it. +- **OQ-3 (non-load-bearing deferral).** The Layer-2 operator block-0-replace + field (operator-path free-text authoring beyond what `AgentAccount.role` + already selects) is deferred until an operator need materializes; Layer 2 + today is the existing role/persona server-authoritative surface itself. + Nothing in Layer 1 depends on it. - **OQ-4 (mechanical, driver resolves).** Ledger row numbers below are `DL-`; the driver assigns concrete numbers at submit against the live `docs/designs/DECISIONS.md` head (DL-278 at drafting time). - **OQ-5 (non-load-bearing deferral — tuple MUTATION scoped out).** Every override field is set-at-creation-only: despawn preserves identity (`lifecycle.go:205-210`) and re-spawn resumes under STORED values - (`:322-343`), so an agent's model/role/slice is immutable through every + (`:322-343`), so an agent's model/role is immutable through every agent-facing path for its handle's lifetime. Decision: for the initial rollout, **tuple change = a new handle** (each ladder rung — implementor candidate A → B, or a promote to the Manager tier — is a fresh lane), which @@ -841,75 +675,63 @@ Interfaces: pre-freeze visibility rides OQ-1's RIG-2937 home as an explicit sub-fork (above) rather than a second load-bearing question here. The record is correct and shippable without it. -- **OQ-6 (LOAD-BEARING — instruction_slice keep-or-cut, harness-owned OQ9, - Matt to rule).** The per-agent instruction-slice dial's PREMISE is under - review: the model-eval lane flagged that the eval's instructions-first lever - may assume a capability no harness ships by default, and is running - OSS-harness research to bring Matt a keep-or-cut recommendation. This is - owned on the harness side (RIG-2935 OQ9) because the shared Layer-1 tuple is - harness-canonical; Compass consumes the outcome. It blocks this record's - freeze (a frozen tuple must not carry a field whose premise is unsettled) — - the merge-freeze is already held for it. - - **If KEEP (design-against default):** proceed exactly as designed — - `instruction_slice` stays the third Layer-1 field, with T3s (store-door - whitelist), T5's `instruction_slice?` tool param, and T6 - (`readMountedInstructionSlice` + runner twin + rules-append) all as - specified. Criterion (c) is satisfied by the slice mechanism. - - **If CUT:** Layer 1 collapses to `{ model?, role? }`. The exact Compass - cut surface: drop the `instruction_slice` tuple field and its - `SpawnPeerRequest`/`ProvisionAgentWorkspaceRequest` wire fields + store - column (from T1/T2/T3), drop T3s and the T6 runner-twin whole, drop T5's - `instruction_slice?` param, and drop `readMountedInstructionSlice` + - `resolveInstructionSlice` + the rules-append composition from T6 — the - record simplifies to per-agent model + role only. The ref-only-append - anti-injection property (§Layer 1) simply has nothing to attach to and is - removed with the field; it opens no new surface either way. Criterion (c) - would need re-scoping with the eval lane (it is the only acceptance - criterion the cut touches; (a) and (b) are unaffected). - - **Independent of OQ-1/RIG-2937:** the role-security composition-authority - fork stands whether the field is kept or cut; OQ-6 changes only whether - the third field exists. +- **OQ-6 (RESOLVED — Matt ruled CUT, 2026-08-27, via model-evals).** The + per-agent instruction-slice dial (a third Layer-1 field pointing at a + mount-provisioned `instructions/.md`) was proposed and then CUT from + the tuple. **The cut is NOT a capability gap:** harnesses do ship + model-keyed prompts by default (Hermes, opencode, OpenHands, Cline). It is a + deliberate **shared-corpus-first choice** — one hardened shared prompt + benefits all models and avoids the staleness of a per-model variant matrix — + deferred, not unbuildable. Consequences for this record, all applied above: + Layer 1 is `{ model?, role? }`; the `instruction_slice` field, its + `SpawnPeerRequest`/`ProvisionAgentWorkspaceRequest` wire fields and + `agent_accounts` column, the `instructions/` store-door + runner top-dir + whitelists (the structural twin), and the in-container slice consumption + (`readMountedInstructionSlice` / `resolveInstructionSlice` / rules-append) + are all out of scope. Acceptance criterion (c) (per-agent instruction + variation without forking the shared corpus) is descoped WITH the field — + it is the only criterion the cut touches; (a) the model ladder and (b) the + cross-family constraint stand. With this resolved, the record has exactly + one load-bearing question (OQ-1). If a per-agent instruction dial is ever + revived, it re-enters as an additive Layer-1 field via a fresh record — the + tuple's additive shape leaves that path open. ## Ledger delta Draft rows for `docs/designs/DECISIONS.md` (driver applies numbers + the flip at submit, per the same-PR ledger-delta rule): -- **DL-next-1:** The per-agent override tuple {model, role, - instruction_slice} is parent-choosable at spawn as additive - `SpawnPeerRequest` fields (`model = 7`, `instruction_slice = 8`; - `role = 5`/`persona = 6` per RIG-2673), stored at creation and threaded - store → provision → runner env — the model becoming per-agent-first with - the Runner-global `AgentModel` as fleet default fallback (supersedes the +- **DL-next-1:** The per-agent override tuple {model, role} is + parent-choosable at spawn as additive `SpawnPeerRequest` fields + (`model = 7`; `role = 5`/`persona = 6` per RIG-2673), stored at creation and + threaded store → provision → runner env — the model becoming per-agent-first + with the Runner-global `AgentModel` as fleet default fallback (supersedes the model-is-Runner-wide posture of `runner.go:48-51` as a default, not a contract). - **DL-next-2:** The cross-lane override contract is two-layered, and the boundary is AUTHORING vs SELECTION (not append vs replace): Layer 1, the - shared portable tuple {model: `provider/model:thinking`, role, - instruction_slice(named ref, append-scoped)} owned by the harness lane - (RIG-2935) and consumed by Compass, is bounded SELECTION against - server-provisioned config (a ref selects, never authors — no free-text - injection surface) and may select replace-composed content (a `role` label - selecting `SYSTEM.md`, which replaces block-0) or append-composed content - (a slice); Layer 2, the Compass-only escape hatch, is caller-AUTHORED free - text (inline instruction text, or an inline block-0 swap from - caller-supplied bytes), rides the already-server-authoritative provision - path, and is never agent-facing. -- **DL-next-3:** Instruction slices are mount-delivered - (`instructions/.md` in the fleet config bundle, admitted at BOTH the - store-door and runner top-dir whitelists — structural twins), selected per - agent by reference at spawn and composed at the rules-append surface — - instruction variation per agent/model never forks the shared corpus. -- **DL-next-4:** The cross-family review constraint (Reviewer.family ≠ + shared portable tuple {model: `provider/model:thinking`, role} owned by the + harness lane (RIG-2935) and consumed by Compass, is bounded SELECTION + against server-provisioned config (a label/selector selects, never authors — + no free-text injection surface) and may select replace-composed content (a + `role` label selecting `SYSTEM.md`, which replaces block-0); Layer 2, the + Compass-only escape hatch, is caller-AUTHORED free text (an inline block-0 + swap from caller-supplied bytes), rides the already-server-authoritative + provision path, and is never agent-facing. +- **DL-next-3:** The cross-family review constraint (Reviewer.family ≠ Implementor.family; Design-critic.family ≠ Designer.family) is wave-level composition config — expressible against the Layer-1 tuple map (family = registry function of the model selector), MACHINE-enforced on the harness lane (rollout-artifact lint) and ADVISORY on Compass (supervisor spawn guidance), never a `SpawnPeerRequest` field or blocking server-side spawn check (parent-of is an unreliable proxy for reviewer-of). -- **DL-next-5:** Per-agent overrides are set-at-creation-only (re-spawn and +- **DL-next-4:** Per-agent overrides are set-at-creation-only (re-spawn and resume run under STORED values); tuple MUTATION (re-laddering a live agent's - model/role/slice) is out of scope for this record — the initial eval - rollout ladders by fresh handle per rung — and a mutable-override path, - which reopens the composition-authority question for mutation, is deferred - (OQ-5). + model/role) is out of scope for this record — the initial eval rollout + ladders by fresh handle per rung — and a mutable-override path, which + reopens the composition-authority question for mutation, is deferred (OQ-5). +- **DL-next-5:** The per-agent instruction-slice dial is CUT (Matt, via + model-evals) — a deliberate shared-corpus-first choice (one hardened shared + prompt over a per-model variant matrix), not a capability gap. Layer 1 is + {model, role}; a per-agent instruction dial, if ever revived, re-enters as + an additive Layer-1 field via a fresh record (OQ-6). From f7810a96eb1167c9592164d7eebc37b50937e835 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 22:42:19 -0400 Subject: [PATCH 4/4] docs(product): review nits on the instruction_slice cut (RIG-2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sole review pass on the cut returned CLEAN at the gating floor (high 0 / medium 0), two non-gating lows. Both fixed here: - Normalize the OQ-6 prose tuple to {model?, role?} (no inner brace spaces) to match the frozen Layer-1 code block and DL-next-5. - Retitle the grounding bullet 'The instruction surfaces are the config mount' to 'The config-mount surfaces (prompts/, rules/, models.yml)' — removes an incidental 'instruction' word collision in a record that cut the instruction dial; the bullet's content (prompts/, rules/, models.yml mount surfaces the surviving role/model design rests on) is unchanged. Review verified the cut complete and coherent: all slice vocabulary confined to OQ-6 + DL-next-5, tasks T1-T6 consistent, ~14 file:line citations spot-checked accurate against live source, OQ-1 the sole load-bearing question, acceptance (a)/(b) intact and (c) cleanly descoped. Review loop all-clear. Gates green (scrub, design-ledger-gate check 245/98, markdownlint 0). Status stays Draft; merge-freeze held pending Matt's RIG-2937 ruling + the harness frozen-tuple ping. Ledger-impact: deferred to freeze. Co-authored-by: Matt Wilkinson --- docs/designs/product/compass-per-agent-overrides/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/designs/product/compass-per-agent-overrides/design.md b/docs/designs/product/compass-per-agent-overrides/design.md index c453081f7..531f63525 100644 --- a/docs/designs/product/compass-per-agent-overrides/design.md +++ b/docs/designs/product/compass-per-agent-overrides/design.md @@ -101,7 +101,7 @@ and its rationale. (`docs/designs/platform/compass-initial-prompt-removal.md:14-16`), replaced by the channel-message first turn. New fields take fresh numbers. -- **The instruction surfaces are the config mount.** The role's block-0 comes +- **The config-mount surfaces (`prompts/`, `rules/`, `models.yml`).** The role's block-0 comes from `prompts//SYSTEM.md` read by `readMountedRolePrompt` (traversal-guarded, `packages/compass-agent/src/config-reader.ts:370-390`), injected as `customSystemPrompt` (REPLACES block-0) while persona appends @@ -683,7 +683,7 @@ Interfaces: deliberate **shared-corpus-first choice** — one hardened shared prompt benefits all models and avoids the staleness of a per-model variant matrix — deferred, not unbuildable. Consequences for this record, all applied above: - Layer 1 is `{ model?, role? }`; the `instruction_slice` field, its + Layer 1 is `{model?, role?}`; the `instruction_slice` field, its `SpawnPeerRequest`/`ProvisionAgentWorkspaceRequest` wire fields and `agent_accounts` column, the `instructions/` store-door + runner top-dir whitelists (the structural twin), and the in-container slice consumption