Skip to content

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.

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.

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. 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/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/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/<role>/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)

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.

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;
};
  • 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/<role>/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/<role>/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.

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 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.

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:

    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.

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/<role>/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)

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 modelSpawnPeerRequest.modelCOMPASS_MODEL; tuple roleSpawnPeerRequest.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.

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).
  • 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.
  • 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.

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 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

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.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

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 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.

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.goagentEnv 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.

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.

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_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)
  • 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-<next>; 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/<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?}; 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.

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).