v3.4.1 - what the config says is what routing does #177

Merged
john merged 14 commits from cadence/v3.4.1 into main 2026-08-15 19:30:21 +00:00
Owner

Closes #129, #134, #135.

Three surfaces described the review gates and nothing had ever compared them. config.mjs get answered a gate from the schema default when no layer set one, config.schema.json's prose named a level's gate, and route-table.json is what actually fires. phase_diff was the case already on the floor: the schema called it advisory at shipped, the route table fires off.

GAT-03 - all four review.triggers.*.gate defaults move to the null sentinel, and each purpose string names the gate for solo, shipped and critical read off route-table.json. risk_surface moved with them despite agreeing at every level today: a scalar default legal only while every cell equals it goes quiet the first time a cell moves. The review grid did not move - it is the authority.

GAT-02 - config.mjs get of a gate no layer pinned answers null plus one warnings[] entry naming route.mjs resolve, so a reader can tell "no layer set this" from "this project pinned it". A pinned gate still reads back byte-identical, a keyless get carries no gate warning, and check review.triggers.diff.gate=null still refuses with must be one of: off, advisory, blocking, adjudicated.

ENF-02 - self-verify.mjs check 18, gate-agreement: compares every trigger's schema default AND its prose against the route grid, over six codes, as a pure lib at cadence-core/bin/lib/gate-agreement.mjs unit-tested from frozen fixtures. Watched to FAIL against the unpatched tree before the fix landed, reporting plan, diff and phase_diff and naming phase_diff with shipped.

The workaround came out with the defect: workflows/execute.md and workflows/plan.md no longer carry the paragraph explaining why a gate must not be pre-fetched through config.mjs get, and references/config-catalog.md's gate row stops publishing a per-key scalar default routing never fires.

