v3.2.0 - the controls that reported success #124

Merged
john merged 106 commits from cadence/v3.2.0 into main 2026-08-14 17:01:34 +00:00
Owner

Twelve requirements across four phases, 105 commits. The theme is one line: a control that reports success without having checked is worse than no control, because the product advertises the check.

A repo config layer could reparent the merged config through __proto__ and reach every enforcement surface, while config.mjs validate returned {"ok":true,"checked":0,"errors":[]} over the same file. atomicWrite wrote through a symlinked temp path. The unattended-close gate could not tell "no findings" from "could not read the findings" and returned proceed for both. Git failure details carried credentials out of a remote URL. Each now fails safe and names the failure it saw.

The review arm proves itself in the same cycle: adjudication records kills as well as survivors, the reviewer set is resolved in the seam rather than by prose that could be skipped without a trace, and an external reviewer gets the same stance and severity bar as the local one. Three triggers that blocked nothing are off or deleted, risk_surface learned the eight categories a project actually has, and every dispatch is bounded at 200 turns instead of a nominal 400.

Audit green: 12/12 traced, 0 broken, 36/36 acceptance criteria covered. Manifest at 3.2.0. LND-01 was cut before execution and stays open as #121.

Twelve requirements across four phases, 105 commits. The theme is one line: a control that reports success without having checked is worse than no control, because the product advertises the check. A repo config layer could reparent the merged config through `__proto__` and reach every enforcement surface, while `config.mjs validate` returned `{"ok":true,"checked":0,"errors":[]}` over the same file. `atomicWrite` wrote through a symlinked temp path. The unattended-close gate could not tell "no findings" from "could not read the findings" and returned `proceed` for both. Git failure details carried credentials out of a remote URL. Each now fails safe and names the failure it saw. The review arm proves itself in the same cycle: adjudication records kills as well as survivors, the reviewer set is resolved in the seam rather than by prose that could be skipped without a trace, and an external reviewer gets the same stance and severity bar as the local one. Three triggers that blocked nothing are off or deleted, `risk_surface` learned the eight categories a project actually has, and every dispatch is bounded at 200 turns instead of a nominal 400. Audit green: 12/12 traced, 0 broken, 36/36 acceptance criteria covered. Manifest at 3.2.0. LND-01 was cut before execution and stays open as #121.
john added 105 commits 2026-08-14 16:55:12 +00:00
Nine requirements across four phases, scoped from a full-codebase security
and quality audit rather than from the capture queue. Three parallel deep
scans over ~34k lines, with 1420 tests green and self-verify clean across all
20 checks; every finding was reproduced against the real code before it was
written down.

One theme: a control that reports success without having checked is worse
than no control, because the product advertises the check. config.mjs
validate returns ok:true on a config that is disabling the git rails.
milestone-prune records a deferred requirement as delivered, inside the
command whose job is auditing that nothing was dropped. The unattended-close
gate reports no surviving finding about input it never read. atomicWrite
follows a symlink the read path already refuses.

Phase 1 takes CFG-01 and CFG-02 together because they are the same fact from
two angles - a tracked config.json is attacker-controlled input - and they
share one hostile-repo fixture. Phase 3 orders the zero-cost trace change
first, since it is what makes the rest of the review work measurable rather
than asserted. Phase 4 carries the only new surface in the cycle.

Tracked as #114-#122.
deepMerge accumulated with merged[k] = ..., which runs Object.prototype's
__proto__ setter for a key JSON.parse makes an ordinary own property. A
.planning/config.json arriving with a clone could therefore hand the merged
config a prototype of its choosing, and every config.git?.on_protected read in
the spine inherited it. The accumulation now defines an own property, so the
key lands inert and the result keeps its ordinary Object.prototype.

Both live shapes close: top-level {"__proto__":{"git":...}} when no global
layer defines git, and {"git":{"__proto__":...}} one level down when one
does. constructor and prototype ride along as regression pins - they pass
against the unfixed merge and distinguish nothing on their own.
flatten skipped every _-prefixed key, a rule written for the _meta annotation
block that also swallowed __proto__ - so validate answered
{"ok":true,"checked":0,"errors":[]} on the same hostile file git-guard was
reading as settings. The skip is narrowed to exactly _meta rather than deleted;
deleting it turns validate red on every hand-annotated config.

Two smaller holes on the same path close with it. flatten's accumulator defines
own properties, so a top-level SCALAR leaf named __proto__ is reported instead
of silently reparenting the accumulator, and validate's schema lookup is guarded
by hasOwn, so that leaf is answered "unknown key" rather than Object.prototype
standing in as a spec with no type.
hostileLayers() builds a git repo on main whose repo layer arrived with a clone,
parameterized by the polluting spelling and by whether the global layer defines
git - the two axes the two live forms fire under, and the fixture the
global-only-key arms reuse. Built under mkdtemp like every other fixture here,
never checked in: a hostile config.json under bin/fixtures/ becomes input to
this repository's own tooling runs.

Watched to fail: with the deepMerge accumulation reverted, the guard goes SILENT
on a protected branch and the arm is red; the constructor/prototype pins stay
green either way, which is the whole reason they are pins.
`workflow.test_command`, `workflow.lint_command` and `review.key_file`
choose what Cadence runs and where a provider key is read from, so a
`.planning/config.json` that arrived with a clone must not be able to set
them. lib/global-only-keys.mjs states the set once and strips it out of
the object mergeLayers consumes; `layers.repo` still carries what the
file said.

The strip is value-agnostic and the warning is not. deepMerge returns the
higher layer's value for a null, so a repo `null` left in place would
OVERRIDE the user-global value - and templates/config.json ships all
three at null into every scaffolded repo. Warning on presence would then
fire on three untouched keys at a new project's first command.

The diagnostic rides a `scopeWarnings` field of its own, never
mergeLayers's `warnings[]`: git-publish's tornLayerDetail refuses to
mutate on ANY non-empty warnings array, so a cleanly-ignored key would
otherwise stop a land. `config.mjs get` folds it in the way retiredKeysIn
already is.
`src` was enum provenance with two documented values, one of which
([proposed]) had zero instances across 72 keys and so recorded nothing.
It now says which config LAYER a key may be set from, and the three
global-only keys carry `"src": "global"`. The 34 existing `"repo"` values
stay as they are - they are true under the scope reading.

