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
Section titled “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
Section titled “Approach”Grounding: what is per-agent today, and what is not
Section titled “Grounding: what is per-agent today, and what is not”-
Model is Runner-global.
AgentModelis “the model selector handed to every agent this Runner starts (the agent’s COMPASS_MODEL)” (go/internal/runner/runner.go:48-51), threaded toAgentHostConfig.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), viaagentEnv:// go/internal/runner/host.go:878-887func (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)returnsenv.COMPASS_MODEL?.trim()“as an opaque pattern string forcreateAgentSessionto resolve against its own model registry” (packages/compass-agent/src/cli.ts:135-149), forwarded asmodelPattern(cli.ts:850). -
Role and persona are per-agent but server-authoritative.
ProvisionAgentWorkspaceRequest.persona/.roleare 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/server/service.go:159-165if acc.IsAgent() {req.Msg.Persona = acc.Agent.Personareq.Msg.Role = acc.Agent.Role} else {req.Msg.Persona = ""req.Msg.Role = ""}pinned by tests (
go/server/service_placement_pgtest_test.go:330-366persona,:404-441role: “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)” withPersona: "", Role: ""in thestore.CreateAgentliteral (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
SpawnPeerRequestcarries onlyhandle = 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.mdDL-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 fromprompts/<role>/SYSTEM.mdread byreadMountedRolePrompt(traversal-guarded,packages/compass-agent/src/config-reader.ts:370-390), injected ascustomSystemPrompt(REPLACES block-0) while persona appends after (cli.ts:905-929). Fleet rules load from the mount’srules/and pass as the SDKrulesarray (config-reader.ts:404-408,cli.ts:892-902). The model registry loads from the mount’s top-levelmodels.yml, symlinked into$HOME/.omp/agent/models.ymlfor the SDK’s ModelRegistry (config-reader.ts:392-401,cli.ts:788) — “nocreateAgentSessionobject seam for the ModelRegistry yet (flagged gap)” (config-reader.ts:486-489). The mount itself is server-provisioned: bundles flow store door → RunnerConfigMaterializerunpack → read-only container mount with atomiccurrentflips (go/internal/runner/config_materialize.go:3-11).
The two-layer override contract (frozen with RIG-2935)
Section titled “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
Section titled “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:
type AgentOverride = { /** models.yml selector 'provider/model:thinking'; wins over role. */ model?: string; /** Which modelRoles tier the agent runs as (role label). */ role?: string;};modeluses the existing opaqueCOMPASS_MODELselector 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 againstmodels.yml.roleis the role label / modelRoles tier — on Compass theprompts/<role>/SYSTEM.mdblock-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:
rolenames a mount/version-controlledprompts/<role>/SYSTEM.mdthe operator/server provisioned, andmodelnames an entry in the server-provisionedmodels.ymlregistry. 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
Section titled “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/<role>/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
ProvisionAgentWorkspaceRequestpath, 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 sourcecompass.proto:579-600; live overwriteservice.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
Section titled “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:
-
SpawnPeerRequestgainsstring model = 7(fields 5/6 are claimed by RIG-2673’srole/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 withmodelempty and stay on the known-good Runner-configured tier. -
store.NewAgentgainsModel stringbesidePersona/Role(go/internal/store/inputs.go:20-37);agent_accountsgains amodelcolumn;SpawnAsAccountwritesModel: req.GetModel()atCreateAgentandprovisionAndStartthreadscreated.Agent.Model— the request value is never forwarded directly, matchinglifecycle.go:197. -
ProvisionAgentWorkspaceRequestgainsstring model = 5, SERVER-AUTHORITATIVE with the same overwrite-from-store contract as persona/role:service.go:159-165growsreq.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.AgentSpecgainsModel stringbesidePersona/Role(go/internal/runtime/agent.go:44-51) with anAgentHandle.Model()accessor (mirroringagent.go:84-90); the Runner’sSpecBuilder.BuildSpeccopies it from the request. -
agentEnv(host.go:878-887) becomes per-agent-first with Runner-global fallback:model := handle.Model()if model == "" {model = h.model}AgentEnv.Modeland theexecSpecomitted-when-empty injection (agent_exec.go:83-85) are unchanged — the container still just readsCOMPASS_MODEL. -
Registry resolution is unchanged by construction. The per-agent selector lands in the same
COMPASS_MODELenv var and flows tomodelPattern: resolveModelSelector(env)(cli.ts:850), resolving against the same mount-symlinkedmodels.ymlregistry (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 fleetmodels.yml.
2. Per-agent role
Section titled “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 selectsconfig/prompts/<role>/SYSTEM.mdunder 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 threadexisting.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)
Section titled “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; tuplerole→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
Section titled “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
modelselector (theprovider/modelprefix resolved againstmodels.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
modelwhose 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) andmodel(wire,model = 7), the spawner (ParentAgentID: callerset at creation,go/server/lifecycle.go:190-193), and the parent’smodel(a store column per T2) — sofamily(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
Section titled “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 newmodelfield 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 (arolelabel choosing a server-provisionedSYSTEM.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 shipsmodels.ymlthrough 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
Section titled “Global Constraints”- Additive to the frozen spawn/despawn wire contract. No wire-breaking
change to
SpawnPeerRequestexisting fields (handle = 1,display_name = 2,client_request_id = 4); field 3 isreserved/initial_prompt(DL-187) and is NOT reclaimed; new fields take fresh numbers (role = 5/persona = 6per 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 atservice.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 — andprompts//models.ymlare 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
ProvisionAgentWorkspaceRequestfield 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
readMountedRolePromptguard 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.
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
Section titled “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 inpackages/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
Section titled “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.Modelon reads. - Consumes: existing
CreateAgent(ctx, ownerUserID, NewAgent). - Test: pgtest round-trip — create with
modelset, read back verbatim; empty default for a bare create.
T3 — Server: spawn threading + provision overwrite
Section titled “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
modelset →agent_accountsrow value AND Provision wire carries it (pattern:service_placement_pgtest_test.go:957-976provisionPersona/provisionRoleaccessors; addprovisionModel); 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
Section titled “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 contractCOMPASS_MODEL(now a per-agent value with Runner-global fallback). - Test:
agentenv_test.gopattern (: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—agentEnvfallback precedence. Spec-survival case: a handle reconstructed on the reattach/recovery path must still carryModel, 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 viah.registry.Resolve(name)(host.go:299) rather than re-provisioning, andagentEnvreadshandle.Model()/.Role()off that resolved handle — so the implementor MUST trace registry-handle population on theResolvepath and confirm theAgentHandlecarriesModelafter 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
Section titled “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 (
SpawnPeerRequestSchemanew field). - Produces:
agents_spawn_peerargmodel?. - 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)
Section titled “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.
- T1
[compass-server]proto:SpawnPeerRequest.model=7,ProvisionAgentWorkspaceRequest.model=5, regen (3 buf lanes) - T2
[compass-server]store:agent_accountsmodelcolumn +NewAgentfield + round-trip pgtest - T3
[compass-server]spawn threading + provision overwrite-from-store extension (AgentOverridesstruct, stale field-number comment fix) + pgtests - T4
[compass-runner]AgentSpec/AgentEnvmodel field, per-agent-first model fallback inagentEnv+ spec-survival-on-reattach test + tests - T5
[compass-agent]agents_spawn_peermodel?(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
Section titled “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; enforcedservice.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
modelbe 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.rolealready 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-<next>; the driver assigns concrete numbers at submit against the livedocs/designs/DECISIONS.mdhead (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/<slice>.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?}; theinstruction_slicefield, itsSpawnPeerRequest/ProvisionAgentWorkspaceRequestwire fields andagent_accountscolumn, theinstructions/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
Section titled “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
SpawnPeerRequestfields (model = 7;role = 5/persona = 6per RIG-2673), stored at creation and threaded store → provision → runner env — the model becoming per-agent-first with the Runner-globalAgentModelas fleet default fallback (supersedes the model-is-Runner-wide posture ofrunner.go:48-51as 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 (arolelabel selectingSYSTEM.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
SpawnPeerRequestfield 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).