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..531f63525 --- /dev/null +++ b/docs/designs/product/compass-per-agent-overrides/design.md @@ -0,0 +1,737 @@ +# Compass per-agent model and role overrides + +Status: Draft + +Refs: RIG-2936 (this record). Sibling: RIG-2935 (harness/OMP wave-spawn — owns +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. + +## 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 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). 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 + +### 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 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 + 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** — 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 + +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 = { + /** models.yml selector 'provider/model:thinking'; wins over role. */ + model?: string; + /** Which modelRoles tier the agent runs as (role label). */ + role?: string; +}; +``` + +- `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. +- **Key property: Layer 1 has NO prompt-injection surface BY CONSTRUCTION.** + 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 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`). 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: + +- 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 + 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 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. 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` → 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. + +### 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 +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); 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 (§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 + 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, 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. + +## 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`). +- **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 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 + `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. +- **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) 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 (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` (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`, + `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 + (additive). + +### T2 — Store: override columns + creation threading + +Owner: compass-server + +`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`; 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 string}`; `AgentAccount.Agent.Model` on + reads. +- Consumes: existing `CreateAgent(ctx, ownerUserID, NewAgent)`. +- 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()` 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 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` 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` set → `agent_accounts` row value AND + Provision wire carries it (pattern: + `service_placement_pgtest_test.go:957-976` + `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. + +### T4 — Runner: per-agent model env + +Owner: compass-runner + +`runtime.AgentSpec` (`go/internal/runtime/agent.go:44-51`) gains +`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`; 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. `host_test.go` — `agentEnv` fallback + precedence. + **Spec-survival case:** a handle reconstructed on the reattach/recovery + 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` 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 + +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). 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 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 — 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 (§4). 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`, + `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 +- [ ] 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 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) — 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 + 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 — + 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 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, + §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, §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 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 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 + 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 (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} 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} 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-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) 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).