Every surface that renders the field moves in the same change, so a
reader of the old legend cannot reach a `src` value under the old
meaning: the schema's own `_meta.note`, the `[repo]`/`[proposed]`
sentence of config-catalog.md's type key, and the three rows there.

config-reach.md's three rows drop `universal` for `user-global config
layer only`, which reachIssues compares verbatim against each key's
`purpose` - so the purposes carry the phrase plus the clause a user acts
on. Both references are re-pinned in weight-budgets.json at their
measured size, in this commit rather than after a red CI run.
The global-only set is hand-maintained rather than read off
config.schema.json at runtime, because a runtime read makes
CADENCE_CONFIG_SCHEMA a one-variable switch that un-marks every protected
key. What keeps a hand-maintained set honest is a cross-check, so
self-verify gains check 17 on the same both-directions shape check 8 uses
between the routing cells and agents/.

A key the merge strips whose spec carries no `src: "global"` is a scope
enforced and invisible - neither the schema nor the catalog derived from
it shows a user why their value was ignored. A marked key the merge does
not strip is the mirror: every rendered surface says it cannot be set
from a repo layer, and it still is. Each direction is filed against the
file a maintainer opens to fix it.

A key with NO `src` is repo-settable and is never reported, so this stays
one edit rather than an explicit `"src": "repo"` across the 38 unmarked
keys. Both failure modes watched to fail against the shipped tree first.
The hostile-layer helper now builds the CFG-02 half of the same file: a
`.planning/config.json` that reaches for the merged config's prototype
also reaches for what Cadence runs, so one fixture carries both and
reverting either fix alone turns an arm red (AC7).

All THREE keys are asserted, with a contrasting value in each layer. The
pure-lib rows prove the set; they do not prove the merge strips every
member of it, so a strip that handled `workflow.test_command` and dropped
the other two would pass a one-key arm.

The AC4 arm is D-05's regression pin: publish reaches `published` and
reap reaches `reaped` on a repo layer that sets a command key. Moving the
diagnostic onto mergeLayers's warnings[] turns it red with
`config-parse-failed` - watched, then restored. The reap deletes a real
branch rather than taking the already-absent skip, which returns before
the torn-layer gate and would have pinned nothing.
`cmdDetectCommands` matched `tsconfig.json` and that name only, and its
comment named THIS repository as the case it declined - which is exactly
the repository whose `.planning/config.json` can no longer supply a lint
command from a repo layer. Unconfigured now means detected here too:
`tsconfig.ci.json` answers `npx tsc -p tsconfig.ci.json`.

Two EXACT names, still no `tsconfig*.json` glob, and both commands are
fixed literals - no matched file name is ever interpolated into a
command. Order is why there are two arms: a tree carrying both keeps
`npx tsc --noEmit` off `tsconfig.json`, because that is the project's own
typecheck and the CI file is the narrower one.

The shipped arm that asserted `tsconfig.ci.json` yields null pinned the
behaviour this changes, so it now pins the pair; the both-present and
near-miss (`tsconfig.build.json`) arms keep "two exact names" a rule
rather than a description of today's tree.
The strip read the key and nothing above it, so a repo layer holding
`{"workflow":"x"}` reached the same end by a path that sets nothing:
deepMerge replaces scalars and arrays wholesale, so that section took out
the user-global `workflow` object outright and both commands fell back to
their schema defaults. `review: 5` did the same to `review.key_file`.
readPath reported ABSENT for it, so the strip skipped it and nothing
warned - the suppression D-13 reasons about, arriving without touching a
protected key.

Walk the intermediate segments and remove the offending section instead,
warning on a non-null value the way the leaf arm does. Nothing legitimate
is lost: a scalar cannot carry any key, so the repo-settable siblings
under that section were already unreachable through it.

The shipped arm asserting the old behaviour pinned the defect, so it is
rewritten rather than added to.

Raised by the blocking risk_surface review of a27441f..a9b429f.
AC7 claimed reverting either fix alone turns tests in both arms red. The
helper is single and carries both payloads, but sharing a fixture couples
nothing on its own - each arm has to ASSERT the other requirement's
observable, and neither did. Measured out-of-tree: reverting deepMerge's
own-property definition gave 16/1, the CFG-01 arm alone; unwiring
stripGlobalOnly gave 14/3, the CFG-02 arms alone.

The CFG-01 arm never put the scoped keys in its fixture, so the strip was
not in scope for it at all. The CFG-02 arm did carry a cross-assertion,
but on getValue - and that face cannot see the regression it exists for:
mergeLayers' result keeps an own `git` from the default layer, which
shadows the reparented prototype, so `get` still answers the schema
default 'ask' while git-guard's own `config.git?.on_protected` read goes
silent. It passed for the wrong reason.

Put the scoped payload in the CFG-01 fixture and assert the three keys
resolve to the global layer; move the CFG-02 arm's cross-assertion onto
guard(), the face that goes red. Both mutations now fail an arm of each
requirement: 15/2 and 13/4.

The control fixture gains the same scoped payload so the deepEqual
against it still compares like with like.

Raised at the /cad-verify 1 deep pass; blocking risk_surface review of
the staged diff returned no findings.
The gate half of a review fire is resolved by the routing seam; the reviewer
half is prose, so a blocking risk_surface fire went to a same-model subagent
while review.reviewers said openai, and nothing refused or recorded it.
Phase 3 gains the seam-resolved reviewer set and the identity on the
lifecycle event.

Refs #123
Two writers of one planning file shared `<file>.tmp`, so the second
writer's bytes could land under the first writer's rename. The temp
name is now `<file>.<pid>.<n>.tmp` with a module-level counter -
derived rather than random, so a test can name the paths this process
will use - and still a sibling of the target, so the rename stays
same-filesystem and atomic. Two writers still last-write-wins the
target; that cost is now stated in the block comment (D-05).
The read side has lstat-and-skip for this exact shape; the write side
never got it, so a planted temp-path symlink sent the caller's bytes
wherever it pointed. atomicWrite now lstats the temp path it is about
to use and throws when it is a link, naming the path (D-02) - an
ordinary stale temp file is still overwritten, so a crashed process
cannot wedge future writes. The guard covers the TEMP path only: the
target is still replaced by the rename (D-03), which is what heals a
tree an attack already touched.
appendFileSync follows a symlink, so a planted .planning/trace.jsonl
link redirected every event a run wrote out of the tree. appendEvent
now lstats the path ahead of the size stat and returns
{written:false, reason:'symlinked-trace'}, appending nothing. A reason
rather than a throw (D-04): the header's NEVER-throws contract is what
keeps a record of a decision from changing the decision, and the
reason reads exactly like size-cap and append-failed beside it. An
absent file is still the ordinary first write; the header's APPEND
contract paragraph now states what the append costs and what is paid.
`archiveRequirements` selected shipped rows by phase alone, so a `Deferred`
row belonging to a completed phase was moved into `## Shipped` as `Complete` -
inside the command whose stated job is auditing that nothing was dropped.

