Files
msd-core/docs/adr/443-opus48-unified-effort-and-fast-mode-routing.md
Tom Boucher 004e9dd741 fix(#3007): resolve Codex reasoning effort per model and make every clamp visible (#3765)
* test(#3007): failing-first suite for per-model Codex effort capability

RED by construction. Binds to behavior renderEffortForRuntime does not yet
have: an optional third `model` argument, a per-model advertised-level table,
`max` passing through instead of clamping to `xhigh`, `minimal` clamping to
`low`, `ultra` rejected outright, and clamp visibility (`requested`/`clamped`/
`reason`) so a downgrade is legible from resolver output rather than silent.

Two of these pin defects that exist on next today:

- `max` is discarded. Both Codex models whose catalog entries are retrievable
  (sol, luna) advertise `max`; GSD clamps it to `xhigh` and reports nothing.
- `minimal` is emitted to a model that refuses it. providerPresets.openai.
  haiku.low pairs gpt-5.6-luna with reasoning_effort "minimal", and luna's
  advertised floor is `low`. GSD is sending a value into a document Codex
  itself validates. The parity test is what pins that fixed, and it names the
  offending path/model/effort when it trips.

Also corrects tests/model-resolver.test.cjs:351, which asserted
renderEffortForRuntime('codex','max').value === 'xhigh' -- the defect pinned as
though it were a contract. ADR-443 recorded "Codex has no max" as fact and it
was true when written; Codex has since added both `max` and `ultra`. That is a
stale premise, so the assertion is corrected here rather than worked around.

The property test asserts the invariant the whole change exists for: a rendered
effort is always a level the target model actually advertises, or an explicit
rejection. There is no third outcome.

* fix(#3007): resolve Codex effort per model, and make every clamp visible

Codex declares supported_reasoning_levels per MODEL and validates against it,
so a single per-runtime capability set cannot be right for all of them. GSD's
was wrong in both directions at once.

`max` reaches Codex now. ADR-443 recorded "Codex has no max" as fact and clamped
max -> xhigh on that basis; it was accurate when written, and Codex has since
added both `max` and `ultra`. Every Codex model whose catalog entry is
retrievable advertises `max`, so the clamp was discarding a level the provider
supports, silently, on the most-used path.

`minimal` stops reaching Codex. No Codex model advertises it -- both retrievable
entries floor at `low` -- yet providerPresets.openai.haiku.low paired
gpt-5.6-luna with reasoning_effort "minimal". GSD was writing a value the
receiver validates and refuses into a file the receiver reads. Being
unconservative in what you send is the half of Postel's rule with no defensible
reading, so that preset is corrected and a parity test pins it.

`ultra` is refused rather than laddered. Codex's own catalog calls it "Maximum
reasoning with automatic task delegation": at ultra, effective_multi_agent_mode
returns Proactive and Codex spawns sub-agents on its own initiative, underneath
GSD's orchestration rather than inside it (#2167). It is a mode switch, not a
reasoning depth, so it is not added to the universal ladder -- which stays
provider-agnostic by ADR-443's design -- and it is rejected even for
gpt-5.6-sol, which does advertise it. Clamping it down to `max` was considered
and rejected: that silently discards what the user actually asked for.

Clamping is now visible. RenderedEffort carries requested/clamped/reason and
resolve-execution surfaces them. The previous table clamped correctly but
invisibly, so a user asking for `max` on Codex had no way to find out they were
getting `xhigh` -- exactly the failure mode the robustness principle's modern
critique warns about, and why "be liberal" has to mean "liberal and loud".

Also closes a latent trap found while reviewing the implementation: the clamp-up
loop walks the ladder upward, and for a future model advertising `ultra` but not
`max` it would have selected `ultra` as the clamp target -- re-entering by the
back door the mode the rejection above exists to keep out. A clamp may never
produce a value that a direct request for that value would refuse. Unreachable
with today's catalog, which is why no test caught it; a test now asserts the
invariant directly.

Signature stability is preserved: the third `model` argument is optional and the
two-argument form still resolves, against the family baseline. That form's
BEHAVIOR does change for `max` and `minimal`, and it must -- keeping the old
answer would have fixed the defect only where a model happened to be threaded
through and left it live everywhere else.

tests/model-resolver.test.cjs:351 asserted the defect as if it were a contract
and is corrected here rather than worked around.

* fix(#3007): close every review finding on the Codex effort alignment

Two isolated reviewers, correctness and security. Both found the same two
blockers, and the per-model work was inert on every surface that matters until
this commit.

BLOCKER — resolve-execution never passed the model and discarded the clamp.
cmdResolveExecution called the two-argument form and emitted only
effort_rendered/effort_param/effort_propagation, so the per-model table was
unreachable from production code (tests were its only caller) and requested/
clamped/reason were computed and thrown away. Requested outcome 3 names "the
effective rendered effort in resolver output" specifically, so the feature was
unmet on the exact surface the issue asks for. Now passes the resolved model and
emits effort_requested / effort_clamped / effort_clamp_reason, flat, matching the
existing key convention rather than introducing a nested object.

BLOCKER — the docs described output that did not exist. CONFIGURATION.md showed
a nested {"effort": ...} sample; the real result is flat and those keys were
absent entirely. A reference doc asserting a JSON path a reader can copy is worse
than no doc. Corrected against the actual emitted key set.

MAJOR — the argv channel still shipped both original defects. EFFORT_ARGV.codex
kept minimal in its supported set and still clamped max down to xhigh, so the
invocation-time and install-time channels disagreed about the same runtime's
capability: --host codex with max emitted xhigh while the generated TOML said
max. This is the repo's documented generative-fix-divergence class, so both
tables now cross-reference each other and a parity test fails if they ever
diverge again.

MAJOR — malformed catalog data failed OPEN and could crash the CLI. A null
_baseline became an EMPTY Set that is nonetheless truthy, so the nullish fallback
never fired and every effort rendered as null. And a non-array value made the Set
constructor throw at module load — model-catalog.cjs is required across the whole
CLI, so one bad JSON value killed every command, not just codex effort. Guarded
on size and filtered to array values; both degrade to the hardcoded baseline.

MAJOR — value widened to a nullable string with two consumers left behind.
runtime-artifact-conversion passed it straight into injectEffortFrontmatter (a
null effort key in generated frontmatter); install-effort-resolver still declared
a non-nullable return, a structural lie that silently defeated null checking.
Both corrected, both omitting the key on null — the same posture as 'inherit',
where omission means "follow the host default".

MAJOR — the per-model table is inert today, and the docs now say so. All three
shipped models advertise the same usable range and ultra (sol's only
differentiator) is rejected for every model, so no observable output differs by
model. The table stays because Codex declares capability per model and the sets
are free to diverge — a single per-runtime assumption is precisely what went
stale and produced this issue — but overselling it as a visible per-model feature
would have been the same class of error as the doc blocker above.

Tests: three passed under a full revert and are strengthened rather than deleted,
since each guards a real contract (#3533's inherit rule, the undeclared-host
rule, off-ladder handling) — they now also assert the clamp-visibility fields,
which only exist after this change. The fast-check property is kept for its
shrinking, and a deterministic nested loop over the full cross-product now sits
beside it so coverage is exhaustive rather than sampled.

Also folded in earlier: bin/install.js generated the Codex TOML with the two-arg
form and would have written a literal null reasoning effort on the ultra path;
CONTEXT.md's Model Catalog Module glossary entry now records CODEX_MODEL_EFFORT.
The installer defect was found by the co-change gate, not by a reviewer —
install.js is a historical co-change partner of model-catalog.cts that this diff
had not touched.

* test(#3007): correct assertions that pinned Codex's stale effort premise

Thirteen pre-existing tests encoded "Codex has no max" as fact and failed on the
shipped commit. Every one is a stale pin, not a defect: each was probed against
the built module before its expectation was changed, and none failed for a
reason other than this premise correction.

Kept as its own commit per CONTRIBUTING — a test-fixture correction made stale
by a production change must not ride inside another commit, because the
release-sdk hotfix cherry-pick filter routes by subject prefix and a correction
buried under the wrong prefix ships a half-state (v1.42.3, #3621).

The most valuable one was tests/model-resolver.test.cjs's cross-provider
validity invariant, which hardcoded the Codex enum as
`minimal|low|medium|high|xhigh` and failed with "real API would 400". That
message is now false in both directions: Codex accepts `max`, and rejects
`minimal`, which no model advertises. The enum is corrected to
`low|medium|high|xhigh|max` and the guard is kept intact — it is exactly the
"would the real API refuse this" check worth having, and it was right to fail
here. It simply carried the stale fact in its own fixture.

Test NAMES were corrected alongside their assertions wherever the name asserted
the old behavior — "max is Anthropic-only", "max clamps to xhigh", "minimal
passthrough". A renamed test that still claims the old thing is worse than a
failing one, and a green test whose name states a falsehood is how the next
reader inherits the wrong premise.

Both channels are covered: install-time (renderEffortForRuntime, and the
generated .toml in install-runtime-artifacts) and invocation-time argv
(effort-surface-axis). They were deliberately brought into agreement in this
change, so their assertions had to move together.

Each site carries a #3007 comment recording that Codex gained max/ultra and that
capability is declared per model, so a future reader can tell this was a
deliberate premise correction rather than a test bent to fit an implementation.

* test(#3007): separate the effort-precedence case from the clamp case

The previous stale-assertion pass over-corrected one test. It saw
`effort: { default: 'max' }` on codex expecting `effort_rendered: 'xhigh'`,
assumed the xhigh came from the max→xhigh clamp #3007 removes, renamed it to
"max passes through" and changed the expectation to `max`. The remote runner
disagreed.

Reproduced against the real CLI: with that config and `gsd-planner`, the
resolver emits `effort: "xhigh"`, `effort_requested: "xhigh"`,
`effort_clamped: false`. The xhigh is produced by effort-resolution PRECEDENCE —
gsd-planner is heavy/opus tier and its routing-tier default outranks
`effort.default` — so `max` never reaches the renderer at all. The test says
nothing about clamping and never did; it only looked like a clamp pin because
both mechanisms happened to yield the same string.

Restored to `xhigh` and renamed to say what it actually tests. It now also
asserts `effort_clamped === false` and `effort_requested === 'xhigh'`, which is
what makes it impossible to mistake for a clamp pin again: those two fields prove
the value is what the resolver produced rather than something the renderer
downgraded. Before #3007 there was no way to tell the two apart from the output —
which is precisely why the previous pass could not tell them apart either.

Added the test that was actually missing: `effort.agent_overrides`, which
outranks the tier default, so the requested level genuinely reaches the renderer
and `max` survives to `effort_rendered` end-to-end through the real CLI. Verified
by probe before asserting.

One test now pins the precedence rule and the other pins the #3007 behavior, and
neither can be read as the other. That the clamp-visibility fields are what
resolved this is a small argument for having added them.

* chore(#3007): backfill changeset pr number to 3765

* test(#3007): put model-catalog under the mutation gate

The Stryker shard showed as `skipping` on this PR despite the diff rewriting
model-catalog's effort logic. That was legitimate, not a detection bug:
`model-catalog` was never in scripts/mutation-matrix.cjs's COVERED map, so the
whole module — including everything #3007 touches — sat entirely outside
mutation scoring with has_work "false".

Registered, with a dedicated spawn-free surface. tests/model-catalog.unit.test.cjs
is new: 44 in-process tests, no runGsdTools, no child process, no filesystem, no
temp dirs. That shape is not stylistic — it is the #2790 precedent this file
already documents. Stryker's command runner treats a whole `node --test <file>`
invocation as ONE test costing whatever its slowest case costs, and re-runs it
per mutant, so pointing a shard at tests/model-resolver.test.cjs (which uses
runGsdTools throughout) would reproduce exactly the 15-minute shard-cap
cancellation #2790 hit. The integration file is unaffected and keeps running in
full in the normal test job.

Coverage spans the module rather than only the diff, because the score is
measured over the whole file: effort rendering across every model and ladder
level in both channels, the prototype-chain host guard, the exported enums and
maps, isAnthropicFlavoredModel's provider namespacings, the profile projections,
nextTier, and mergeEffortTierDefaults. The last two were nearly left out and are
worth naming — every uncovered exported function is score given away, and
mergeEffortTierDefaults turned out to have a genuinely interesting contract
(#3531: a partial override merges over the built-ins rather than replacing them,
and isValid gates the VALUE, not the tier name, so an unknown tier key is still
merged in). Every expectation was probed against the built module before being
asserted.

minScore is 1 and that is a PLACEHOLDER, flagged as such in the registry comment.
Floors in this repo are measured, not chosen — the existing entries sit at 94, 75
and 56 — and they can only be measured in CI, because mutation shards run
`node --test`, which is hard-blocked locally. The first CI run on this branch
reports the real number and the floor gets ratcheted to it before merge. A
placeholder of 1 reaching `next` would make the gate decorative: it would pass
whether or not a single mutant is ever killed.

Note the target is "never regress from measured", not a fixed 80 — planning-inspect
sits at 56 and is documented as an accepted ratchet candidate.

* test(#3007): bootstrap model-catalog's mutation floor legally

The placeholder floor was structurally illegal and the remote run said so.
tests/mutation-matrix-ratchet.test.cjs guards the guard: every COVERED module
must carry a matching RATCHET_BASELINE entry in the same diff, minScore must
EQUAL that baseline, and it must be at least 50. `minScore: 1` failed all three.
That is the ratchet working exactly as intended — a floor nobody can satisfy
accidentally is the point of it.

Bootstrapped at 50 in both places. Fifty is not a measured score and the comment
says so plainly: it is the minimum the guard permits, and it coincides with
Stryker's own configured `break` threshold, so it is the lowest legal starting
point for a module that has never been measured. It still must be ratcheted to
floor(measured) - 1 before this PR merges.

Also corrected a real defect in the file's own instructions. "HOW TO UPDATE"
step 1 read "Run the per-module Stryker shard locally" — which cannot be done
here, and which the same file contradicts eighty lines further down, where the
#2790 scores are recorded as "not a local run; mutation shards run `node --test`,
hard-blocked in this repo's local environment". stryker.config.mjs confirms the
command runner invokes `node --test` once per mutant, and
.claude/hooks/block-local-node-test.sh denies exactly that. So the documented
first step sends the next contributor at a wall. Rewritten to describe the path
that works — push, read the measured score off the CI shard, then set the floor
and its baseline together in one diff — and to say why local measurement is not
available, so nobody rediscovers it the slow way. GOODHART SAFETY is untouched.

The two-step is inherent to the environment rather than a shortcut: a floor
cannot be measured before the first CI run exists, and the guard rightly refuses
to accept an unmeasured one below its minimum.

* test(#3007): ratchet model-catalog's mutation floor to its measured score

The shard ran in CI and reported 59.62% — 248 mutants killed, 168 survived, no
timeouts, no errors (run 32605073352, job 97108869486). Floor set to 58 per this
file's own rule, minScore = floor(measured) - 1, which is the same arithmetic
every sibling entry used: 57.03 to 56, 76.58 to 75, 95.65 to 94.

Both halves moved together, because the ratchet guard asserts minScore equals its
RATCHET_BASELINE entry and would reject them drifting apart.

The spawn-free unit surface is vindicated by the clock: 57 seconds, against a
15-minute shard cap and a 9m46s frontmatter shard in the same run. That was the
whole reason for creating tests/model-catalog.unit.test.cjs rather than pointing
the shard at tests/model-resolver.test.cjs — #2790 recorded shards being
CANCELLED at that cap when they targeted a runGsdTools-heavy integration file.

The registry comment is rewritten rather than deleted. It previously warned that
the floor was provisional and must not ship that way; leaving that text next to a
measured floor would make the file lie in the other direction. It now records the
measurement the way the sibling entries do, including that 59.62 sits below
TARGET (80) and is therefore a ratchet candidate like planning-inspect at 56 —
comfortably clear of its own floor with real room to grow. Raise it as the tests
improve; never lower it.

Worth stating plainly: 168 surviving mutants is not a clean bill of health. It is
an honest floor for a module that had NO mutation coverage at all an hour ago,
and it is now pinned so it cannot silently regress.

---------

Co-authored-by: sim <sim@local>
2026-08-22 20:51:55 -04:00

26 KiB
Raw Blame History

ADR 443: Unified cross-provider effort controls and fast-mode-aware routing

  • Status: Accepted (2026-08-19)
  • Date: 2026-05-28
  • Tracking issue: #443

Why this is still Proposed (audited 2026-07-17)

The audit confirmed the cross-provider resolver/renderer/CLI machinery genuinely shipped: resolveEffortInternal, resolveEffortForTier, renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE, and cmdResolveExecution (src/model-resolver.cts:534,654; src/commands.cts) implement the cascade and clamping exactly as Decision items 1–3, 5, and 6 describe, and static install-time propagation is real and end-to-end tested — tests/install-runtime-artifacts.test.cjs's describe('#443 Claude install: effort: injected into frontmatter') runs the actual install() function and reads the resulting agent .md files off disk, confirming gsd-planner gets effort: xhigh, gsd-codebase-mapper gets effort: low, and gsd-executor gets effort: high. That test predates the QA audit below (landed 2026-05-29 in the original #443 PR, commit 5ca646f01), so the "resolver-only, nothing reaches the runtime" framing of the original flavor-text problem this ADR set out to fix is fixed for the static path.

The blocker. Decision item 1's cascade names an "(1) orchestrator invocation override" as the highest-precedence layer, and Decision item 6 adds a dynamic escalation path ("effort steps up the ladder on a failed attempt"). Both exist only as CLI-callable resolver code — resolveEffortInternal's invocation-override step (src/model-resolver.cts:535) and resolveEffortForTier's attempt-based escalation (src/model-resolver.cts:654) — exercised solely by unit/CLI tests. Nothing in the shipped orchestration actually calls them: a search across every file in gsd-core/workflows/*.md and agents/*.md for resolve-execution or CLAUDE_CODE_EFFORT_LEVEL returns zero hits; the only workflow-level mentions of "effort" are documentation of the config keys in settings-advanced.md's confirmation table. The only propagation channel actually wired into a real GSD flow is the static one (config → install() → frontmatter, baked once at install time) — the ADR's own decided design promises more than that, and the more-than-static-baking part has no consumer. Separately, the repo's own dated QA test-architecture audit (docs/issueevidence/1192-adr-test-audit-2026-06-13.md, produced under issue #1192, closed COMPLETED) rated ADR-443 "partial ... end-to-end effort propagation untested" and named it in its action plan ("Strengthen ... ADR-443 end-to-end effort propagation," line 220); that action item was never converted into a tracked follow-up issue, and no commit since 2026-06-13 addresses it. That audit's blanket "untested" framing overstates the gap — the static path is tested — but the underlying signal (a decided mechanism with no live caller) is real and independently confirmed here.

Unblock condition. Either (a) wire the orchestrator-invocation-override and attempt-based-escalation paths into an actual GSD workflow or agent dispatch (so resolveEffortForTier's escalation and resolveEffortInternal's invocation-override step have a real caller outside src/commands.cts's CLI surface and tests), and add a test exercising that live path the way tests/install-runtime-artifacts.test.cjs exercises the static one; or (b) if the ADR's intended scope is in fact limited to static install-time propagation, amend Decision items 1 and 6 to say so explicitly and close out audit issue #1192's action-plan item 18 with a note pointing at the shipped install-wiring tests. Either is a maintainer call this file records but does not make.

Amendment (2026-07-21): path (a) chosen; audit corrected (#2481)

The maintainer call above has been made: path (a). Raised by #2475 — reviewer CLIs invoked as subprocesses by the review workflow silently inherit whatever reasoning effort sits in the user's own global CLI config, because no shipped orchestration resolves effort at invocation time. That is this ADR's blocker surfacing as a user-visible defect, not a new problem.

Two deferrals recorded above are closed by this change — resolved, not re-tracked:

  1. Audit issue #1192's action-plan item 18 ("Strengthen … ADR-443 end-to-end effort propagation") — which the blocker text notes "was never converted into a tracked follow-up issue" — is satisfied by this change, which supplies the end-to-end propagation and the live-path tests it asked for. It is closed out, not converted into another follow-up.
  2. The choice between (a) and (b) — which this file previously recorded without making — is resolved as (a). Scope is not limited to static install-time propagation. Choosing the path is not the same as completing it; see the status table below for what remains.

How path (a) is being satisfied. The consumer is defined through the Host-Integration Interface rather than by hard-coding per-CLI effort syntax into a workflow: ADR-1239 gains an effortSurface axis declaring how each host accepts reasoning effort (argv | none), so a universal effort value resolved by this ADR's cascade is rendered per host through the negotiated descriptor. EFFORT_RENDERING (src/model-catalog.cts) — whose channel vocabulary is frontmatter | api, both install-time — collapses into that descriptor data rather than growing a parallel per-runtime table. Its callers today are exactly the two channels this ADR already ships: the static install-time renderer (bin/install.js, via src/install-effort-resolver.cts) and the manual query resolve-execution / effort-sync CLI surface (src/commands.cts). No workflow or agent dispatch calls it — which is the blocker restated in terms of the renderer rather than the resolver.

What this change actually delivers — and what it does not. Path (a) names two mechanisms needing a live caller. This change delivers neither of them; it delivers a third thing the blocker did not anticipate, and the audit of the other two turns out to have been stale.

Path (a) mechanism Status
Decision item 1 — resolveEffortInternal's invocation-override step (--effort) Still no live caller. No workflow, reference, or agent passes --effort to resolve-execution. This change does not add one.
Decision item 6 — resolveEffortForTier's attempt-based escalation Already satisfied — by #2296, not by this change. gsd-core/references/execute-phase-quota-recovery.md calls resolve-execution gsd-executor --attempt "${QUOTA_ATTEMPT:-1}" --failure-class quota-exceeded, and that reference is @-included into gsd-core/workflows/execute-phase.md, so it executes as part of the live workflow.
New here: the resolved effort cascade reaches a spawned host as an invocation argument Delivered. gsd-core/workflows/review.md calls resolve-execution … --host <id> per reviewer and appends the rendered argument, gated by the host's negotiated effortSurface.

The blocker's grep was stale in two ways. It searched only gsd-core/workflows/*.md and agents/*.md; references/*.md is @-included into workflows and is therefore just as live — that is where #2296's escalation caller sits. And the blocker text was written 2026-07-17, three days before #2296 landed (455ad49ae, 2026-07-20), so its "zero hits" finding was correct on the day and has since been overtaken.

This ADR therefore remains Proposed. Decision item 6's condition is met (by #2296); Decision item 1's is not. The corpus rule for ratifying a stale Proposed requires the decided mechanism to demonstrably exist in the tree, and the invocation-override step still has no caller outside the CLI surface and tests. Status flips when item 1 gains a live caller and a test exercises that path through a workflow rather than through gsd-tools directly.

Boundary. #2313 owns the static/install-time effort channel for Codex (model_reasoning_effort in generated ~/.codex/agents/<agent>.toml, plus a sync path) and explicitly places orchestrator effort-override drift outside its scope. That is the static channel this ADR already ships; the work above is the invocation-time channel it does not.

Amendment (2026-08-19): Decision item 1 scoped to the operator surface; this ADR is ratified (#2475)

Recorded as a dated section rather than by editing the amendment above or Decision item 1 itself, since ADRs here are append-only.

The remaining maintainer call has been made: unblock path (b), applied to Decision item 1 only. The 2026-07-21 amendment above left this ADR Proposed on a single condition — that item 1's orchestrator invocation override (precedence step 1) gain a caller in shipped orchestration. It does not gain one. The scope is corrected instead.

Why the original (b) wording does not fit, and what replaces it. The unblock condition offered (b) as "if the ADR's intended scope is in fact limited to static install-time propagation, amend Decision items 1 and 6 to say so explicitly." That sentence is now false on both counts and must not be adopted verbatim: item 6 has a live orchestration caller (#2296, gsd-core/references/execute-phase-quota-recovery.md, @-included into execute-phase.md), and #2481 delivered a live invocation-time channel in which the resolved cascade reaches a spawned host as an argv argument, gated by ADR-1239's effortSurface. This ADR's scope is emphatically not limited to install-time propagation. What is narrowed is one precedence step, for a reason (b) did not anticipate.

What Decision item 1's invocation override is. An operator-facing surface, not an orchestration channel. gsd_run query resolve-execution <agent> --effort <level> exists so a human — or an agent diagnosing routing — can ask "what would this agent run at if effort were X?" without mutating project or home configuration. That is its whole job, and it does it today. Shipped orchestration deliberately does not pass --effort: workflows resolve effort through precedence steps 2–5 (effort.agent_overrides → effort.routing_tier_defaults → effort.default → built-in), which is the configured, reviewable, per-project path. An override baked into a workflow would be an unconfigurable constant overriding the user's own configuration at the top of the cascade — the inverse of what step 1 is for.

Why no consumer was invented to satisfy the gate. The unblock condition is a proxy for design maturity; wiring a caller purely to flip a status is the textbook case of a measure becoming a target, producing a number that looks better while the design gets worse. No user has asked for a per-invocation effort override, and #2475's actual complaint — reviewer CLIs silently inheriting a global effort default — is closed by the cascade→argv path, not by step 1. Adding an unrequested knob to ratify an ADR would also invert the very property ADR-1239's effortSurface amendment claims for itself ("a wired axis, not a declared-but-unconsumed one"): a consumed-but-unrequested one is no better.

--effort is supported and is NOT deprecated. Scoping it out of orchestration says nothing about its standing as a CLI flag. It ships, it is documented, it is covered by tests, and callers may rely on it. This paragraph exists so that a future reader — or a dead-code sweep — does not read "no orchestration caller" as "unused, remove it".

What is deliberately not built. There is no per-run effort override for review lanes ("make this review cheap"). A user wanting that edits effort.*. Should a real need appear, it is a new enhancement to be judged on its merits — not a deferred obligation of this ADR, and not a request that was refused.

Status: Proposed → Accepted. The corpus rule for ratifying a stale Proposed requires the decided mechanism to demonstrably exist in the tree. With item 1's decided scope narrowed to the operator surface, every Decision item now clears that bar: items 1–3 and 5 in the resolver, renderer, and resolve-execution output; item 4 in the static install-time propagation the 2026-07-17 audit confirmed end-to-end; item 6 in #2296's escalation caller; and the cascade's delivery to a spawned host in #2481's review-lane wiring.

The invariant this ratification rests on, and its guard. Ratification is conditional on item 1 staying operator-only. tests/effort-surface-axis.test.cjs walks gsd-core/workflows, gsd-core/references, agents, and commands and fails if any of them invokes resolve-execution … --effort in either accepted shape (--effort <level> or --effort=<level>). That guard was widened in this change: it previously matched only the space-separated form, so --effort=low would have passed it silently. Wiring an orchestration caller therefore requires amending this ADR first — the guard is the enforcement of this decision, not a snapshot of a gap awaiting closure.

Boundary — unchanged. ADR-2313 still owns the static/install-time channel; ADR-1239's effortSurface amendment still owns the invocation-time argv channel. This amendment changes neither, and changes no runtime behavior at all.

Amendment (#3007, 2026-08-22) — the Codex capability premise went stale

What changed underneath this ADR. The Context section below records, as fact, that Codex's ladder is minimal, low, medium, high, xhigh and that it "has minimal; no max", and Decision item 2 clamps max → xhigh on that basis. That was accurate when written. It is no longer: Codex's ReasoningEffort now accepts none, minimal, low, medium, high, xhigh, max, ultra, and capability is declared per model via supported_reasoning_levels, which Codex validates against (validate_spawn_agent_reasoning_effort) and exposes through model/list.

Verified against Codex's own codex-rs/models-manager/models.json:

Model supported_reasoning_levels default_reasoning_level
gpt-5.6-sol low, medium, high, xhigh, max, ultra low
gpt-5.6-luna low, medium, high, xhigh, max medium

No Codex model advertises minimal — the level this ADR called Codex-only.

What this amendment changes. Decision item 2's per-runtime clamp table is superseded for Codex by a per-model advertised set, with the family baseline low, medium, high, xhigh, max for any model GSD does not know:

Universal level Claude rendering Codex rendering (was → now)
minimal low (clamped) minimal → low (clamped)
low–xhigh unchanged unchanged
max max xhigh (clamped) → max (passes)
ultra not on the ladder rejected, never clamped

Two defects this corrects, both live on next before it:

  1. max was silently discarded for every Codex model, all of which advertise it.
  2. providerPresets.openai.haiku.low paired gpt-5.6-luna with reasoning_effort: "minimal" — a level luna does not advertise, written into a document Codex itself validates.

Clamping becomes visible rather than silent. RenderedEffort gains requested / clamped / reason, and resolve-execution surfaces them as the flat result keys effort_requested, effort_clamped, and effort_clamp_reason — siblings of the existing effort_rendered, not a nested effort object. The old table clamped correctly-but-invisibly, so a user asking for max on Codex had no way to learn they were getting xhigh — the failure mode Postel's robustness critique warns about, and the reason "be liberal" here has to mean "liberal and loud".

No model divergence is observable today. All three shipped Codex models advertise the same usable set (low…max), and ultra — sol's only differentiator — is rejected for every model regardless. So today, the same requested level renders identically across gpt-5.6-sol, gpt-5.6-terra, and gpt-5.6-luna; the per-model table exists because Codex declares capability per model and the sets are free to diverge, not because a user can currently observe a difference.

ultra is refused, not laddered. Codex's catalog describes it as "Maximum reasoning with automatic task delegation", and at ultra Codex enters proactive multi-agent mode (effective_multi_agent_mode → Proactive), spawning sub-agents on its own initiative underneath GSD's orchestration (#2167). It is a mode switch, not a reasoning depth, so it is not added to the universal ladder — which stays provider-agnostic per Decision item 2 — and it is rejected for every model including gpt-5.6-sol, which advertises it. GSD is deliberately stricter than Codex here: Codex applies proactive mode only to V2 sessions and never to spawned sub-agents, but GSD writes effort at install time and cannot know the session source of a future invocation.

What this amendment does NOT change. The universal ladder itself, the cascade, the resolver precedence, the two channel boundaries above, and Claude's rendering are all untouched. The per-model table is static and will go stale exactly as this premise did; runtime discovery via model/list is the known escape hatch and was deliberately deferred as the larger step.

Context

Effort control and fast mode in Claude Opus 4.8

Claude Opus 4.8 introduced two orthogonal execution controls relevant to GSD's agent orchestration:

  1. Effort control — API request field output_config.effort (string enum). Anthropic levels: low, medium, high, xhigh, max; Opus 4.8 defaults to high. In Claude Code it is exposed as /effort, the --effort CLI flag, the CLAUDE_CODE_EFFORT_LEVEL env var, the effortLevel settings.json key (accepts low/medium/high/xhigh; max is session-only), and — critically for orchestration — a per-subagent effort frontmatter key (shipped per anthropics/claude-code issue #31536, CLOSED/COMPLETED).

  2. Fast mode — API request field speed (standard|fast); fast enables high output-tokens-per-second inference. Pricing for Opus 4.8 fast mode is $10/$50 per MTok in/out vs $5/$25 standard. In Claude Code it is the interactive /fast toggle ONLY — there is no settings.json key, env var, or subagent-frontmatter mechanism to enable fast mode for a spawned subagent.

GSD already routes WHICH model runs a task (routingTier heavy/standard/light, model_profile quality/balanced/budget/adaptive/inherit, model_overrides, and dynamic_routing escalation). It had no way to control HOW HARD the model reasons or WHICH speed tier it uses.

The "flavor text" problem: issue #2517

Issue #2517 added resolveReasoningEffortInternal and made query resolve-model emit a reasoning_effort field derived from the Codex runtime's per-tier catalog values (model-catalog.json runtimeTierDefaults.codex.*.reasoning_effort). However, a codebase audit found that NO orchestrator, workflow, or agent ever consumes that emitted field — it is never passed to an actual Codex invocation. The resolver computed a value and a test asserted the computed JSON, but the value reached no runtime. The feature was inert ("flavor text, no code"): asserting a resolver's return value is not the same as asserting the control reaches the model.

Cross-provider effort enum mismatch

The two providers' effort enums are NOT identical:

  • Anthropic/Claude (Opus 4.8): low, medium, high, xhigh, max (has max; no minimal)
  • OpenAI/Codex (model_reasoning_effort / Responses API reasoning.effort; SDK ReasoningEffort ranks none=0, minimal=1, low=2, medium=3, high=4, xhigh=5): minimal, low, medium, high, xhigh (has minimal; no max)

Common core: low, medium, high, xhigh.

Decision

  1. Introduce a single universal effort config knob (and an orthogonal fast_mode knob) that compose with model selection rather than replace it. Resolution precedence mirrors the existing model cascade: (1) orchestrator invocation override, (2) effort.agent_overrides[agent], (3) effort.routing_tier_defaults[routingTier], (4) effort.default, (5) built-in default high. Same cascade for fast_mode with built-in default false. Invalid enum values at any level are ignored and fall through (mirrors the VALID_TIERS gate in resolveModelInternal) so a typo never silently breaks resolution.

  2. The universal effort value is provider-agnostic; a per-runtime renderer maps it to each runtime's wire parameter, clamping the genuinely-unique tail levels:

    • Claude / API: param output_config.effort (Claude Code: subagent effort frontmatter / CLAUDE_CODE_EFFORT_LEVEL env). minimal clamps to low (Claude has no minimal); low/medium/high/xhigh/max pass through.
    • Codex: param model_reasoning_effort (Responses API reasoning.effort). max clamps to xhigh (Codex has no max); minimal/low/medium/high/xhigh pass through.
    Universal level Claude rendering Codex rendering
    minimal low (clamped) minimal
    low low low
    medium medium medium
    high (default) high high
    xhigh xhigh xhigh
    max max xhigh (clamped)
  3. Fold the inert reasoning_effort output into this unified model. query resolve-model is preserved for back-compat; a NEW query resolve-execution is the superset that emits: model, effort (universal), the per-runtime rendered effort, the wire param name, the propagation channel, fast_mode, and fast_mode_supported. Each config key ships help text naming exactly which runtime field/invocation it drives.

  4. Make effort actually reach the runtime (close the flavor-text gap). Claude is first-class: the resolved effort propagates to spawned subagents via the effort frontmatter / CLAUDE_CODE_EFFORT_LEVEL env. Tests assert end-to-end propagation, not just resolver return values.

  5. Fast mode honesty: because Claude Code has no per-subagent fast-mode mechanism, fast_mode is resolved and surfaced (with a fast_mode_supported flag, false for the claude runtime's subagents) but is NEVER emitted as a fake frontmatter key — doing so would be a silent no-op. It propagates only where the runtime supports it (API speed:"fast").

  6. Dynamic-routing integration is additive: a new effort-escalation path (effort steps up the ladder on a failed attempt BEFORE model-tier escalation) is gated on the same dynamic_routing.enabled / escalate_on_failure switches and does NOT modify resolveModelForTier (so existing feat-3024 behavior is unchanged).

Consequences

Positive

  • One coherent effort policy across all runtimes; Claude effort is first-class and actually wired.
  • The dead reasoning_effort field becomes meaningful; finer-grained cost/quality control (a light-tier scanning agent can run low effort; a heavy planning agent xhigh) without changing model class.
  • Effort-first escalation reduces unnecessary model upgrades.
  • Cross-provider clamping is explicit and documented.

Negative

  • The universal enum is the union of two providers' ladders, so two levels (max, minimal) are runtime-specific and clamp when rendered to the other provider — users must understand the mapping (mitigated by help text and the table above).
  • Fast mode remains asymmetric: it cannot be forced per-subagent on Claude Code, only at session level or on API-direct runtimes.
  • Updating issue-2517's tests to assert real wiring is a deliberate behavior/contract change (the old "null on claude" assertion encoded the now-false premise that Claude has no effort control).

Alternatives Considered

(a) Global effort env override (e.g. a single CLAUDE_CODE_EFFORT_LEVEL for the whole session) — rejected: caps cost but starves heavy agents that legitimately need deep reasoning; static global breaks the per-tier design.

(b) Model selection alone (status quo) — rejected: choosing Haiku for light tasks reduces cost, but within one model class there is no way to tune reasoning depth; a quality profile pays full reasoning cost even for scanning.

(c) Static per-agent effort only — rejected: loses context sensitivity; the same agent doing trivial vs complex work should not always get the same effort.

(d) A separate effort field kept fully parallel to Codex's existing reasoning_effort (two independent lanes) — rejected: produces two overlapping fields that can diverge and confuse; Codex's reasoning_effort is better modeled as one rendering of the single universal effort.

(e) Overloading the existing reasoning_effort field to also carry Claude effort — rejected: it would conflate a Codex-specific wire name with the universal concept and break the clean per-runtime rendering.

References

  • Tracking issue: #443
  • Prior art (inert reasoning_effort): #2517; tests/model-resolver.test.cjs (folds former issue-2517-runtime-aware-profiles)
  • dynamic_routing escalation: #3024; tests/model-profiles.test.cjs (folds former feat-3024-dynamic-routing, consolidation epic #1969)
  • phase-type tiers: #3023
  • Anthropic effort API: output_config.effort (low/medium/high/xhigh/max); fast mode: speed (standard/fast)
  • Claude Code effort: /effort, --effort, CLAUDE_CODE_EFFORT_LEVEL, effortLevel setting, subagent effort frontmatter (anthropics/claude-code #31536, completed); fast mode: /fast (interactive only)
  • OpenAI Codex effort: model_reasoning_effort config key; Responses API reasoning.effort; ReasoningEffort enum none<minimal<low<medium<high<xhigh