Files
msd-core/gsd-core/references/model-profile-resolution.md
Tom Boucher 6fb832a93d fix(#2684): bind scan.md and ship.md model dispatch to fields their workflows resolve (#2710)
* test(#2684): failing-first guard for unbound model= dispatch placeholders

Extends the #2517 omit-on-inherit bucket with a behavioral binding guard: every
model="{X}" in a workflow must name a field that workflow actually binds — an
init-payload key (queried for real), a shell assignment, or a declared parse
field. scan.md ({resolved_model}) and ship.md ({balanced_model}) substitute
names nothing emits, so the orchestrator invents the value (ADR-1411).

Also pins the shipped reference that seeded the placeholder and instructs the
#2517-forbidden model="inherit".

RED expected on scan.md, ship.md, and references/model-profile-resolution.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* fix(#2684): bind scan.md and ship.md model dispatch to fields their workflows resolve

scan.md:85 passed model="{resolved_model}" and ship.md:486 passed
model="{balanced_model}" — names neither workflow's init payload emits.
init.map-codebase emits mapper_model; init.phase-op emits no model field at
all. With no source for the substitution the orchestrator invents a value, so
model_overrides/model_policy are silently inert at both sites — the invisible
partial application ADR-1411 prohibits.

- scan.md: declare the init fields in a Parse JSON line and substitute
  {mapper_model}, matching its sibling map-codebase.md.
- ship.md: ref.agent is only known at runtime, so resolve it per hook via
  query resolve-model and dispatch with {HOOK_AGENT_MODEL}, following the
  same NAME=$(...) → model="{NAME}" convention every other shell-resolved
  dispatch in the corpus uses (code-review.md, secure-phase.md, ui-phase.md).
- Both sites now carry the #2517 rule: omit model= entirely when the resolved
  value is "inherit" or empty. A bare rename would have traded a dangling
  placeholder for model="", which 404s on non-Claude runtimes — an agent type
  absent from the profile table resolves to the empty string, which is exactly
  ship.md's case.
- references/model-profile-resolution.md was the seam: it shipped the
  copy-pasteable {resolved_model} snippet scan.md inherited, used the stale
  Task( spelling, and instructed passing model="inherit" outright. Rewritten
  to teach the real binding convention and the omit rule.

Two defects surfaced while fixing this and fixed inline rather than deferred:

1. ref.agent originates in a capability manifest, which may be third-party.
   Resolving it at runtime made this the first place that value reaches a
   shell command, so ship.md now validates its shape before interpolating and
   skips the hook when it fails — matching code-review.md's existing
   defense-in-depth pattern. Covered by a test that runs the shipped regex
   against real agent names and injection payloads.
2. The reference doc's omit example first placed a literal model= inside an
   Agent(...) comment, which tripped the #2284 fail-closed Hermes projection
   guard (bin/install.js:3704) and refused the install outright. Moved out of
   the call span; gen:golden is green across all 19 runtimes.

Guard tests extend the existing #2517 bucket: every model="{X}" in a workflow
must name a field that workflow binds — an init-payload key queried for real,
a shell assignment, or a declared parse field.

Behavior change (Hyrum's Law): the scan mapper now runs on the catalog-resolved
model rather than the session model. The omit path is unchanged. Disclosed in
the changeset body.

Fixes #2684

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* fix(#2684): validate capability-supplied ref.agent in-context, not in the shell

The first cut of the ref.agent guard was placed after the injection point it
was meant to close. It instructed the orchestrator to substitute the untrusted
manifest value into a shell assignment and test it there:

    HOOK_AGENT="<the ref.agent value>"
    if [[ "$HOOK_AGENT" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then …

Substitution happens before bash parses anything, so a manifest supplying
`x"; touch /tmp/pwned; echo "` yields three statements and runs the middle one
unconditionally — the regex fires afterwards and protects nothing.

ship.md now requires the check to run in-context, the same way the workflow
already reads activeHooks ("do NOT pipe it through a shell parser"), and to
skip the hook outright on a mismatch. Only a value that has already matched
^[A-Za-z0-9][A-Za-z0-9._-]*$ ever reaches a command line. The guard test asserts
the ordering — the in-context requirement and the absence of any raw shell
assignment — not just that the pattern rejects metacharacters, since a pattern
alone was exactly what gave false assurance here.

Also corrects the empty-string explanation in references/model-profile-
resolution.md. model_profile:"inherit" resolves to the literal "inherit", not
"" (model-resolver.cts:395), and an unknown agent takes the empty-string path
via resolve_model_ids:"omit" rather than by absence alone — the doc claimed all
three produced "". The workflow instructions were already correct (omit on
"inherit" OR empty); only the rationale in the citable reference was wrong.

Both found by the isolated adversarial review pass for #2684.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* chore(#2684): backfill changeset PR number

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 13:51:16 -04:00

3.3 KiB

Model Profile Resolution

Resolve each agent's model through gsd-tools, then pass it to the Agent() spawn — or omit the parameter entirely when nothing resolved.

Resolution Pattern

Prefer the field your workflow's own init.* payload already emits (every orchestrator init that spawns subagents carries one — mapper_model, planner_model, executor_model, doc_writer_model, …). Declare it in the workflow's parse line so the binding is stated, not implied:

Parse JSON for: `mapper_model`, …

When the agent type is only known at runtime — a capability hook naming its own agent, for example — resolve it directly instead:

AGENT_MODEL=$(gsd_run query resolve-model "<agent-type>" --raw)

Both surfaces return the same thing: a model string for the active runtime, or the empty string when nothing resolved.

Lookup Table

@~/.claude/gsd-core/references/model-profiles.md

Passing the model to a spawn

Agent(
  prompt="...",
  subagent_type="gsd-planner",
  model="{planner_model}"
)

Substitute the field this workflow bound — never a generic placeholder name.

#2517 — omit, do not emit an empty model. When the resolved value is "inherit" or empty, omit the model= parameter entirely:

Agent(
  prompt="...",
  subagent_type="gsd-planner"
)

No model parameter is passed at all — planner_model resolved to "inherit" or empty, and omitting it inherits the orchestrator's model. Passing either value through as an argument instead 404s on non-Claude runtimes.

This is not cosmetic, and both values really occur. model_profile: "inherit" — and any opus-tier agent — resolves to the literal string "inherit". resolve_model_ids: "omit" resolves to the empty string whenever the project sets it explicitly or the active runtime has no native tier aliases; an agent type absent from the profile table takes that same empty-string path, because it has no tier for the earlier steps to resolve. Emitting either value verbatim fails the spawn on every runtime without native tier aliases.

#2684 — substitute a field your workflow actually bound. model="{…}" must name a key your own init.* payload emits, a shell variable you assigned, or a field on your declared parse line. A placeholder that resolves to nothing does not fail loudly — the orchestrator silently invents a value, which is the invisible partial application ADR-1411 prohibits. tests/model-omit-when-inherit-guard.test.cjs enforces both rules.

Profile semantics

Note: Opus-tier agents resolve to "inherit" (not "opus"). This causes the agent to use the parent session's model, avoiding conflicts with organization policies that may block specific opus versions — and, per the rule above, means the model= parameter is omitted rather than set.

If model_profile is "adaptive", agents resolve to role-based assignments (opus/sonnet/ haiku based on agent type).

If model_profile is "inherit", all agents resolve to "inherit" (useful for OpenCode /model).

Usage

  1. Bind the model once — from the init.* payload field, or query resolve-model when the agent type is runtime-determined
  2. Declare the bound field on the workflow's parse line
  3. Pass model="{bound_field}" on each Agent() spawn
  4. Omit model= entirely whenever the bound value is "inherit" or empty