Add the `r.status !== 'Deferred'` term the siblings in planning.mjs already
carry. A Deferred row keeps its `## Traceability` row, keeps its `## Active`
bullet, and appears in no `moved` entry; the prose half of the close carries it
forward, which is what `phase-done --reqs` already assumes.
Step 1 of `archiveRequirements` filtered the WHOLE file for `- **ID**: summary`
while the Traceability row removal nine lines below was bounded to its section.
An id with a bullet under `## Active` AND under a later hold section had both
deleted and `summaries.set` ran twice - last write wins, so the hold note became
the shipped row's summary.

Bound the bullet scan to `## Active`, heading to the next `^## `, the same cut
parseRequirements and planning-files.mjs use. The bound is by PLACEMENT, never
by matching a hold section by name: the shipped template spells it
`## v2 Requirements` where this repo spells it `## Deferred`, and a name match
would fix one repository and leave every template-shaped project broken.
`decideGateHalt` coerced anything non-array to `[]`, so an unprovable finding
set came back as `proceed` carrying the affirmative reason "no surviving
blocker/high finding" - a claim about input it never saw.

Add a third `unreadable` input naming why the findings could not be read. Under
`auto_close` a named failure halts before merge with an empty `findings` and a
reason that names the failure instead of asserting anything about survivors;
with `auto_close` off the answer stays `proceed`, since no unattended chain is
running. The function stays total and pure and the envelope shape is unchanged,
so /cad-land keeps branching on `action` alone (D-08). The four names the seam
will pass are stated in the JSDoc so the pure core and the seam cannot drift.
`readFindings` collapsed unreadable stdin, EMPTY stdin, malformed JSON and a
valid wrong-shaped envelope all to `[]`, so the unattended-close gate answered
`proceed` with "no surviving blocker/high finding" about a payload it never
parsed. It now reports which of the four it saw and passes that to
decideGateHalt, keeping the advisory envelope exactly as it was (D-08).

EMPTY stdin is one of the four halting states (D-09): the gate requires an
explicit `{"findings":[]}` to proceed, because a forgotten pipe is the likeliest
operator error and is the one case the old gate waved through under
git.auto_close. A bare JSON array still reads as the findings list.

The header's `gate` contract and skills/cad-land/SKILL.md's step-3 pipe
instruction state the same rule, and the SKILL.md weight budget is re-pinned to
its new byte count.
`--n "$PHASE"` with the variable unset reaches parseArgs as a valueless
flag, which mints the boolean `true` - and `Number(true) === 1` boxed
phase 1 complete and flipped its traceability rows behind an ok:true
envelope. Route the flag through requirePhaseArg and use its `.value`
downstream, so `--n 02` still boxes phase 2 and `--n 2.1` still boxes
the sub-phase.
A valueless `--item` parses as the boolean `true`, so `--item "$K"` with
K unset recorded the result against item 1 - a checklist item marked pass
that nobody walked, which the set-once first_pass invariant then makes
permanent. requireInt refuses it before the lookup; a clean integer that
names no item keeps its own unknown-item answer.
Only a NaN screen stood between the flag and the apply, so `renumber
remove --n` with no value read as phase 1 and cut its roadmap line, its
detail section and its directory behind ok:true. requireInt refuses the
valueless and non-numeric shapes; a well-formed decimal is re-tested and
keeps its own "re-place decimal phases by hand" answer, which is a
different repair.
read() collapses ENOENT with EACCES, EISDIR and EIO, so a CONTEXT.md at
chmod 000 collected D-10's absent-file exemption and criteria-coverage
answered {"ok":true,"phases":[]} over criteria it never looked at. A
reader scoped to this one call site keeps the exemption for a genuinely
absent file and pushes an unreadable-context break - the envelope's only
verdict-moving key - for every other errno. read() and its 38 call sites
are unchanged.
`milestone-prune --label '../../../outside-tree'` built the archive root
by string concatenation and handed it to mkdirSync and renameSync, so it
moved phases/1 clean out of the planning root and still answered
{"ok":true,"action":"pruned"}. Two independent terms now run at the point
the label is read, before any read, mkdir or rename, and in both modes:
containment via resolve(), and a refusal for "|" or a newline, which
archiveRequirements would write straight into a table cell. A spaced
milestone name is still accepted - REMOTE_NAME's shape would refuse it.
CADENCE_ROUTE_TABLE, CADENCE_CONFIG_SCHEMA and CADENCE_PLUGIN_MANIFEST each
carried a comment saying "hermetic test injection only" and were honored in
production with nothing gating them. Each now reads its override only when
CADENCE_TEST_SEAM is exactly `1`, falling back silently to the shipped file
otherwise - silently because all three are module-load constants resolved
before any dispatch exists to carry a warning.

The new lib/test-seam.mjs is one pure predicate over one sentinel, and its
header states what it is not: a repo-supplied .envrc can set the sentinel
beside the path, so this buys a stated production surface, not isolation.
What it removes is a lone variable turning a blocking review gate off,
re-opening CFG-02 through the schema's src:global markers, or moving the
version every skew answer is computed from.

Gated per variable (D-16): CADENCE_GLOBAL_CONFIG, CADENCE_MANAGED_SETTINGS,
CADENCE_USER_SETTINGS and CADENCE_DEFERRED_READS are untouched, and the two
comments describing CADENCE_DEFERRED_READS as "the same shape as" the gated
pair now state the divergence instead.