Verification

  • node cadence-core/bin/self-verify.mjs exits 0, problems: [], checked names gate-agreement
  • node --test cadence-core/bin/*.test.mjs - 1870 pass, 0 fail, no test file removed, no count-pin loosened
  • npx tsc -p tsconfig.ci.json exit 0
  • UAT 8/8, all auto-verified with cited evidence

Known gap

gate-agreement compares the default against the cells, not against the null sentinel, so a trigger whose three cells agree could regress its default and stay green. risk_surface is that case today. Filed in CAPTURE.md.

Closes #129, #134, #135. Three surfaces described the review gates and nothing had ever compared them. `config.mjs get` answered a gate from the schema default when no layer set one, `config.schema.json`'s prose named a level's gate, and `route-table.json` is what actually fires. `phase_diff` was the case already on the floor: the schema called it `advisory` at `shipped`, the route table fires `off`. **GAT-03** - all four `review.triggers.*.gate` defaults move to the `null` sentinel, and each `purpose` string names the gate for `solo`, `shipped` and `critical` read off `route-table.json`. `risk_surface` moved with them despite agreeing at every level today: a scalar default legal only while every cell equals it goes quiet the first time a cell moves. The `review` grid did not move - it is the authority. **GAT-02** - `config.mjs get` of a gate no layer pinned answers `null` plus one `warnings[]` entry naming `route.mjs resolve`, so a reader can tell "no layer set this" from "this project pinned it". A pinned gate still reads back byte-identical, a keyless `get` carries no gate warning, and `check review.triggers.diff.gate=null` still refuses with `must be one of: off, advisory, blocking, adjudicated`. **ENF-02** - `self-verify.mjs` check 18, `gate-agreement`: compares every trigger's schema default AND its prose against the route grid, over six codes, as a pure lib at `cadence-core/bin/lib/gate-agreement.mjs` unit-tested from frozen fixtures. Watched to FAIL against the unpatched tree before the fix landed, reporting `plan`, `diff` and `phase_diff` and naming `phase_diff` with `shipped`. The workaround came out with the defect: `workflows/execute.md` and `workflows/plan.md` no longer carry the paragraph explaining why a gate must not be pre-fetched through `config.mjs get`, and `references/config-catalog.md`'s gate row stops publishing a per-key scalar default routing never fires. ### Verification - `node cadence-core/bin/self-verify.mjs` exits 0, `problems: []`, `checked` names `gate-agreement` - `node --test cadence-core/bin/*.test.mjs` - 1870 pass, 0 fail, no test file removed, no count-pin loosened - `npx tsc -p tsconfig.ci.json` exit 0 - UAT 8/8, all auto-verified with cited evidence ### Known gap `gate-agreement` compares the default against the cells, not against the `null` sentinel, so a trigger whose three cells agree could regress its default and stay green. `risk_surface` is that case today. Filed in `CAPTURE.md`.
john added 14 commits 2026-08-15 19:29:09 +00:00
`review.triggers.<t>.gate` is described by three surfaces - the schema's
`default`, the schema's `purpose`, and route-table.json's `review` grid - and
nothing made them agree. lib/gate-agreement.mjs is the one statement of that
agreement, taking the parsed schema keys, the parsed table and the caller's
level/gate vocabularies, and returning `{code, detail}` entries.

Two distinguishable halves, both mandatory. `gate-default-drift` fires when a
scalar default names a gate the grid does not fire at some level;
`gate-prose-missing` / `gate-prose-drift` fire when the purpose carries no
`<gate> at <level>` clause for solo, shipped and critical or names the wrong
gate at one. The prose half is never opt-in: reading it only where a clause
already exists lets a maintainer silence it by deleting one sentence, the hole
check 14 closes for CONTRACTS rows.

`null` is exempt specifically - it is the "no scalar claim, the level decides"
sentinel - while any other non-gate default is `gate-default-invalid`, since
nothing in the tree checks a schema default against its own `values` enum and
`config.mjs get` answers whatever is written there. An unusable grid, a
non-object row and a non-string purpose report under a code rather than
throwing. The trigger list is derived from the schema's own key names, so a
fifth trigger is walked the day its key lands.

The test holds the four schema rows and the `review` grid exactly as they ship
today as frozen literals rather than disk reads, so the pre-patch failing
evidence stays re-runnable after the schema moves onto the grid.

Not wired into self-verify.mjs yet: the repo stays green at this commit.
Wire lib/gate-agreement.mjs into self-verify as check 18, in the
route-table.json arm where both files are already parsed, and reconcile
config.schema.json's four `review.triggers.*.gate` rows to it in the same
commit. Every issue is filed against config.schema.json: route-table.json's
`review` grid is the authority and does not move.

All four defaults become the `null` sentinel - `risk_surface` included, even
though its `blocking` default agrees at every level today, because a scalar
default that is legal only while every cell equals it passes now and goes
quiet the first time a cell moves. The `values` arrays stay four-membered, so
`config.mjs check review.triggers.diff.gate=null` still refuses with
`must be one of: off, advisory, blocking, adjudicated`.

Each `purpose` now carries the mandatory `<gate> at <level>` clause for solo,
shipped and critical, read out of the grid at edit time. The twelve cells
audited, moved or not:

  plan          advisory at solo, blocking at shipped, adjudicated at critical
  diff          off at solo, off at shipped, blocking at critical
  risk_surface  blocking at solo, blocking at shipped, blocking at critical
  phase_diff    off at solo, off at shipped, adjudicated at critical

Only phase_diff's prose was wrong where it spoke: it claimed `advisory at
shipped`, which v3.2.0 (81bdb5d) had moved to `off`. plan, diff and
risk_surface named no level at all. Three defaults drifted - plan
`adjudicated`, diff `advisory`, phase_diff `advisory` - each a gate
`config.mjs get` answered for an unset key that routing fires at no level.
phase_diff's "writing any value pins it at every level and warns" stays
verbatim: it is true of shipped code at route.mjs:435-468.

The CLI test runs on a synthetic root that writes its own schema and its own
route table, with a grid deliberately unlike the shipped one, so no
expectation there can be met by the live files.

Plan tasks 2 and 3 land as one commit by user decision at a structural
checkpoint: self-verify.test.mjs:210-214 already asserts the live tree has
`problems: []`, so a deliberately-red task-2 commit could not also leave
`node --test` green. The failing run against the unpatched tree is recorded
verbatim in the phase report.
The value half was already done by the schema sentinel: an unset
`review.triggers.<t>.gate` reads back `null` and a pinned one reads back
byte-identical, on the same unchanged `layered[k] !== undefined ? layered[k] :
SCHEMA[k].default` line. What was missing is the reporting half - `null` alone
cannot tell a reader "no layer set one, the level decides" apart from a layer
that wrote null.

`get` now folds one entry per offending key into the `warnings[]` it already
composes beside `retiredKeysIn` and `scopeWarnings`, naming the key and
pointing at `route.mjs resolve` as where a level's gate is answered. It fires
only when all three hold: the caller named keys explicitly, the key is a gate,
and no layer set it. A keyless full read walks ~72 keys and stays clean, so
the prose workflows/milestone.md and verify.md relay to the user does not grow
four lines on every read.

The warning never states what the level fires and never reads
route-table.json: this seam does not know the stakes level, and answering as
if it did is the same defect pointed the other way.

`config.mjs:258`'s `wanted.filter((k) => !SCHEMA[k])` and the `SCHEMA[k].default`
read beside it are untouched - the prototype-getter hazard CAPTURE.md records
on both faces is out of this phase's scope.
`workflows/execute.md:35-39` and `workflows/plan.md:58-62` carried the same
warning: do not pre-fetch a gate through `config.mjs get`, BECAUSE that seam
would answer the schema default. The instruction stays - fire(trigger) still
takes every gate from the routing bundle, which is what makes the stakes level
reach a fire site rather than only the seam - and the reason goes, because it
is no longer true. An unset gate now answers `null` and names
`route.mjs resolve`, so the seam is not a source for a level's gate either way.

Both files sit at exactly their weight-budgets.json row and the budget check
fails on shrink as well as growth, so both rows are re-measured and re-pinned
here: execute.md 25289 -> 25287, plan.md 22041 -> 22067. No other row moves.

`.planning/DOCS-CLAIMS.md` rows EXECUTE-07 and PLAN-09 quoted the retired
sentence at `accurate`; both move to the stale-then-corrected form CONFIG-02
models, citing this phase.

The AC5 sweep is repo-wide, not the two edited files.
`grep -rniE "schema default|schema DEFAULT" cadence-core docs README.md skills`
returns neither execute.md nor plan.md, and every remaining hit is a legitimate
use of the phrase:

  route-table.json:9              states the RULE that route.mjs must never
                                  turn a schema default into a user assertion
  bin/route.mjs:47                the same rule, at the resolver (D-04)
  bin/lib/gate-agreement.mjs:209  the new check's own detail, naming why a
                                  non-gate default is a problem
  bin/config.mjs:236, :242        the new null-means-unset copy, and the
                                  pre-existing description of the layering
  references/conventions.md:73    the layering rule generally; D-11 rules it
                                  is not a criterion-4 hit
  workflows/context.md:93         `memory.backend` really does default to
                                  `builtin`
  bin/planning.mjs:1898           the same `memory.backend` default
  prose-agreement.test.mjs:107, :181, route.test.mjs:564,
  config-seams.test.mjs:340, :434, :789, global-only-keys.test.mjs:119,
  config.test.mjs:252, :281, :290, :294, :366, :758, :1010, :1074
                                  test prose about non-gate keys resolving at
                                  their defaults, plus the new block's own
                                  description of the defect it closes

No hit anywhere claims that a `get` of a GATE returns the schema default.
`references/config-catalog.md`'s `review.triggers.<t>.gate` row published
"`adjudicated` for plan · `advisory` for diff/phase_diff · `blocking` for
risk_surface" in its Default column - three defaults the router resolves at no
stakes level. The catalog is deliberately transcribed by hand rather than
derived (workflows/config.md:65-72), so no schema edit reaches it, and it is
the surface a user reads during `/cad-config`.

The cell now says the key is unset by default and the stakes level decides,
naming `route.mjs resolve` as where the per-level answer comes from. The twelve
per-level values are deliberately NOT transcribed into it: that would create a
fourth hand-kept copy for the next cycle to reconcile. The row's per-value
Explanation copy is untouched, since the `/cad-config` walk requires each
option to carry it, and the `.tier` and `.effort` rows below it are accurate
against the `tiers` grid and do not move.

The file sat at exactly its weight-budgets.json row and the budget check fails
on shrink as well as growth, so that one row is re-measured and re-pinned:
8824 -> 8815. No other row moves.

`.planning/DOCS-CLAIMS.md` row CONFIG-CATALOG-08 quoted the retired cell at
`accurate` and moves to the stale-then-corrected form, closing the standing
CAPTURE.md phase-5 item that named this exact row.
chore: enable git.create_tag - tagging happens only at land
All checks were successful
test / node-test (git, 22) (pull_request) Successful in 24s
test / node-test (git, 24) (pull_request) Successful in 21s
test / node-test (other, 22) (pull_request) Successful in 21s
test / node-test (other, 24) (pull_request) Successful in 20s
test / node-test (planning, 22) (pull_request) Successful in 1m9s
test / node-test (planning, 24) (pull_request) Successful in 1m11s
test / node-test (prose, 22) (pull_request) Successful in 27s
test / node-test (prose, 24) (pull_request) Successful in 26s
test / node-test (review, 22) (pull_request) Successful in 18s
test / node-test (review, 24) (pull_request) Successful in 15s
test / node-test (routing, 22) (pull_request) Successful in 26s
test / node-test (routing, 24) (pull_request) Successful in 29s
test / self-verify (pull_request) Successful in 14s
test / typecheck (pull_request) Successful in 16s
eb2dc93b3b
john merged commit bb8cd5a4e8 into main 2026-08-15 19:30:21 +00:00
Sign in to join this conversation.
No description provided.