Every fixture setting one of the three sets the sentinel; three new arms set
the override WITHOUT it and assert the shipped triggers, key set and manifest
version come back, each paired with a with-sentinel run so the arm proves the
gate rather than a fixture path that never worked.
CST-01 (no review fires that nothing consumes) and CST-02 (risk_surface
fires on the project's actual surfaces) join RVW-01/RVW-02 in phase 3 -
same files, one pass. CST-03 (tune the maxTurns bound that already exists;
stop reporting an unmeasured figure as Spend) joins phase 4's residue work.

Also repairs REQUIREMENTS.md:39, where an inline `## Shipped` had lost its
leading backtick and become a real heading, making ## Active appear to end
twelve lines early. Same defect repaired at the v3.1.0 close; it recurred.
The adjudication record carried what survived and not what was raised, so a
gate that found nothing and a reviewer whose every finding was refuted wrote
the same row. `--raised <int>` is the structured route for the figure: a
non-negative integer beside `--tokens`, validated the same way, with a
malformed value refused as a malformed CALL so nothing is appended rather
than a best-effort append with the field dropped.

No comma-grouping exception - that one exists because token figures are
PRINTED grouped and a finding count never is. The CONTRACTS row in
self-verify is what keeps the flag the only structured route; the free-text
`--detail` slot is the one RVW-02 already condemned.
`parseAdjudication` now takes the adjudication EVENT and returns a raised
count beside the trigger and survivors; a bare detail string still works,
because the trigger/survivor half has always been readable from one.

Resolution order is the point: the structured `raised` field first, else an
`of <m>` clause written by hand before the flag existed, else null - UNKNOWN,
never 0, since a fire nobody recorded a count for is not a fire that raised
nothing. The legacy clause is read only immediately after the survivor count,
so "all 9 applied" and a voice list containing "of" cannot be mistaken for
one, and the trigger regex is untouched: measured over this repo's own
trace.jsonl, the same 14 of 16 lines parse, none diverge, and the two that
never parsed still do not.
The adjudicated arm's append now carries `--raised`, and the step reports
`<n> survivors of <m> raised` to the user - the line that makes a
nine-findings-all-killed fire visible in the session and not only in the
record. The flag-not-detail rule is written down beside it, because the free
-text slot is already condemned for the voice list and a later reader would
otherwise fold the figure back in.

Scoped to the ADJUDICATED arm: advisory and blocking fires write what they
wrote, with the stated cost that `trace suggest` learns nothing about `plan`
at `solo`. The surface had zero headroom, so `weight-budgets.json` is re-pinned
in this same commit - an un-repinned budget would make a `budget-overrun` from
this addition indistinguishable from one caused by a later removal.
"Nothing survived" means two opposite things. Nothing raised at all is a gate
finding nothing, and the suggestion to turn it off stands. Nine raised and
nine killed is a gate doing real work in front of a reviewer that cannot tell
a finding from an opinion - and turning the gate off is the wrong move on
that row, so it now gets its own subject, evidence reading
`0 survivors of <m> raised`, and `review.reviewers` as the key to look at.

An UNKNOWN raised count contributes zero to the total, so every corpus
written before the flag existed lands on exactly the arm it lands on today.
R2-R6 untouched; the schema-key fixture now exercises both arms, so the new
action is held to the no-phantom-key rule like every other.
An advisory gate blocks nothing: both triggers wrote a findings file that no
SUMMARY and no CONTEXT ever referenced, so the dispatch bought findings that
changed nothing. The routing row and the three prose statements of the same
fact move together because prose-agreement resolves the live table per level.
A user who does read those files sets review.triggers.<t>.gate back on and
wins over the level, which is the existing config-wins precedence.
Four route.test.mjs assertions pinned the shipped level literally, which is
the point of them - they are not derived from route-table.json. Turning plan
and phase_diff off there is what moved them, including the agrees-silently
fixture, whose advisory gate now disagrees with the level it was chosen to
agree with.
Step 3 is gone and the rest renumbered: a fourth adversarial pass over work
three passes already cleared, at the one point in the cycle where acting on a
finding means committing on top of the range being published. The rails the
step carried that were never about the review survive - the unattended
close’s blocker/high halt moves intact into step 3(b), beside the merge it
guards.

Two deferred-read rows went with the step. cad-land Reads
references/review-triggers.md and references/triage-gate.md at no step now,
because it fires nothing and triages nothing, so re-anchoring them would have
pointed at arms that do not read them. git-publish.md keeps both arms, at
3(a) and 3(b).
Deleting pre_ship left decideGateHalt with no producer. Leaving it wired and
always fed {"findings":[]} would re-create in /cad-land exactly the
control-that-reports-success defect this milestone is named after, with phase
2s hardening in place and unreachable, so the gate gets a real feed instead:
a risk_surface fire now persists its settled survivors to
.planning/phases/<N>/REVIEW-risk_surface*.md, and the unattended close unions
those files rather than firing a review of its own.

Every behaviour phase 2 gave the gate is untouched - the four named
unreadable failures each halt under auto_close, proceed whenever auto_close
is off. What changed is the trigger the halt reason names, now pinned by a
test so a gate fed by a deleted producer cannot go quiet again.
Eleven surfaces asserted something about pre_ship, and a dangling clause is
its own defect, so each claim is rewritten rather than deleted: the trigger
count drops to four, the reason diff is off at shipped no longer leans on a
gate that fires later, and every auto_close halt sentence names the producer
it actually reads.

execute-parallel needed a new statement rather than an edit. phase_diff was
the cross-plan detector and it is off at shipped now, so the file says plainly
that nothing else catches an interaction bug there and names the key that buys
it back.

The three DOCS-CLAIMS rows recording these claims are re-stated with them. The
CHANGELOG and the archived phase records are untouched: they are dated records
of what shipped.
One commit, because self-verify derives its trigger vocabulary from the schema
and cross-checks it against both the routing table and the reach table: any
split leaves unknown-trigger or unstated-reach standing.

The three keys go to lib/retired-keys.mjs with since v3.2.0, following the
risk.override.* precedent for a whole retired feature. A user who set the gate
gets a diagnostic naming the removal and the gate that still runs on this
branch, not an unrecognized-key error - this repo carried that exact key until
this commit, so the upgrade path is not hypothetical.

BREAKING CHANGE: review.triggers.pre_ship.{gate,tier,effort} are removed. They
are refused at the write face and warned at both read faces.
The risk_surface artifacts are now the only input /cad-land's unattended
halt has, and two paths made that input read CLEAN when it was not.

/cad-milestone prunes .planning/phases/<N>/ in step 3 and only then chains
/cad-land in step 7, so on the autonomous path the gate globbed a directory
tree that no longer existed - indistinguishable from nothing surviving.
Step 3 now unions the survivors to .planning/REVIEW-risk_surface-<label>.md
before it prunes, and both consumers glob that path too.

The unsuffixed REVIEW-risk_surface.md was also shared by every non-plan
fire, so a later empty settle silently overwrote an earlier survivor rather
than merging with it. Every write is discriminated now: plan-<k> for a plan
fire, the fix's short sha otherwise.

Budgets regenerated for the four surfaces that grew.
The re-armed risk_surface round caught both holes the first fix left.

The carry-forward file was staged into the prune commit, so a survivor rode
onto base and every later autonomous land unioned it again - a hard halt on
a finding answered a milestone ago, with nothing left that could clear it.
It is transient now: written before the prune, read by the chained close,
deleted when that close resolves on either arm, never staged.

The short HEAD sha alone did not discriminate either. /cad-debug and
/cad-verify can fire against the same unchanged HEAD, so the later empty
settle still overwrote the earlier survivor. The discriminator carries the
firing command as well.
The reviewer-availability test needs a tier per trigger, and D-04 forbids
reading one from config.schema.json's defaults: that turns "the schema says
flagship" into "the user asked for flagship", and with the key unset - the
shipped state - every provider would resolve unavailable and the fallback
would be an artifact of the seam rather than the user's config.

So route-table.json carries the fallback tiers by hand, beside a tier_names
vocabulary array, both documented in _meta with that reason stated. Validation
lands where every other cell's does: lib/route-cells.mjs files a missing-cell
for a schema trigger with no tiers entry, an unknown-trigger for an entry no
trigger claims, and an unknown-tier for a value outside tier_names - each
naming the trigger, so a missing tier dies in CI rather than at resolve time.
The gate map said whether a trigger fires; nothing said WHO it fires to, so
the availability rule lived in prose at the fire site - the shape that sent a
blocking risk_surface review to a same-model subagent on 2026-08-13 with
review.reviewers set to openai and nothing recording the substitution.

resolve now returns a reviewers map keyed by the same triggers: claude-subagent
is always available, any other name needs a model id at the tier that trigger
resolves at (the layer's review.triggers.<t>.tier when a layer set one, else
the table's tiers row - never the schema default), and an empty set falls back
to claude-subagent. Every drop and every fallback names the trigger, the
provider and the tier in warnings, so the cause rides in the return instead of
being inferred from a set the caller never sees resolved.

Its own top-level field, never folded into review (D-05), whose values stay
gate strings. Detection only (D-07): nothing refuses an out-of-set dispatch.
Step 3 told the model to start from review.reviewers[] and apply the
availability rule itself - the prose-enforced half of a fire, and the reason a
blocking risk_surface review went to a same-model subagent on 2026-08-13 with
review.reviewers set to openai and nothing refusing it.

It now takes the set from the step-1 resolve, keyed by this trigger, alongside
the gate it already takes from the same line. The availability rule stays
written down as a statement of what the seam decided - including which tier it
reads and where that tier comes from - and the fallback points at the resolve's
warnings entry rather than asking the model to log its own. D-07's limit is
stated in one clause so the set does not read as a refusal it is not.

config-reach.md's review.reviewers row now names bin/route.mjs resolve, and the
four tier rows say the availability test reads the same key. Both surfaces sat
at exactly their ceiling, so weight-budgets.json is re-pinned in this commit.
A cad-reviewer bracket said a reviewer ran, never WHICH one, so a cross-model
fire and a subagent fire of the same trigger were one shape in the record - and
the 2026-08-13 substitution was invisible after the session that made it.

trace append gains --reviewer, validated the way --step is: present but bare or
blank is bad-args naming the flag and NOTHING is appended, because a bare flag
parses as boolean true and would store the literal true as a reviewer name. The
seam stays event-agnostic - no coupling to an event name - and the flag joins
the CONTRACTS row, which is what keeps it the only structured route.

review-triggers.md step 4 passes it on all three halves of the claude-subagent
bracket, and states that it names the reviewer that ACTUALLY ran rather than the
one the trigger asked for: nothing refuses the substitution (D-07), so the mark
is the whole enforcement. weight-budgets.json re-pinned in the same commit.
The provider/request event already carried provider, model, effort, tier,
duration and outcome, and already derived the same phase-scoped corr the fire's
own events derive. What it could not say is WHICH trigger it was fired for, so
a cross-model review and a subagent review of the same phase were one shape in
the record.

review gains an optional --trigger, passed into beginProviderCall's subject and
emitted on the event only when it is a non-empty string - a call made without
it writes byte-for-byte the shape it wrote before. No second event (D-06): a
duplicate would double-count every cross-model review in renderTrace's
counts.provider and would let the seam-written and model-written records
disagree about one call.

The flag joins the review-provider.mjs review row of CONTRACTS and the seam
invocation in review-triggers.md step 4, which now states that this event plus
the subagent arm's --reviewer field are what make two fires of one trigger
distinguishable. weight-budgets.json re-pinned in the same commit.
Plans 1-4 executed and committed. Plan 5 (CST-02, the surface scan) has
not started. The executor reports for 1-4 are here; SUMMARY.md is not
written yet because the phase is incomplete.
trace.jsonl only ever recorded `read` as the declared file list at dispatch.
I measured the gap this morning and it is most of the run: contract plus
declared reads come to about 12% of an 8.2M-token run on this repo, and the
other 88% is whatever the worker went and opened afterwards, which nothing
recorded at all. Every cut I might propose against that 88% is unfalsifiable
until this exists, so it goes in first.

It writes a sidecar and not trace.jsonl on purpose. That file says it has one
writer, its counts are a fixed four-key literal so a fifth family would write
fine and count nowhere, and its 1 MiB cap is sized for tens of events per
phase rather than the thousands that one line per tool call produces.

Paths only, never payloads. Bash records argv[0] with inline VAR= assignments
stripped and Grep records its scope and never its pattern, because /cad-report
reads this file back into a model's context and that is exactly how a recorded
value turns into a leaked one.

This is for data collection. If it becomes a problem it comes back out.

- lib/read-trace.mjs: the rule, the guarded append, the summary
- read-trace.mjs: the PostToolUse hook, silent and exit 0 whatever happens
- planning.mjs reads: the reader, since the file is inert without one
- 22 new tests, 1585 green, tsc clean, 13ms per tool call measured
First live records off the hook came back with target "cd", because almost
every Bash call I make opens with `cd /code/cadence && ...` and programOf took
the first token. That loses the one field the record exists to carry.

It now walks the segments and skips a leading cd, so `cd /a && rg foo` bills
rg. The inline VAR= strip runs per segment, so a secret assigned after the cd
is still never the token recorded.

Also worth noting from those first records: tool_response DOES arrive on
PostToolUse even though the docs do not list it, so bytes is populated and we
get exact figures rather than call counts alone.
The live hook billed a target of "...`" within minutes of going in. My segment
splitter was splitting the whole command string on && and |, which walks past
the first line and into whatever a heredoc carries, and this file's own
contract three paragraphs up says paths only, never payloads. I broke it with
the cd fix an hour ago.

It now scans lines, stops at the heredoc opener after searching that line, and
refuses any token that is not a bare program name. Scanning the first line
alone would have been safe and useless, since nearly every command here opens
with a bare cd on its own line.

The charset bound is what makes it safe rather than lucky: a program name is
short and bare, so anything carrying a quote, a backtick, a space or a sigil is
content and gets refused outright instead of recorded and hoped about.

Second defect the instrumentation caught in its first hour, both in itself.
`review.triggers.risk_surface.surfaces` arrives as an array_enum over the eight
categories with `default: null` and no default array, so "the user chose
everything" and "nobody has answered" stay different states (D-12).
route-table.json carries its own hand-maintained `risk_surface_categories`
copy - route.mjs may never read config.schema.json - and
lib/route-cells.mjs files `surface-vocabulary-missing` when that array is
absent, empty or holds anything but non-empty strings, because route.mjs
returns it verbatim as the unanswered scope. review-triggers.md's detection
list now names the token beside each prose category, and one
prose-agreement.test.mjs case holds the schema enum, the table array and that
list to one list.
`lib/surface-scan.mjs` is a pure lib that takes a description of a tree - base
names, file types, declared dependency names - and returns which of the eight
categories the structure EVIDENCES and which it cannot speak to. Source text is
not a parameter, which is the only form D-14's rule can take that a later edit
cannot quietly undo: the 2026-08-13 keyword pass read `session` x16 as auth and
prose about token cost as billing. No signal at all is `inconclusive` with all
eight recommended, and `destructive` - which no directory, manifest or file
type can ever evidence - is derived as unspeakable and rides every
recommendation rather than being dropped for want of evidence.

The disk half is `planning.mjs detect-surfaces --root <path>`, sibling to
`detect-commands`: two levels deep, ignored trees skipped, an unreadable or
unparseable manifest named in `warnings` rather than thrown, `no-root` when the
root cannot be listed. Run against this repository it returns all eight
recommended and inconclusive, naming neither auth nor billing.
`route.mjs resolve` returns `surfaces` beside `review` and `reviewers`, so one
resolve carries all three halves of a fire (D-13): whether it fires, who it
fires to, and on what. `review.triggers.risk_surface.surfaces` is read through
the same layer-only reader the gate and the tier use - a schema default read
here would erase the unanswered state D-12 exists to keep - and
`surfaces_answered` states which of the two the caller is looking at, so
"the user chose all eight" and "nobody has answered yet" stay distinguishable.

Every unusable value fails toward all eight and says why: a non-list, an entry
outside the table's vocabulary, and a list resolving to no category each push a
warning naming what was dropped. An empty scope would turn the only blocking
review trigger off while every document says it is blocking.

Also re-pins route.test.mjs's top-level route-table key set, which task 1's
`risk_surface_categories` addition turned red.
A `risk_surface` fire whose resolve reports `surfaces_answered: false` does not
reach detection until the project has answered. `detect-surfaces --root .` runs
FIRST so the question arrives carrying evidence, then the ask-user seam takes
it: four options, the recommended one first and labelled, its reason lifted
from the scan's own output - no research pass, because the scan already ran. An
inconclusive scan recommends all eight and says why, because silence is not
absence (D-14). The answer is persisted at the repo layer, which is what makes
it one-time, and it cannot be skipped or defaulted: the seam forbids
fabricating an answer it was supposed to collect.

Asked here rather than in /cad-new-project or /cad-adopt, both of which forbid
configuration questions in their own prose and their own success criteria
(D-15).

Detection is now scoped: a heuristic match outside the resolved set does not
fire, and the set comes from the step-1 resolve rather than a config read at
this site (D-13). Unset still resolves all eight, so no existing project's
coverage shrinks on upgrade. The two pre-filter drops and the once-per-plan
rule are untouched.
route.mjs still described matching a plan's `files:` list against the table's
`surfaces` block and lowering a raised level with a persisted
`risk.override.<surface>` waiver. Neither exists: the table has no such block,
this file has no code for either, and the waiver family is retired with the
dispatch-time floor. Left uncorrected, the new `risk_surface_categories` block
and `review.triggers.risk_surface.surfaces` key read as that floor returning
under a new name (D-24) - so the paragraph now says the floor is gone and not
coming back, names what it cost (`tests/ingest_concurrency.rs` taking six roles
to their top rung on one path token), and says what the new pair actually is:
the scope of ONE review trigger's fire, read off the diff, moving no role's
model and no role's rung. The "Config keys read" list gains the key.
Task 1 added `review.triggers.<t>.surfaces` to the schema, and this fixture
hand-lists the trigger keys its prose is supposed to cover, so the new one came
back as inert-config-key: a key nothing reads. It reads fine on the real repo,
self-verify there is ok:true with problems: [], it was only the fixture that
had not heard about it.

One token added beside the three siblings already in that string.

Outside PLAN-5's declared files, so the executor correctly refused to touch it
and halted blocked rather than reaching past its lease. Authorized separately
and done here instead of by a continuation dispatch, because a fresh executor
for a one-token string edit costs more than the whole repair is worth.
The blocking risk_surface review caught two on plan 5's own range, and both are
the same mistake the scan was written to avoid.

The blocker: once ANY category was evidenced, `recommended` dropped every
silent category except the unspeakable ones. That reads "no signal matched" as
"not present", which is the absence-from-silence conclusion the file argues
against twice in its own comments. A detector set cannot be complete - Django's
contrib.auth and Rails' has_secure_password ship no separate dependency - so a
project with a migrations/ directory and framework built-in auth got `auth`
dropped from its recommendation, and a later auth diff would fire no blocking
review at all. Presence is provable from structure, absence is not, so every
category is recommended now and `evidenced` is what supplies the question's
reason. The narrowing is the user's, which is the whole point of asking once.

The high: a surfaces list carrying an unrecognised entry resolved to its valid
subset and marked the question answered. `["auth", "secret"]` is a typo for
`secrets`, not a decision to stop reviewing secret handling, and it would
suppress the one-time question forever while shrinking the only blocking gate.
It fails safe now. The sibling test made the case on its own: a SCALAR
malformation already stood all eight down as unanswered, so narrowing here gave
one malformation class two different answers.

Both falsifiers are tests, and the bad-entry warning stops saying "dropped"
about entries that no longer are.
The TOML manifest reader collected lines only while inside a table whose
header mentioned dependencies, so PEP 621's `dependencies = [...]` array
under `[project]` never entered the collector. A pyproject-only project
lost its dependency evidence entirely and the surface scan reported
inconclusive, which recommends all eight categories and pays for reviews
the evidence would have scoped.

Track the array by its key instead, across the lines it spans. The close
bracket is looked for outside the quotes so a PEP 508 extra does not end
the array early, and the key is anchored so a tool table's
ignored-dependencies stays a setting.

Four fixtures, each failing without the reader change.
The read register billed every Bash call to its program name and discarded
the arguments, under a paths-only contract meant to keep a secret in a flag
value out of a file /cad-report reads back into context. Measured on this
repo: 323 of 329 targets were bare shell verbs, six named a file, and
`redundancy` - the figure the register exists to produce - was reporting how
often `sed` was typed rather than how often a file was re-opened.

`filesOf` recovers the paths without recovering the payload. A token qualifies
only if it looks like a path, resolves inside the project root, and is an
existing regular file. A secret fails the third test: a token or a password is
not a file in this repo, so it is dropped before anything is written. The
recorded form is root-relative, so an absolute home path never lands in the
record even for a file that qualified. Pure by injection, like `now`.

`target` is untouched and `files` is a new field beside it, so every record
already on disk keeps its meaning and the two quantities stop sharing a
denominator: `redundancy` stays the target figure, `fileRedundancy` is
path-touches over distinct files, and `fileCalls` reports how much of the
corpus the file half covers so a pre-existing corpus never reads as a total.

Only measures forward - the 329 records already written retain no command
string and cannot be recomputed.
Every dispatch re-bills its transcript per turn, so a worker that fires
independent greps one-then-wait pays for the same context N times. Measured on
this cycle's analyzer pass: 63 of 91 billed calls were single grep/sed probes.

Scoped to INDEPENDENT probes only - forcing dependent probes into one batch
fires speculative calls whose unneeded output costs more than the saved turns.
Every dispatch in the spine re-derives the same file map from scratch: the
analyzer greps to locate, the planner locates again, the executor again after
that. The planner already paid for anchors in CONTEXT.md evidence, so tasks
carry them forward and the executor opens named files directly rather than
searching for them.

Weak form deliberately: the anchor is where reading STARTS, never permission to
skip the caller check. The strong form is refuted by this cycle's own evidence -
every audit-cited line number had moved within a day, and caller detection is
exactly what a worker cannot safely inherit. Symbols, never line numbers, for
the same reason.
The coordinator step-marker mandate ran one `trace append` at the start of
every step in context.md (13) and plan.md (12) - 25 orchestrator round trips
per phase, each one a full turn that re-reads the accumulated context.

What they bought was a DERIVED coordinator cost: a step's span minus the
worker brackets inside it. The host already records exact per-turn token
figures in its own transcript, so those 25 turns were buying an estimate of
something measured exactly and for free.

execute.md and verify.md never carried the mandate and lose nothing.
report.md already specs the clean degrade - "No `coordinator` block means say
nothing about residue at all" - so the consumer needed no change.
58.9% of this repo's non-test .mjs bytes are comments and blank lines
(812,320 -> 334,017 across 47 files). An agent that opens planning.mjs to
find one function pays for the whole design record: 170,520 B, of which
94,290 is prose. Skimmed it is 76,230 B.

LINE FIDELITY IS THE CONTRACT. A comment becomes nothing but its newlines
survive, so line N of the output is line N of the file. That is what makes
the two-step read work - skim to orient, then Read the exact range with the
comments intact - and the CLI aborts with `line-count-drift` rather than emit
a file whose references would silently point at the wrong code.

Every ambiguity fails toward KEEPING: an unterminated block comment, a `//`
inside a template interpolation, a `)` before a `/`. Keeping a comment costs
tokens; dropping a line of real code hands an agent source that does not
parse. An unlisted extension is refused outright rather than guessed at.

Verified across all 89 .mjs files in the tree: zero parse failures after
stripping. No AST, no dependency - a string-aware scanner with the standard
regex-vs-division lookback.

The success path writes SOURCE on stdout, not the usual one JSON line:
wrapping 76 KB of code in a JSON string escapes every quote and newline,
inflating exactly what this command exists to shrink. Failures and --stats
still emit JSON.

The four contracts that read code (executor, planner, verifier,
assumptions-analyzer) now point at it; plan-checker reads plans and reviewer
reads diffs, so neither carries the instruction.
recall returned every hit. A real query against this repo's .planning/
corpus returned 72 results at 55.8 KB - past the host's spool threshold, so
the caller got a 2 KB preview written to a temp file and paid a SECOND round
trip to read back the five hits it actually wanted. Same query bounded: 953 B.

--top N, default 5. Every call site in the workflows already says "one line
per TOP result" and nothing in the spine consumes the tail, so the default
gives the three callers what they were asking for and the flag is there for
a caller that genuinely wants more.

`total` rides the envelope. Without it a bounded answer and a thin corpus
look identical, and this repo has spent enough on the difference between
absence and silence to not reintroduce it here.
Two plans, five tasks each: CST-03 (retune the maxTurns bound, add the
consumer-side truncation arms the executor family already has, stop
/cad-report labelling an unmeasured figure `Spend:`) and HYG-01 (the audit's
low-severity removals). plan-overlap reports no shared file, so both run
concurrently.

LND-01 - the /cad-land tracker check - is CUT before execution and moves to
`## Deferred`; issue #121 stays open. It was the one item left in the cycle
that ADDED a mechanism, at the point where the evidence said the next
improvement comes from deleting mechanisms rather than adding gates. Its
GitLab arm was also untestable here: `glab` is absent, so that half would
have shipped unwired and unverified.

Cutting it is what makes the remaining two plans disjoint - LND-01's plan was
the only other writer of weight-budgets.json - so the phase now delivers the
three-disjoint-blast-radii shape CONTEXT asked for, with two.
Two plans, 10 tasks, 24356d6..4e6f1a1. The turn bound drops 400 -> 200 with a
test holding the frontmatter and the seam prose against each other in both
directions; a truncated reviewer return stops reading as "nothing survived";
/cad-report stops calling an unmeasured figure `Spend:`; and the audit's
low-severity tail is deleted rather than carried.

Three deviations, all plan 2, all the same species: the plan asserted a figure
or a grep result that measurement contradicted. 48 of 49 files carry a
@ts-check pragma, not 40 of 41; the milestone-prune grep can never return
nothing because 8 unrelated matches live in that file; trace.mjs:463 carries no
comment to copy. Each was corrected against the tree rather than written as
planned.

risk_surface was not fired on either range - judged non-matching against the six
answered surfaces, and recorded in the summary as a judgement so it can be
falsified rather than assumed.
Plan 2 task 1 corrected the measurement in tsconfig.ci.json's _comment -
checkJs is what makes a file checked, so a pragma documents intent and
switches nothing on - but the same claim is asserted on two surfaces its
lease did not reach, and both now contradicted the shipped config.

CONTRIBUTING.md's typecheck line called the command a pragma honorer; it
names checkJs and the excluded tests instead. REQUIREMENTS.md HYG-01 kept
the pre-measurement 40-of-41 figure against the measured 48 of 49 non-test
files. DOCS-CLAIMS.md rowed the CONTRIBUTING line as accurate, so the drift
ledger recorded the contradiction as verified; CONTRIBUTING-05 is stale
with its resolution, and both its line cite and CONTRIBUTING-04's are
moved forward the two lines the file gained since run 1.
renderUat's counts loop guarded with `in`, which walks the prototype chain,
where parseUat thirty lines up uses Object.prototype.hasOwnProperty.call for
the identical job. A UAT.md hand-edited to carry `status: constructor` or
`status: toString` passed that guard and wrote the count back as NaN on a new
own key: the five printed totals survive, but the object rendering them
silently gains keys the schema has no place for.

One job, one idiom, and the prototype chain out of a counts guard for good.
docs: close v3.2.0, open no cycle
All checks were successful
test / node-test (git, 22) (pull_request) Successful in 46s
test / node-test (git, 24) (pull_request) Successful in 22s
test / node-test (other, 22) (pull_request) Successful in 14s
test / node-test (other, 24) (pull_request) Successful in 15s
test / node-test (planning, 22) (pull_request) Successful in 1m9s
test / node-test (planning, 24) (pull_request) Successful in 1m7s
test / node-test (prose, 22) (pull_request) Successful in 27s
test / node-test (prose, 24) (pull_request) Successful in 27s
test / node-test (review, 22) (pull_request) Successful in 16s
test / node-test (review, 24) (pull_request) Successful in 17s
test / node-test (routing, 22) (pull_request) Successful in 28s
test / node-test (routing, 24) (pull_request) Successful in 26s
test / self-verify (pull_request) Successful in 14s
test / typecheck (pull_request) Successful in 18s
92c2a9e255
PROJECT.md's Active carried the v3.1.0 close and never learned v3.2.0 opened,
so it is rewritten to the close it actually describes: twelve requirements,
four phases, 104 commits, 12/12 traced and 36/36 criteria covered.

REQUIREMENTS.md's Active had accumulated three closes worth of leftovers,
including a fragment of the v3.1.0 narrative and the same "/cad-plan seeds
each row" sentence twice. Replaced with the unscoped state and the five
deferred ids that carry forward.

Cursor points at /cad-phase add with no active cycle.
docs: register risk_surface.surfaces and stop drawing the deleted pre_ship gate
All checks were successful
test / node-test (git, 22) (pull_request) Successful in 19s
test / node-test (git, 24) (pull_request) Successful in 20s
test / node-test (other, 22) (pull_request) Successful in 15s
test / node-test (other, 24) (pull_request) Successful in 15s
test / node-test (planning, 22) (pull_request) Successful in 1m5s
test / node-test (planning, 24) (pull_request) Successful in 1m7s
test / node-test (prose, 22) (pull_request) Successful in 26s
test / node-test (prose, 24) (pull_request) Successful in 27s
test / node-test (review, 22) (pull_request) Successful in 16s
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 27s
test / self-verify (pull_request) Successful in 14s
test / typecheck (pull_request) Successful in 16s
2d6f8fc363
Phase 3 removed pre_ship from the config vocabulary, the wiring table,
/cad-land step 3 and the deferred-reads register, but four doc surfaces kept
describing it as a live gate: the milestone-land figure drew its diamond,
WORKFLOW.md's alt text and caption counted two gates where there is one, and
METHOD.md listed it among the adjudicated gates and named it as the unattended
close's gate. The close reads risk_surface findings now, so METHOD says that.

README never picked up the cycle's headline new key at all.
review.triggers.risk_surface.surfaces is what lets you name the subset of the
eight surfaces your project has, and self-verify cannot catch that kind of
miss: it fails on a key README names that does not exist, not on one README
leaves out.

CHANGELOG gains a Removed section for pre_ship, which the 3.2.0 entry had only
mentioned in passing inside a Changed bullet.
john merged commit 81bdb5d4fc into main 2026-08-14 17:01:34 +00:00
john deleted branch cadence/v3.2.0 2026-08-14 17:01:34 +00:00
Sign in to join this conversation.
No description provided.