Files
msd-core/docs/adr/3473-enforcement-by-construction.md
Tom Boucher ab69b9ce56 enhance(#3987): guard slug re-derivation and the swallowed-precondition shape — §8.5 was guardable after all (#3999)
* feat(#3987): guard slug re-derivation, and record why the swallow shape cannot be guarded

Epic #3473's Decision 1 requires the wrong call site be UNREPRESENTABLE. #3984
measured that two of the nine §8 rules had no guard at all and recorded both as
"Shipped - test-covered". This closes one of them, proves the other cannot be
closed the same way, and corrects two false claims I merged yesterday.

1. §8.3 - scripts/lint-slug-derivation-drift.cjs.

   generateSlugInternal (src/core-utils.cts) is the canonical owner; #3883 removed
   11 inline copies. Nothing prevented a twelfth: no slug guard existed in
   scripts/ or eslint-rules/.

   The detector is STATEMENT-scoped and matches the shape the real copies took -
   one statement carrying BOTH .replace(<negated class>, '-') and
   .replace(/^-+|-+$/, ''). Statement scoping is what buys the precision: the
   loose LINE-level form yields 18 hits with 7 unrelated, a material
   false-positive rate. Measured on the tree: 5 flags, 2 TRUE, 3 SANCTIONED,
   0 FALSE.

   The three sanctioned sites are allowlisted with a reason each, following
   lint-phase-enumeration-drift's form rather than a bare denylist. The owner
   itself is listed explicitly even though it escapes by construction - an
   implicit escape is a latent bug, and the next person to touch line 192 would
   not know the guard depended on it.

2. Both TRUE positives were live defects, not style.

   scripts/qa-smell-ratchet.cjs reproduced the canonical formula including the
   60-cap but trimmed BEFORE truncating - the #2849 bug - and never
   transliterated. The divergence is total, not cosmetic:

     canonical  "privet-mir-privet-mir-privet-mir-privet-mir-privet-mir-prive"
     inline     "tail"

   Cyrillic collapsed to nothing and only the ASCII remainder survived, so the
   ratchet was keying on wrong identifiers for any non-ASCII input.

   tests/planning-inspect.test.cjs carried a helper whose comment claimed parity
   with getPhaseDirFromPhaseId. That function now transliterates; the helper did
   not, so the test asserted against a stale formula while looking correct. Both
   now route through the seam.

3. §8.5 - measured, and deliberately NOT shipped.

   A candidate detector (swallowing catch + errno-retry-set test in the same
   function) gives 26 flags across 11 functions: 0 TRUE, 26 FALSE. Every one is
   best-effort unlink/rm/close cleanup, lost-rename-race backoff, or a deliberate
   null fallback. The file-scoped variant is worse at 71.

   Worse than the noise: the only known true instance was removed by #3885, so
   there is NO POSITIVE CONTROL - the guard cannot be shown capable of failing,
   which this repo requires of every drift guard. Shipping it would add a guard
   nobody can trust and nobody can test.

   The ADR now records the measurement and the reason, keeps §8.5 at
   "Shipped - test-covered", and points at the #1884 regression test as what
   actually enforces it. An honest "not detectable at acceptable precision" beats
   a guard that only ever passes.

4. Two claims I merged into the ADR yesterday were wrong.

   §8.9 said 17 of 19 subsumed children have a test citing their issue number,
   and that #3364 and #3812 have none. Both halves are false, and the claim came
   from a NUMBER-GREP - inside an amendment whose own subject is that a text match
   is not a fact.

     #3364 IS cited: tests/runtime-marker-resolution.test.cjs:107,
       T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364), asserting at
       :115-119.
     #3812 IS covered: tests/gen-state-md-docs.test.cjs:374, asserting at :382.

   Corrected to 19 of 19.

   #3812 does carry a real finding, though a different one: it is PARTIALLY
   DELIVERED on a CLOSED issue. The shipped fix declares cardinality for
   frontmatter keys, but #3812's stated acceptance was about the
   ## Current Position BODY section, and docs/reference/state-md.md:196-208 still
   has no normative single-valued/overwrite sentence and no pointer to
   ## Performance Metrics for history. Recorded in the ADR and left for #3812 to
   re-open - fixing it here would bury a scope question inside an unrelated PR.

Note on B6: this ADDS a guard, and B6 said the net count must fall. #3951 already
amended that clause - a guard ledger is a claim about COVERAGE, not count - which
is what makes adding this one honest rather than contradictory.

Verified: the guard flags 0 on the fixed tree, and PROVES IT CAN FAIL - a fresh
inline copy planted in src/ makes it exit 1 naming the exact statement. All three
sanctioned sites were confirmed exempt BY the allowlist, not by accident of the
pattern, by re-attributing each to a non-exempt path and watching it flag.
build:lib, lint and lint:ci all exit 0.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3987): add the changeset fragment

Doc-only, so it carries forward from the verified sha rather than costing a
second matrix run.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): §8.5 IS guardable — I was wrong, and the guard found a live defect

Two orthogonal reviews. The correctness review overturned my central judgment,
and it was right.

1. I concluded §8.5 was "not detectable at acceptable precision" and recorded
   that in the ADR. False.

   My evidence was 26 flags / 0 TRUE / 26 FALSE. The reviewer pointed out what I
   had not: all 26 false positives are CLEANUP verbs - rmSync 54, unlinkSync 43,
   closeSync 17, chmodSync 12 - and the obvious narrower predicate was never
   tried. A swallowed cleanup is legitimate best-effort. A swallowed CREATION is
   a precondition silently lost, which is exactly the #1884 shape.

   Measured properly, in three stages:
     swallowing catch                                     911
     + try-block calls a CREATION verb                     24
     + enclosing function references a *_ERRNOS set         0

   0 flags, 0 false positives. The `*_ERRNOS` naming key is empirically total -
   all 10 retry/tolerate sets in src/ follow it.

   My second claim was worse. I wrote that no positive control exists because
   #3885 removed the only true instance, so the guard "cannot be shown capable of
   failing". That is self-refuting: this very PR's slug guard proves-it-can-fail
   on a synthetic tree, and the pre-#3885 blob is available as exactly such a
   fixture. It is now the control, and it works in both directions - the rule
   flags 0c43d853e^:src/planning-workspace.cts at line 210, the line the fix
   commit's own message cites, and reports zero on the post-fix code.

   I stopped at the first negative result on the option that meant less work.

   Shipped as eslint-rules/no-swallowed-precondition.cjs, wired into the existing
   src/**/*.cts ESLint block rather than a scripts/lint-*-drift.cjs: no script in
   scripts/ requires typescript/espree/acorn, and scripts/ ships to consumers, so
   a .cts-parsing standalone guard would add a devDep at consumer runtime. The
   ESLint block already parses .cts for free.

2. The guard immediately found a live defect of the same class.

   src/capability-lock.cts swallowed a mkdirSync on the lock directory, then
   acquireLock classified the follow-on failure as `code !== 'EEXIST' → return
   null`. A real EACCES/EROFS makes openSync(lockPath,'wx') fail ENOENT, which is
   not EEXIST - so a fatal filesystem error was laundered into "lock
   unavailable". Same defect as #1884, different laundering target.

   Fixed the way #3885 fixed #1884: the creation failure propagates. Regression
   test proven fail-first by hand - with the fix stashed, EACCES was laundered to
   null; restored, it throws.

   The strict rule does NOT catch this shape (its errno classification is an
   inline literal, not a named set). The rule is deliberately left strict: the
   broadened form had 2 false positives - capability-lock.cts:408, the deliberate
   EEXIST steal protocol, and commonjs-marker.cts:131, which returns a distinct
   documented outcome. The gap is noted in code rather than papered over with a
   noisy predicate.

3. The security review found the slug guard's exemption FAILED OPEN.

   currentFunction was never reset, and only a column-0 `function` declaration
   updated it, so exemption bled from an allowlisted declaration to the next one.
   generateSlugInternal exempted 50 lines for an 11-line function. A
   re-derivation planted anywhere in that window was silently exempt - the same
   fail-open shape that produced a blocker in #3897, and an allowlist is a
   SUBTRACTION so a mismatch fails open by construction.

   Extent is now tracked by real brace depth, and a test plants a violation after
   each allowlisted function's real closing brace and asserts it IS flagged.

4. Also from the security review: the guard was a CI-DoS and narrower than I
   claimed.

   Its unbounded [^\]]* was re-scanned from every `.replace(/[^` start: 54.3s on
   a 1.28MB line. It imported MAX_REGEX_LITERAL_LEN and never called
   readRegexLiteralAt - the bounded tokenizer that exists for exactly this. Now
   routed through it with a 2MB file cap: ~200ms.

   15 of 25 genuine re-derivations evaded. Widened to catch replaceAll, {1,},
   \s*-wrapped classes, escaped ], literal new RegExp(...), five trim spellings,
   .split().join(), and multi-line .replace( args - still 0 false positives.
   Two forms still evade and are documented as deliberate gaps with negative
   tests: the two-statement/temp-var form and new RegExp built from a variable.
   Both need data flow, and guessing at it is how a guard becomes noisy.

   Also fixed: // inside a string truncated the line, a ; inside the collapse
   regex split the statement (a one-character bypass), and SCAN_EXT omitted
   .mjs/.tsx/.jsx.

5. A regression I introduced, caught by the same review.

   qa-smell-ratchet.cjs top-level-required a build output that is not
   git-tracked, so the script hard-failed MODULE_NOT_FOUND before build:lib -
   including for --help, which previously had no build dependency. The require is
   now lazy at the point of use.

6. Four of my own tests were vacuous or weak.

   T9's input yielded an identical string under the buggy formula, so it passed
   on the implementation it was meant to catch. T12 compared maxLen null vs 60 on
   an 18-char name, where they agree trivially. T9-T12 all asserted
   generateSlugInternal directly, so they would pass unchanged if both call-site
   fixes were reverted. And prove-it-can-fail was scoped to scanRepo, never the
   CLI - dropping main()'s exit-code line would have kept every row green.

   All rewritten with discriminating inputs, per-call-site rows that red when the
   fix is reverted, 59/60/61 boundaries, an entirely-non-alphanumeric row, and a
   CLI row asserting the real subprocess exit code and both sanitizeForReport
   sites.

Verified: both guards flag 0 on the tree and both prove they can fail. The
swallow rule's control is confirmed in both directions - pre-#1884 shape flagged,
post-#3885 shape clean. build:lib, lint and lint:ci all exit 0.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3987): record that §8.5 IS guardable, and correct a correction that made a ledger worse

Three ADR corrections, two of them to text this branch wrote hours ago.

§8.5 advances to Enforced. Its previous entry said the rule was not detectable
at acceptable precision. That was wrong twice: the 26 false positives were
uniformly CLEANUP verbs, which is a reason to narrow the predicate rather than
abandon it, and the claim that no positive control exists was self-refuting - the
pre-#3885 blob is available as a fixture and this repo's own guards prove-it-can-
fail on synthetic trees. Narrowed to creation verbs plus a *_ERRNOS reference:
911 -> 24 -> 0 flags, 0 false positives, control confirmed in both directions.
The entry keeps the wrong reasoning visible, because a high false-positive count
being evidence the predicate is wrong - not evidence the rule is unguardable - is
the transferable part, and the first negative result is most seductive when it is
also the answer that means less work.

§8.9's correction is itself corrected. The original 17-of-19 claim was CORRECT
for the predicate it stated; this branch silently swapped cited -> covered and
declared 19 of 19. #3812 appears in zero test files. Changing what a word means
to make a ledger read better is a worse failure than the miscount it claimed to
repair. Both predicates are now reported separately - 18 of 19 cited, 19 of 19
covered - because §8.9 asks for a test NAMING each child, so 18 is the number
that answers it. #3812 is also re-opened for real, rather than the first draft's
promise that it could be.

§8.3 stays Shipped - test-covered rather than advancing. The slug guard catches
the copy-paste class and a dozen variants, but two forms still evade by decision
(temp-var split, new RegExp from a variable) because both need data flow. Naming
them keeps the status honest: the wrong call site is much harder to write, not
unrepresentable.

Closes #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3987): backfill changeset pr number

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): replace my own wall-clock assertion, and close the guard that let me write it

CI went red on ubuntu shard 2/3. The failing test was mine, and the failure was
the test, not the code.

  a 1.28MB line ... scans in well under a second (was 54.3s pre-fix)  7368ms

It asserted ELAPSED TIME. ~200ms locally, 7.4s on a shared CI runner. The bound
introduced for the MAJOR-2 DoS fix works - 7.4s against a 54.3s pre-fix baseline
is the fix doing its job - but an absolute wall-clock threshold on shared
hardware is a race, not an assertion. CLAUDE.md says so directly: "Clock Seams:
Do not assert on wall-clock time." I wrote the anti-pattern the project bans, in
a PR about guards.

Raising the threshold would only move the flake. The row now asserts a
DETERMINISTIC bound instead: an instrumentation seam on drift-scan.cjs counts
readRegexLiteralAt calls and characters examined, and the test asserts
charsExamined stays under an absolute ceiling. Measured on the same 1.28MB
fixture: 120,000 calls, 48,000,000 chars - two orders under the ceiling. The
pathological fixture is kept; only the thing being asserted changed.

Proven to still discriminate: with MAX_REGEX_LITERAL_LEN raised to simulate the
unbounded pre-fix behavior, the same fixture does not complete in 120 seconds,
versus ~0.3s bounded. It is a real regression test, not a tautology.

Then the second half, which is the same defect class as the rest of this PR.

  eslint-rules/no-elapsed-assertion.cjs matched only the EXACT identifiers
  ^(elapsed|duration|took|ms)$.

I used `elapsedMs`. It evaded the rule entirely. tookMs, durationMs,
elapsedTime and msElapsed evade the same way. A guard that cannot see the
violation it exists to catch is exactly what this PR is about - it just happened
to be an existing rule rather than one of the two I came here for, and it was
found because I committed the violation it should have blocked.

Widened to /^(?:elapsed|duration|took|ms)(?:[A-Z]\w*)?$/ plus a narrow
start/endMs delta pair. Deliberately NOT a blanket *Ms suffix: a first draft did
that and produced 2 false positives on `timeoutMs` in
plan-phase-stall-detection, which is a configured timeout and not a measurement.
Verified negative on params, items, forms, terms, dirnames, timeoutMs,
cacheTtlMs and staleAfterMs.

Measured over the five files carrying camelCase timing identifiers: 0 true
positives beyond my own, so nothing else needed rewriting. The rule's own test
file gains a row asserting `elapsedMs` flags, proven to fail against the
pre-widening rule - the same prove-it-can-fail standard both new guards in this
PR are held to.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): a comment I added leaked a Claude reference into every runtime install

The runner went red with 4 failures in tests/install.test.cjs:

  Leaking: .hermes/scripts/lib/drift-scan.cjs
  Leaking: .qwen/scripts/lib/drift-scan.cjs

The instrumentation seam added for the deterministic bound carried a comment
naming CLAUDE.md as the source of the no-wall-clock-assertions rule. scripts/
SHIPS to consumers, so that comment was installed verbatim into hermes and qwen
trees, and the install suite scans for exactly this - a Claude-specific reference
reaching a non-Claude runtime.

The rule is real and worth citing; the filename is not portable. The comment now
says "this repo's test rules" and states the rule inline, which is what a reader
of an installed tree actually needs anyway.

Worth noting what caught it: not lint, and not the two guards this PR adds - the
install suite's full-tree scan, which exists precisely because a shipped file is
read by runtimes that have never heard of CLAUDE.md. Same lesson as the rest of
this PR from the other direction: the check that matters is the one that can see
the surface where the defect actually lands.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): a test fixture swallowed 46 git exit codes and produced a silent false negative

CI red on ubuntu shard 3/3:

  tests/health-validation.test.cjs:2029
  expected exactly one W024, got [{"code":"W006", ...}]

Not caused by this branch, and the evidence is decisive rather than a hunch: the
SIBLING test at :2039 builds the IDENTICAL fixture with the identical
commitsAhead and asserts the same thing, and it PASSED in the same process, same
file, same run. Same input, both outcomes - which rules out logic, ordering,
sharding and environment, and leaves a per-invocation nondeterministic failure
inside one fixture build.

The mechanism is an unchecked exit code, 46 times over. The W024 fixture performs
~46 runGit spawns and never checks a single one. runGit returns failures as DATA
and never throws, so one silently-failed `git commit` yields 19 commits instead
of 20, or a silently-empty `git rev-parse HEAD` yields a blank state_head. Either
drops readStateHeadFreshness below the advisory threshold, W024 never fires, and
only W006 remains.

Reproduced exactly: 20 commits -> ["W006","W024"]; 19 -> ["W006"]; blank
state_head -> ["W006"] - byte-identical to the CI assertion dump.

The arithmetic is what hid it. At threshold-1 and threshold+1 a lost commit still
produces the asserted answer; only the exactly-at-threshold cases sit one commit
from a false negative. Two of the seven tests are in that position, and CI hit
one. That is why it had never been seen before, and why it surfaced now: this
branch adds three test files, which reshuffles the cost-weighted shard partition
and moved this file into a chunk where the latent flake fired.

My files were checked as suspects first and cleared: all fixtures mkdtemp-unique,
no process.chdir, no .planning/ writes, no git spawns, and node --test gives
per-file process isolation regardless.

Fixed at the cause, not the symptom. A mustGit wrapper throws on a non-zero exit
with the command, exit code and stderr, and all nine call sites route through it.
The fixture now asserts its OWN preconditions before the assertion under test
runs - the seed head is non-empty, and `git rev-list --count <seed>..HEAD` equals
the requested commitsAhead - so a fixture that did not build what it claims fails
loudly as a FIXTURE ERROR naming got-versus-asked, instead of quietly handing a
weaker input to the assertion.

Proven: dropping one commit now raises
  FIXTURE ERROR: requested commitsAhead=19 but git rev-list --count reports 18
where it previously produced a silent ["W006"] pass-for-the-wrong-reason. 64/64
tests in that block pass unperturbed.

Deliberately NOT done: no threshold change, no retry, no loosened assertion, no
skip. The assertion was correct; the input was silently wrong.

Worth naming, because it is the same shape from the other side: this PR ships
eslint-rules/no-swallowed-precondition.cjs, whose entire subject is a swallowed
precondition failure being laundered into a plausible downstream outcome. This
fixture is that defect in test code - the swallowed git failure was laundered
into a legitimate-looking "W024 did not fire". The rule does not cover test
fixtures, so the connection is noted at the fix site rather than enforced.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3987): two tests wrote to committed files; the shard packing decided when that mattered

CI red on windows-latest shard 1/3 only:

  "gen-exit-code-registry: CLI" > "a --write run redirected to a tmpdir leaves
  every committed artifact untouched"
  AssertionError: hooks artifact must be untouched

The Linux runner passed the same sha at 40425/40425. It is Linux-only, so a
Windows-scheduling defect is structurally invisible to it.

Root cause, established by measurement rather than inference.

tests/cli-exit.test.cjs appended a corruption marker to the REAL COMMITTED
hooks/lib/exit-code-registry.js, held it corrupted across a full subprocess, and
restored it in a finally. tests/exit-code-registry.test.cjs reads that same real
file before and after its own subprocess and asserts byte equality. If it samples
while the other test holds the file corrupted, it fails. The landmine is
pre-existing, from 2ea5efc15 (#3911).

What this branch changed is WHEN the two run together. scripts/run-tests.cjs
shards by cost-weighted LPT over the sorted unit list, so adding three test files
repacks the bins:

  merge-base c3e667df3 (838 files): cli-exit -> shard 1, exit-code-registry -> shard 3
  HEAD       03b342601 (841 files): BOTH in shard 1, same argv chunk, one
                                    node --test process, concurrent

Co-location is necessary but not sufficient - Linux shard 1/3 also had both and
passed. Windows loses because TEST_CONCURRENCY defaults to 2 there against 4
elsewhere, spawn cost is ~10x, and the sibling corruptor holds one of only two
slots through a ~90s tsc compile. That turns a sub-second overlap into seconds.

Not a path-separator or case-sensitivity issue, and not CRLF - .gitattributes
pins * text=auto eol=lf. Redirection was not at fault either: ensureScriptsOut
derives all five --out flags correctly and gen-exit-code-registry.cjs honours
them with no __dirname escape.

Fixed at the cause: no test writes to a committed file any more. Both corruptors
now copy to a mkdtempSync tmpdir, corrupt the COPY, and point the generator at
it. Repinning or reordering the shards would have turned CI green while leaving
the landmine armed for the next reshuffle.

That required closing an inconsistency between two sibling generators.
gen-exit-code-registry.cjs already accepts
--out/--scripts-out/--hooks-out/--dts-out/--sh-out and honours them under
--check; gen-hooks-cli-exit.cjs hardcoded OUTPUT_PATH and had no flag surface at
all, so its corruptor could not be redirected anywhere. It now takes --out in the
same style, honoured by both --write and --check, and is a no-op when absent -
verified: a bare --check on the default path still exits 0.

ensureScriptsOut moved to tests/helpers/exit-code-artifact-flags.cjs and both
test files import it. Hand-rolling a second copy of the flag derivation would
have been a re-derivation of exactly the kind this PR ships a guard against.

Verified: both tests still detect corruption (proven by defeating the check and
watching them red, with a positive control showing an uncorrupted copy exits 0);
SHA-256 of hooks/lib/exit-code-registry.js and hooks/lib/cli-exit.js identical
before and after running both rewritten bodies, and git reports nothing under
hooks/ modified - that is the property that was violated. A repo-wide search for
the corrupt-then-restore-in-finally shape against hooks/ found no other
instances.

One detail worth recording: the tmpdir test keeps --declaration pointed at the
real committed declaration rather than copying it, because the generated banner
embeds path.relative(REPO_ROOT, declarationPath) - copying it would produce a
false drift unrelated to the injected corruption. The declaration is read-only on
that path and never written.

Refs #3987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:40:02 -04:00

71 KiB
Raw Blame History

ADR-3473: Enforcement by Construction — One Owner per Invariant

  • Status: Accepted, and all phases have landed (2026-08-28). Phase 0 shipped this file alone; every rule in §8 now carries its delivered status and its measured executor — see §8's status amendment (#3984), which records that four rules are test-covered rather than guard-enforced.
  • Date: 2026-08-25
  • Issue: #3473 is the scope authority (epic + approved-enhancement + area: core), which is why this ADR carries its number. #3868 is the Phase-0 tracking sub-issue this PR closes — the epic stays open until the final phase merges. Convention follows ADR-3180 and ADR-3408.
  • Supersedes: nothing.
  • Relationship to prior work: the third application of ADR-3180's mechanism. ADR-3180 gave each read-side derivation one owner; ADR-3408 applied the same mechanism to the STATE.md write path. Both succeeded on their declared surface. This ADR owns the invariants that sit outside both — document parsing, enumeration, and the return contract of every routine that can fail — and adds the step neither took: retiring the guard once the seam makes it redundant.

Symbol names are the durable anchors throughout. Line references are as of next @ e40e9670f and will drift.

Context

Three mechanisms, one shape — the invariant is not representable in code, so every consumer re-derives it by hand.

The evidence base is #3473's filing (a systemic root-cause review of all 30 open confirmed-bug issues on 2026-08-14) plus a re-verification against next @ e40e9670f on 2026-08-25. Where the two disagree, this section carries the later reading and says so.

A. Policy declared once, enforcement hand-rolled per call site

Invariant Canonical owner Reality on next
slug rule generateSlugInternal, src/core-utils.cts 11 inline copies across 7 files (measured in Phase 6, #3883 — the "13 across 5" written here was wrong twice over: two of the thirteen were an unrelated tokenizer regex, and the file count was never taken); copies and generator disagree on Cyrillic transliteration and trim-vs-truncate order (#2986)
isSentinelPhaseId src/phase-id.cts consumed by 11 modules, absent from 4 phase-enumerating commands (#3372)
verification-file discovery (none) independently implemented twice, both alphabetical-first (#3357)
runtime identity bin/install.js persists it resolveRuntime never reads it back (#3364)
Codex sandbox policy the managed role contract installer map lists a subset; absent roles fall back to read-only, and validate agents passes anyway (#2540)
decision phase fm.current_phase cmdStateAddDecision never consults frontmatter, persists a literal [Phase ?] (#3231)
depends_on resolution resolveDependencyId, src/phase.cts the retired SDK lineage carried a third shortFormToId tier the surviving lineage never had (#3427)

B. A hand-rolled YAML dialect

parseYamlRegion (src/frontmatter.cts) strips surrounding quote characters but never inverts the escapeDoubleQuoted that reconstructFrontmatter applies on write. A quoted field's byte length roughly doubles per read-modify-write cycle until any command that loads the file OOMs (#3349).

Two /m-anchored patterns in the same module use \s at a ^ position. Per ECMA-262, \r is a LineTerminator in its own right, so under /m the ^ asserts between the \r and the \n of a CRLF pair and the \s swallows the \n — parseMustHavesBlock returns [] for all four must_haves blocks in every CRLF plan file (#3360).

js-yaml is already a devDependency and already in the lockfile. Production ships the hand-rolled parser.

C. Absence, emptiness and failure all encode as success

  • parseNamedArgs discards unrecognized and positional tokens with no error; the flag resolves null and cmdStatePlannedPhase runs anyway (#3358).
  • phases.list --pick summaries_total returns an empty string with exit 0, so the $(… || echo "0") idiom never fires (#3365).
  • routeQuickTasksAppend writes a row that contradicts the row quick.md specifies (#3356).
  • review.md writes REVIEWS.md even when every reviewer lane fails, then deletes the evidence (#3352).
  • resolveDependencyId returns null for an unresolvable depends_on token; every call site treats it as continue, every short-form edge is dropped, and the tool then emits a wave-mismatch verdict manufactured by its own data loss (#3427).
  • phase.complete's Requirements-line tokenizer silently under-selects: a spaced range marks only its two endpoints, a tight range marks nothing, and neither warns (#3697).
  • acknowledgeDeferredItem returns ok having written nothing when a U+2028 sits in a deferred item's status line (recorded on #3473, 2026-08-23 — the same ECMA-262 LineTerminator shape as #3360 with a different terminator).

D. The STATE.md family — the surface two ADRs left between them

This family is the reason Phases 1–3 exist and is recorded here rather than inferred later.

ADR-3408 shipped completely: all five phases closed, scripts/lint-state-write-path-drift.cjs is live, and its write-path drift baseline stood at two entries, both owner: sanctioned-permanent — zero debt. The seam is real and it held. (That baseline was retired, file and all, by Phase 1 — see §8.6's amendment. This paragraph records the state at filing.)

Eleven state defects were nonetheless filed between 2026-08-21 and 2026-08-25 (#3853, #3836, #3835, #3834, #3830, #3818, #3812, #3807, #3784, #3756, #3743). None is a bypass of that seam. They sit at four places it does not reach:

Location Mechanism Issues
the seam's input applyStatePreservation's precondition — a populated pre-write snapshot — is unexpressed. When it is absent every declared row silently no-ops, so a preserve-always field is destroyed by verbs unrelated to it #3756, #3834, #3835, #3836
the seam's output reconcileReportedFields excludes progress by classification to dodge the #1264 regression; the same exclusion suppresses the case where the field genuinely changed #3743, #3818
the schema the key set, its types, its enums and its cardinality are transcribed by hand into nine artifacts, so no writer can consult a declared enum and no reader can consult a declared cardinality #3853, #3812
derivation authority which source is authoritative for a fact. Owned by neither ADR — ADR-3408 §7 scopes it out, ADR-3180 covers read derivations only #3830, #3807, #3784

The fourth row is not in this ADR's scope. It is ADR-3180 Amendment 8, which generalizes §7.5's already-locked sentence — "Prose is not a lifecycle signal … a caller may not compensate by reading prose locally" — from plan-lifecycle to every derivation in Decision 7, on read and write. Recorded here so a future reader does not file it against this epic a third time.

The first three rows are instances of §8.4 (failure is a value) and §8.3 (one implementation per rule) at a surface specific enough to phase, and they are Phases 1, 2 and 3 respectively.

The failure mode that hides all of it

This is ADR-3180's signature shape, one layer out: the failure and the success are output-identical. Every row above returns a plausible value no caller can distinguish from a real one. #3258 is the proof the trap works as designed — a careful reporter read the FIELD_CLASSIFICATION table, correctly identified an unimplemented row, and filed #3234 for a symptom that does not occur, because the policy was enforced ~1300 lines away in a different module.

Why a contract, and not twenty point fixes

25 of the 30 open confirmed-bug issues surveyed in the 2026-08-14 filing cite a prior issue by number, and 10 state explicitly that an earlier fix landed on one branch, one call site or one input shape and left the family alive. The intake rate is not discovery. It is one issue per unpatched call site.

The response to each family so far has been a detector: 22 custom ESLint rules and 43 lint/drift-guard scripts, 18 with drift in the filename (counted at next @ e40e9670f; #3473's filing said 20 / 45 / 12 on 2026-08-14 and the mix has drifted since). A detector encodes the fingerprint of the last bug, not the class. Two demonstrations, both verified:

  1. local/no-crlf-fragile-split is registered at error on src/**/*.cts, widened from tests to source by ADR-3212 Phase 2 specifically to stop CRLF parse bugs. #3360 is a CRLF parse bug in src/frontmatter.cts that shipped under it — the rule fingerprints a bare \n in a regex; the defect is \s at a ^ under /m, which matches \n but contains none. The guard structurally cannot see it, and lint is green.
  2. local/no-adhoc-markdown-parsing is registered in exactly one config block, src/**/*.cts — not tests/, not scripts/, not bin/. #3426 and #3239 are hand-rolled section and table scans inside tests/package-legitimacy-gate.test.cjs, outside its glob. A gate's own parsing is now a defect that can false-pass.

Decision

1. Every invariant in §8 has exactly one owner, and the wrong call site is unrepresentable

Not detectable — unrepresentable. Where a seam can make a call site impossible to write, that is the fix; a guard is the fallback, not the goal.

2. A declared policy with no executor is a LOUD failure — and this line does not extend to user documents

ADR-3408 Decision 2's bright line is adopted verbatim and extended to this ADR's surface:

Bad input Response
Internal invariant violation — a declared row with no wired executor, or a pipeline stage reached without its required input throw. Both ends are gsd-core's own source; it is a programming error unreachable from any user document
User-document defect — a drifted, malformed, or unparseable .planning/ file never throw. Degrade per policy and warn; behavior otherwise unchanged

Getting this backwards would turn every desynced project's phase.complete into a hard failure. It is a rule, not a note.

3. Failure is a value

Every routine that can fail returns the hub's existing Result = {ok,data} | {ok:false,kind,…}. [], "", null and exit 0 stop being legal ways to say "I could not parse this."

4. Where a routine discards an input, it says so, naming the input

A derived conclusion may not be reported as authoritative when the derivation dropped input it could not resolve. An unresolved depends_on token surfaces its own warning rather than hiding behind a wave-mismatch verdict (#3427).

5. The anti-divergence contract

ADR-3180 Decision 4 (a)–(e) is adopted verbatim and not restated. Two constraints are made explicit here rather than inherited, because both have already failed once in this repo:

  • The scan surface is declared and includes the prompt layer (gsd-core/workflows, commands, agents, skills), which can shell out to a query and post-process. A guard registered only on src/**/*.cts is how #3426 and #3239 shipped.
  • Owner functions are exempt; the owner file is not. ADR-3180 Amendment 4 records a whole-file owner exemption failing in roadmap-parser.cts.

Per ADR-3180 Amendment 3's standing rule, each phase states its copy count as "N found by the guard", never "N per the epic."

6. Guards are retired, not accumulated — with a truthful ledger

Each landed phase names the guard it makes redundant and deletes it in the same PR. This is the first epic in this repo whose success metric includes a shrinking guard surface.

The ledger is per-phase-set and honest, not per-PR and gamed. A phase that legitimately grows a guard records the growth in the same ledger rather than omitting it. A net fall that is achieved by not counting an increase is the "measure became the target" outcome Decision 5 exists to prevent.

7. Migration order

Locked for the assigned phases: Phase 0 (this ADR) → Phase 1 (§8.6) → Phase 2 (§8.7) → Phase 3 (§8.8).

Phases 1–3 are stacked and sequential: Phase 2 diffs the snapshot Phase 1 makes mandatory, and Phase 3 generates a table whose executor Phase 1 changes.

get_impact(applyStatePreservation, direction=upstream, depth=5) against next @ e40e9670f rates LOW (2 affected symbols, routers only), so Phases 1–3 do not inherit ADR-3408 §6's blanket "never parallel" constraint — that constraint was scoped to readModifyWriteStateMd (185) and syncStateFrontmatter (154), both CRITICAL. ADR-3180 Amendment 8 (advancePlanCore, MEDIUM, 16 affected) is therefore free to run as a concurrent lane; its direct radius and Phase 1's are disjoint, meeting only at the command routers.

Unassigned. §8.1–§8.5 carry no phase number yet. They are as binding as the assigned rules; only the tree's conformance differs.

8. The behavior contract — THIS SECTION IS THE SOURCE OF TRUTH

Decisions 1–7 answer how this epic is organized. This section says what the right answer is, and it is what the guards and identity tests of Decision 5 test against.

  • Where this section and the code disagree, the code is the defect — not this section, and not a caller's local expectation.
  • A behavior not stated here is not decided. It is recorded as an open question with a forcing function, never resolved silently inside an implementation PR.
  • Amending a rule here is an amendment to this ADR, not a code change with a comment.
  • Each rule carries a status. See the amendment immediately below for the current vocabulary.

AMENDMENT, 2026-08-28 (#3984) — the status column was never advanced, and a two-value vocabulary could not have been advanced honestly.

Every phase shipped and every sub-issue closed, yet all nine rules still carried their pre-implementation status: three read "Required — phase unassigned" for rules that Phases 5, 7 and 8 demonstrably delivered, and the word Enforced appeared exactly once in this document — in the sentence that defined it. A reader auditing coverage here would have concluded three of the nine contract rules had no owner. That is the orphan shape this epic exists to eliminate, sitting in the epic's own contract, and it is the same defect class as B6's guard ledger: a status column is a claim, and it was false.

Flipping all nine to Enforced would have repeated the mistake in the other direction. Decision 2 says a declared policy with no executor is a loud failure, so Enforced asserts an executor exists — and measurement (2026-08-28) found the nine are not equally enforced. The vocabulary is therefore three values, not two:

status means
Enforced a standing guard — an ESLint rule or scripts/lint-* drift script wired into lint:ci or CI — fails the build on a violation. A new wrong call site cannot land.
Enforced (structural) the wrong call site is unrepresentable: the only way to do the thing is through the single seam.
Shipped — test-covered delivered, with regression and identity tests, but no standing guard. A regression in covered code is caught; a new wrong call site elsewhere is not.

The third value is not a euphemism for "done". It names precisely where this epic's own thesis — make the wrong call site unrepresentable — is not yet achieved, so the gap is visible instead of implied by a green suite. §8.3 and §8.5 were the thinnest rows at the time this was written: neither had any guard, and nothing then prevented a twelfth inline slug copy or a second silent swallow. Update, #3987: §8.3's slug half now has a guard (scripts/lint-slug-derivation-drift.cjs); its marker half remains unguarded. §8.5's candidate guard was measured and rejected — see its own status block for the 26/0/no-positive-control finding — so it stays Shipped — test-covered, enforced instead by the #1884 regression test.

8.1 One YAML parser — Enforced — Phase 4 (#3881)

Executor: scripts/lint-vendored-deps.cjs + eslint-rules/no-external-require-in-bin.cjs, both wired into lint:ci. Also structural (one vendored parser) and covered by a fast-check round-trip property test.

Question. What parses and serializes .planning/ frontmatter?

Owner. A single vendored parser. parseYamlRegion and escapeDoubleQuoted are deleted, not patched. (Post-#3881-review, finding 2: the hand-rolled implementations behind both names are gone — src/frontmatter.cts now renames them to parseGuardedYamlRegion and escapeDoubleQuotedScalar so no function still answers to the deleted scanner's name; see those functions' docblocks for the full reasoning, including why escapeDoubleQuotedScalar's rename required updating its three call sites rather than being treated as an ADR-amendment matter.)

Rule. Escaping, quoting, CRLF handling and indentation leave this repo's maintenance surface. Round-trip values are identical; a property-based fast-check round-trip test is the gate.

Rule — packaging. gsd-core/bin/** is copied by the installer into runtime dirs that have no node_modules, so it must contain zero external requires. The dependency is vendored to gsd-core/bin/lib/vendor/js-yaml.cjs and imported relatively; js-yaml stays in devDependencies; scripts/lint-vendored-deps.cjs byte-compares it against node_modules in lint:ci. local/no-external-require-in-bin (from #3496) fails the build if this is done the naive way. Promoting js-yaml to dependencies breaks every installed tree — this was learned the expensive way in #3496 (100 test failures across 8 install-surface suites) and is recorded as a rule so it is not re-derived.

OPEN QUESTION — the type contract. Forcing function: this question is answered in §8.1 before any implementation PR for this rule is opened.

extractFrontmatter is deliberately lossy and a large amount of machinery exists to compensate:

  • Every scalar parses as a string. gap_closure: true becomes "true", and FRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValues is written as { gap_closure: 'true' } with a comment saying so. A faithful parser returns a boolean and that check silently stops matching.
  • Object-lists are deliberately flattened to scalar strings. sliceTopLevelFrontmatterSegments (#1572), regenerateFrontmatterKey's fail-closed [object Object] guard, frontmatterDeepEqual, and noOpObjectListSetError (#1660) all exist because of that flattening.
  • A comment channel (#3257) attaches column-0 # comments to the following key. A faithful parser discards comments.

So this is a type-contract migration across ~50 call sites, not a parser substitution. Two answers are admissible:

(a) keep a string-coercing adapter over the parser so the existing contract holds, fixing only the escaping and CRLF defects; or (b) migrate consumers to real types and retire the compensating machinery with them.

Both close #3349 and #3360, which are read-side defects a real parser fixes regardless of the value types it hands back. (b) is the larger prize and is its own epic-sized change.

Sequencing note, decided 2026-08-25. This rule lands after Phases 1–3. §8.8's schema declares each key's real type, cardinality and enum — which is precisely the artifact that makes (b) tractable rather than epic-sized. Answering the fork before the schema exists means guessing the type contract; answering it after means reading it off the schema.

ANSWER, 2026-08-26 (Phase 4, #3881) — the fork is (a), a string-coercing adapter. The forcing function above is discharged here: the question is answered before the implementation PR opens.

The sequencing note's bet did not pay. Measured against the merged schema rather than predicted: extractFrontmatter has 78 non-test call sites across 23 files, and only 33 of them (42%) read STATE.md. The other 45 read PLAN, VERIFICATION, SUMMARY, UAT, roadmap or generic agent/skill frontmatter — document kinds §8.8's schema does not model. FRONTMATTER_SCHEMAS still declares four kinds with no type declaration for any of them, and STATE_FIELD_SCHEMA has no cross-reference to it. (b) would therefore still require net-new type contracts for four-plus document kinds: the schema shrank the STATE.md slice of an otherwise unchanged epic-sized migration.

The prize is also smaller than the list above implies. Of the five compensating mechanisms named, two survive real types: frontmatterDeepEqual (17 lines, 3 callers) is required for no-op/dirty-key detection whatever the value types are, and the #3257 comment channel is orthogonal to typing — a faithful parser discards comments, so that channel is needed more under (b), not less. Only sliceTopLevelFrontmatterSegments, the [object Object] guard and noOpObjectListSetError die: ~31 lines across 3 call sites.

(b) remains the larger prize and is not silently dropped — it is recorded here with the numbers that say why it stays epic-sized, so a future reader inherits the measurement rather than the intuition.

Implementation note: fork (a) needs no hand-written coercion layer for scalars. js-yaml's FAILSAFE_SCHEMA resolves only !!str/!!seq/!!map, so every scalar returns a string by spec. gap_closure: true stays "true" and FRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValues keeps matching with no call site changed. Verified across true, null, ~, 1.5, 0x10, a date, yes, on, .inf, NaN and quoted-vs-unquoted numbers: all agree with legacy.

CORRECTION to the answer above, 2026-08-26 (Phase 4, #3881) — fork (a) as this ADR specifies it is NOT IMPLEMENTABLE, and the fork itself is ill-posed. An adversarial pass on the Phase 4 design established this by execution, and it supersedes the "(a)" answer recorded above.

(a) is defined as "keep a string-coercing adapter over the parser so the existing contract holds." That presumes the existing contract is expressible as a function of a parsed YAML tree. It is not. extractFrontmatter is not a YAML parser; it is a line-oriented scanner whose output is a function of the raw source text. Four spellings of the same value:

source line legacy js-yaml
- test: "a b" ["test: \"a b"] [{"test":"a b"}]
- test: a b ["test: a b"] [{"test":"a b"}]
- test: 'a b' ["test: 'a b"] [{"test":"a b"}]
- {test: a b} ["{test: a b}"] [{"test":"a b"}]

One tree, four legacy strings — one of them mangled, with the closing quote stripped. No adapter over a tree can choose among four outputs that the tree does not distinguish. Reproducing them requires keeping the legacy line scanner, which is the surface §8.1 exists to delete.

The consequence for the fork: for any document with a non-scalar value, (a) collapses into (b). Structured values cannot be flattened back to their source spelling, so consumers must either accept a canonicalized string or move to real types. There is no third option, and roughly 26% of frontmatter-carrying documents (230 of 897 by the adversarial count; 239 of 901 by mine — the denominators differ by fence-detection edge cases and are reconciled during implementation) hold at least one non-scalar top-level value.

Three further defects in the design this correction replaces, all confirmed by execution:

  1. Returning {} on a parse failure is destructive, not benign. Eight call sites across state-transition.cjs and state.cjs compute hasFrontmatter = Object.keys(extractFrontmatter(...)).length > 0 and, when false, reassemble the document without a frontmatter block. A STATE.md carrying a git merge-conflict marker, a tab indent or a duplicate key parses today and would, under a catch-and-return-{} adapter, have its frontmatter deleted on the next write. The caller conflates "empty" with "unparseable"; the adapter must not feed that conflation. (Not a live bug today: the only four tracked documents legacy parses to empty are archived changesets, which never reach the state write path.)
  2. An empty value silently drops its key. Legacy parses progress: to {}; js-yaml yields null; reconstructFrontmatter omits any null-valued key. gsd-core/templates/state.md ships an empty progress:, so passing null through deletes it on the next write.
  3. The truncation probe is not a pre-parse heuristic — it IS parseYamlRegion. #1882's diagnostic cannot both "stay unchanged" and survive that function's deletion. Pointing it at js-yaml silences it on the dominant real shape (fence opened, body follows: legacy sees 2 keys and fires; js-yaml raises bad indentation and yields 0 keys, so it stays silent). Keeping it hand-rolled recreates precisely the parallel-surface divergence frontmatter.cts's own comment warns against.

A new attack surface the fork never considered. FAILSAFE_SCHEMA still resolves anchors and aliases. A 7-line frontmatter block expands to a 22.8 MB structure in 0 ms, and one further nesting level is ~200 MB — which frontmatterDeepEqual and reconstructFrontmatter then walk. The legacy scanner is immune, because &a [...] is just a string to it. .planning/ documents are user documents, so this is a real regression vector that any implementation must close, not a theoretical one. Current corpus occurrences of anchors, aliases and merge keys: zero, so nothing is lost by refusing them outright.

Verified clean, and worth recording as negatives: top-level key order agrees across all frontmatter-carrying tracked documents (0 disagreements); the never-throw claim holds (legacy threw on 0 of 11 hostile inputs, including a 200 KB scalar, 20k keys and 5k nested opens); and the ten scalar spellings above all agree.

RESOLUTION, 2026-08-26 (maintainer decision). Presented with three options — split §8.1 into its own epic, take the full semantic migration now, or patch the scanner and drop the vendoring — the maintainer chose the full semantic migration. Phase 4 therefore adopts js-yaml's semantics as truth and carries all seven consequences above, rather than attempting the contract-preservation that §0.1 proves impossible. The fork is not answered as (a) or as (b); it is answered as "the fork was ill-posed, and the migration is semantic."

Guard ledger, counted rather than estimated — Phase 4 GROWS. Excluding the 3,014 vendored third-party lines, the hand-maintained surface is +307 lines net: src/frontmatter.cts alone is +248/−180 = +68, growing despite deleting four functions, because the compatibility layer that reproduces this repo's bespoke contract on top of js-yaml is larger than the scanner it replaced.

So §8.1's stated benefit — "escaping, quoting, CRLF handling and indentation leave this repo's maintenance surface" — is not delivered as written. Those concerns did leave; a compatibility layer replaced them and the line count rose. What genuinely improved is the kind of code maintained: this repo no longer owns YAML spec conformance, whose bugs were #1779, #1882, #1572, #1660, #3257 and #3497. It owns a thin adapter over a parser whose correctness is upstream's problem. That is a real gain, and a smaller one than the rule claimed.

Decision 6 requires the growth be recorded rather than netted away — a net fall achieved by not counting an increase is the Goodhart outcome Decision 5 exists to prevent. Across the epic: Phase 1 shrank (−665 lines, −1 file); Phase 2 was flat; Phase 3 grew (+2 guard surfaces); Phase 4 grows (+307 lines). Only one of four phases delivered the shrink this epic was framed around.

Amendment, 2026-08-26 (Phase 4, #3881) — the justifying sentence above is wrong, and this is the THIRD wrong premise in this ADR. The claim "Both close #3349 and #3360, which are read-side defects a real parser fixes regardless of the value types it hands back" describes defects that no longer exist. Verified by executing the compiled parser at ddde001af, not by reading it:

  • #3349 (escape never inverted on read → b → 2b+1 growth per read-modify-write until OOM): four successive round-trips of a value containing " and \ return it byte-identical every time, length stable. unescapeDoubleQuoted is a genuine inverse, shipped under #3497.
  • #3360 (\s at ^ under /m eating the \n of a CRLF pair → [] for every CRLF must_haves block): LF and CRLF inputs both return ["alpha","beta"].

Both issues are CLOSED. After §8.6's "keeps only its raw-write check" (no such check existed) and §8.8's "delete lint-state-field-drift.cjs" (it guards an unrelated contract), the pattern is now established firmly enough to be stated as a rule: a factual claim in this ADR is a hypothesis until the implementing phase executes it. Decision 6 obliges a phase to verify a claim before acting on it, not merely to count the result.

The Rule survives the collapse; only the justification died. "Escaping, quoting, CRLF handling and indentation leave this repo's maintenance surface" is untouched, and the evidence for it is better than the two dead issues ever were: #1779, #1882, #1572, #1660, #3257 and #3497 are all this repo paying, repeatedly, to maintain a YAML parser.

And the phase found live defects the dead ones did not cover. A differential across all 901 frontmatter-bearing tracked documents shows 99.1% exact key-set agreement and 2,177 of 2,178 scalars byte-identical — with every disagreement being the legacy parser wrong:

  • Block scalars are not parsed at all. commands/gsd/add-tests.md declares argument-instructions: |; the legacy parser returns the block indicator "|" as the value, discards the instruction text, and invents a phantom top-level key Example from inside the block body. A live defect in a shipped artifact.
  • A Unicode key is silently dropped.

§8.1 closes both by construction. That is the payoff this rule actually has, and it is recorded from measurement rather than inherited from a sentence.

8.2 Enumerations return correct values by construction — Enforced — Phase 5 (#3882)

Executor: scripts/lint-phase-enumeration-drift.cjs, wired into lint:ci. Partial: that guard covers the sentinel-filtering arm only; the #3357 canonical-first verification resolver is test-covered (tests/verification-status.test.cjs) with no guard behind it.

Question. What does an enumeration of phases, plans or artifacts return?

Rule. listPhaseDirs() and every phase/artifact enumerator returns sentinel-filtered ids. A caller that wants sentinels asks for them explicitly. An unfiltered enumeration does not exist to be forgotten.

Rule. Verification-file discovery has one resolver, canonical-filename-first, never alphabetical (#3357).

8.3 One implementation per rule — Shipped — test-covered — Phase 6 (#3883) + rungs 2-4 (#3897)

Update, #3987. Structural at the seams (src/commands.cts delegates to generateSlugInternal; runtime-slash.cts owns the marker reader) and covered by tests/core-utils.test.cjs and tests/runtime-marker-resolution.test.cjs. The slug half now has a guard: scripts/lint-slug-derivation-drift.cjs, wired into lint:ci, measured at 5 flags on the real tree (2 TRUE — scripts/qa-smell-ratchet.cjs, tests/planning-inspect.test.cjs — both fixed by routing through generateSlugInternal; 3 SANCTIONED, allowlisted with a reason each; 0 FALSE). No guard exists for marker re-derivation — a fifth hand-rolled readInstallRuntimeMarker copy would not fail the build.

What the slug guard does and does not catch, stated because an unqualified "guarded" would overclaim. It catches the copy-paste class — the shape all 11 deleted copies actually took — plus replaceAll, {1,}, \s*-wrapped classes, escaped ], a literal new RegExp(...), five trim spellings, .split().join(), and multi-line arguments. Two forms still evade, by decision: a re-derivation split across two statements via a temporary variable, and new RegExp built from a variable. Both require data-flow analysis, and a heuristic that guesses at it is how a guard becomes noisy; each is pinned by a negative test so the gap is visible rather than assumed closed. §8.3's status therefore stays Shipped — test-covered rather than advancing to Enforced: the wrong call site is now much harder to write, but it is not yet unrepresentable.

Rule. Every slug call site delegates to core-utils. resolveRuntime reads the install marker in one place with one cache. The Codex sandbox derives from the role's declared tool contract rather than a maintained subset map, and validate agents fails on semantic drift, not just on missing files.

Correction, 2026-08-26 (Phase 6, #3883) — I wrote this section as if it described decisions already taken. Measured against next @ 832dcbb75, it describes unbuilt work, and three of its statements are wrong. These are my errors, not inherited ones: I authored this ADR in Phase 0 (#3868/#3870) and stated these as rules without executing against the tree.

As written Measured
"13 inline copies across 5 modules" 11 copies across 7 files (commands.cts ×1, init.cts ×4, phase-id.cts ×2, phase-locator.cts ×1, workstream-name-policy.cts ×1, active-workstream-store.cts ×1, gsd2-import.cts ×1). Two of the thirteen I counted were an unrelated tokenizer regex. The file count in my first correction (2026-08-26, same day) was itself wrong — I corrected 13→11 without recounting files and repeated the same class of error I was correcting. The divergence itself is real and reproduced: on Cyrillic input the canonical generateSlugInternal (src/core-utils.cts:107) yields privet-mir while cmdGenerateSlug (src/commands.cts:200) yields ""; at the truncation boundary the copy leaves a trailing hyphen (#2849's regression, still live in the copy).
"resolveRuntime reads the install marker in one place with one cache" It reads no marker at all. src/runtime-slash.cts:132 resolves GSD_RUNTIME > config.runtime > 'claude', with no marker read and no cache. PR #3382, which I cited as prior art implementing this rung, is CLOSED and unmerged.
"The Codex sandbox derives from the role's declared tool contract… and validate agents fails on semantic drift" Both halves false. generateCodexAgentToml (bin/install.js) still reads CODEX_AGENT_SANDBOX[agentName] || 'read-only' — a hand-maintained subset map with a silent fallback. checkAgentsInstalled (src/agent-install-check.cts:156) checks file presence and manifest completeness only; it has no sandbox_mode or tool-contract assertion.

The shortFormToId rule below is accurate — no such tier exists on next, and resolveDependencyId (src/phase.cts:609) remains two-tier.

The guard roster names no §8.3 casualty. Its only §8.3-tagged row is local/no-adhoc-regex-escape, marked widened, not retired. Nothing is retired by this rule.

What this means for the phase: unlike §8.2, this section is genuinely unbuilt — the rewrites it asserts have not happened. It is a work list, not a conformance check, and it should be read that way.

ANSWER, 2026-08-27 (#3897, carrying Phase 6's remaining rungs) — rungs 2–4 built; re-measured, not inherited.

As corrected above Now measured
"resolveRuntime reads no marker at all… PR #3382 … CLOSED and unmerged" Fixed. resolveRuntime (src/runtime-slash.cts) now resolves GSD_RUNTIME > config.runtime > install marker > 'claude'. The four hand-rolled readInstallRuntimeMarker copies (model-resolver.cts + two in gsd-cursor-subagent-start.js + one in gsd-agent-isolation-guard.js) are consolidated into one module-level-cached reader owned by runtime-slash.cts; the marker's raw contents are routed through the same resolveRuntimeNameFromCandidates normalization the env rung uses, never trusted verbatim. Mined from #3382 per this ADR's own direction, not re-derived.
"generateCodexAgentToml still reads CODEX_AGENT_SANDBOX[agentName] || 'read-only'… checkAgentsInstalled has no sandbox_mode assertion" Fixed, with a qualification. CODEX_AGENT_SANDBOX (11 entries) and the silent || 'read-only' fallback are deleted; sandbox_mode now derives from each role's declared tools: frontmatter (Write/Edit present → workspace-write, else read-only). Emitted TOML is byte-identical for all 35 roles today: 17 roles that would derive broader are held at read-only behind an explicit, self-invalidating hold list (CODEX_SANDBOX_HOLDS) pending a decision on whether Codex enforces sandbox_mode. The 17th was surfaced only by review: the tools reader was single-line, so a YAML list-form tools: block returned just its first item and gsd-nyquist-auditor's declared Write/Edit were read as an absence. Deriving from a declaration you cannot parse is not deriving — the reader now handles both shapes, and the role joined the hold list rather than the count being left flattering. That conflict was surfaced rather than resolved silently: §8.3's own criterion asks both that the sandbox derive from the declared tool contract and that no role gain a broader sandbox, and those cannot both hold — a faithful derivation widens 17 roles that the deleted map never listed and that fell through its silent `
"The shortFormToId rule below is accurate — no such tier exists on next" Now built. resolveDependencyId (src/phase.cts) gained the third, in-phase-only tier recovered verbatim from the retired SDK lineage (sdk/src/query/phase.ts at 11918dcc3^): depends_on: ["01"] resolves to the sibling plan whose canonical id ends -01, first-write-wins on a same-phase collision. The display mapping at phase.cts:961 stays passthrough by design (#3785) — this tier is not routed through it.

The guard-roster line above is unaffected: no lint guard is retired by this rung, only the hand-maintained map and the four marker-reader copies.

Rule — consolidation carries invariants forward explicitly. A lineage consolidation may not delete an invariant along with the surface that held it. The shortFormToId tier existed in the retired SDK lineage; the surviving lineage never received it, the gap was recorded only in an archived changeset and a // KNOWN GAP: comment, and both went away with the surface (#3427). A parity note in an archived changeset is not a tracking mechanism.

8.4 Failure is a value — Shipped — test-covered — Phase 7 (#3884)

Structural for argv (src/command-arg-projection.cts — the spec object makes an undeclared shape a TypeError) and covered by tests/command-arg-projection.test.cjs / tests/pick-flag.test.cjs. No guard enforces the general "every fallible routine returns Result" rule.

Rule. Every routine that can fail returns Result. parseNamedArgs rejects unrecognized and positional tokens with a non-zero exit — it is called by agents that will drift again.

Rule. A count query returns 0, not "", and never "" with exit 0 (#3365).

8.5 No silent swallow, and no verdict manufactured from dropped data — Enforced — Phase 8 (#3885), guard by #3987

Executor: eslint-rules/no-swallowed-precondition.cjs, wired into the src/**/*.cts block and reached by npm run lint → lint:ci. Plus tests/intel.test.cjs, tests/review-parallel-lanes.test.cjs, and the #1884 regression test.

This entry previously said the rule was not guardable. That was wrong, and the way it was wrong is worth keeping. #3987 first ran a candidate detector — a swallowing catch co-occurring with an errno-retry-set check in the same function — got 26 flags, 0 TRUE, 26 FALSE, and concluded "not detectable at acceptable precision". An isolated reviewer overturned both halves of that conclusion:

  1. The false positives were uniformly CLEANUP verbs — rmSync (54), unlinkSync (43), closeSync (17), chmodSync (12). A swallowed cleanup is legitimate best-effort. A swallowed creation is a precondition silently lost, which is the actual #1884 shape. That is a reason to narrow the predicate, not to abandon it. Narrowed, measured in three stages: swallowing catch 911 → try-block calls a creation verb (mkdirSync/platformEnsureDir/openSync) 24 → enclosing function references a *_ERRNOS set 0 flags, 0 false positives. The naming key is empirically total: all 10 retry/tolerate sets in src/ follow it.
  2. "No positive control exists" was self-refuting. The pre-#3885 blob is available as a fixture, and this repo's own guards prove-it-can-fail on synthetic trees. The control now exists and works in both directions: the rule flags 0c43d853e^:src/planning-workspace.cts at line 210 — the line the fix commit's own message cites — and reports zero on the post-fix code and on all of src/**/*.cts.

The general lesson, which is why this is recorded rather than quietly amended: a high false-positive count is evidence the predicate is wrong, not evidence the rule is unguardable — and the first negative result is especially seductive when it is also the answer that means less work.

The guard immediately found a live defect of the same class. src/capability-lock.cts swallowed a mkdirSync on the lock directory; acquireLock then classified the follow-on failure as code !== 'EEXIST' → return null, so a real EACCES/EROFS became openSync(lockPath,'wx') failing ENOENT — not EEXIST — and a fatal filesystem error was laundered into "lock unavailable". Same defect as #1884, different laundering target. Fixed in #3987 the way #3885 fixed #1884: the creation failure propagates.

Known gap, deliberate. That defect's errno classification is an inline literal, not a named *_ERRNOS set, so the strict rule does not catch its shape. Broadening to any err.code comparison raises it to 3 flags with 2 false positives — capability-lock.cts:408 (the deliberate EEXIST steal protocol) and commonjs-marker.cts:131 (which returns a distinct, documented outcome). The rule stays strict and the gap is recorded here, rather than trading a trustworthy guard for a noisy one.

Rule. A swallowed catch may not fold a fatal errno into a retry set. A synthesis step may not emit its artifact when its inputs failed (#3352). A derived conclusion may not be reported as authoritative when the derivation dropped input it could not resolve (#3427).

Rule. searchJsonEntries / matchesInValue restore the MAX_JSON_SEARCH_DEPTH = 48 recursion bound lost in the ADR-0174 consolidation. src/intel.cts recurses through arrays and objects with no depth parameter at all, so deeply nested intel JSON overflows the stack. This has no issue of its own — it is tracked HERE and nowhere else.

8.6 The state transaction — Enforced (structural) — Phase 1 (#3871)

Executor: the transaction type in src/state-transition.cts makes a snapshot-less construction unwritable, backed by scripts/lint-state-write-path-drift.cjs wired into lint:ci.

Question. What may apply the STATE.md sync-and-preservation pipeline, and under what precondition?

Owner. src/state-transition.cts · the transaction type, constructed by open() or rebuild().

Rule — the snapshot is mandatory in both constructors. A transaction cannot be constructed without the pre-write frontmatter snapshot. An absent snapshot is a construction failure, per Decision 2's first row — not a runtime no-op. Today the executor reaches a declared preserve-always row with a null snapshot and skips it silently, which is how a curated progress block is zeroed by verbs that have nothing to do with progress (#3756).

Rule — the two constructors differ in preservation, not in snapshot. open() applies preservation. rebuild() does not. Both carry the snapshot, because §8.7's reporting needs it regardless of whether preservation ran.

Rule — rebuild() is the typed expression of the sanctioned exceptions. ADR-3408 §8.3's closed list of commands whose contract is to let the body win — cmdStateSync (#905: state sync re-derives frontmatter from the body) and /gsd-health --repair's REGENERATE_STATE (a factory reset) — call rebuild(). They are not debt: a guard reporting them is reporting correctly, and a change that removes one is a regression. Adding to the list is an amendment to ADR-3408 §8.3.

Consequence for the guard. The write-path drift baseline's two sanctioned-permanent entries are retired, and the baseline file with them: the exception becomes a constructor the type system names, not a ratcheted string match. scripts/lint-state-write-path-drift.cjs keeps every check the type does not make unrepresentable.

Amendment, 2026-08-25 (Phase 1, #3871) — this paragraph originally read "keeps only its raw-write check (fs.writeFileSync against the state path)", and that sentence was wrong on both halves. Verified against next while implementing: the guard contained no raw-write check at all, and it contained four checks besides the one §8.6 retires — policy-dispatch drift, unimplemented policies, unstripped content writes, and prompt-layer state writes. None of those is named by §8.6 and none is made unrepresentable by the transaction type, so all four are retained; the raw-write check is net-new.

It is kept — rather than dropped along with the sentence that mis-described it — because of what it covers, not because §8.6 named it. writeStateMd acquires the STATE.md lockfile before it writes; a raw fs.writeFileSync against the state path acquires nothing. That is not a preservation bypass, it is a lock bypass, and lost-update is the #500/#905/#1230 family. Every other route into the file is now closed by construction — an open() transaction preserves, a rebuild() transaction is the typed exception, and a re-assembled composition is caught by the axis above — so the raw write is the one remaining reachable path that nothing covers. Zero occurrences to date is not the test; reachability and blast radius are.

The tempting counter-precedent does not apply. ADR-3408 §8.6's amendment deleted the clear preservation policy when it turned out no row used it and no executor existed — contract names X, X does not exist, delete the naming. clear was dead vocabulary in a closed enum: deleting it removed a way to express something meaningless. This is coverage of a reachable path. The two look alike and are not the same shape.

The retired axis was the seam-bypass scan, and only half of it was redundant. Its writeStateMd( arm is genuinely replaced by the type and is gone with its ratchet. Its composition-bypass arm — a new call site re-assembling syncStateFrontmatter + applyPostSyncPreservation instead of routing through the owned composition, which is ADR-3408 §8.3's rule and the exact shape #3469 found live in cmdPhaseComplete — is not replaced by the type, which gates one parameter of one function and nothing more. It is retained, made terminal rather than ratcheted, and carries its own reason code. Decision 6 sanctions retiring a guard the change makes redundant; deleting this arm would have been a silent coverage regression dressed as a guard-count win, which is precisely the Goodhart outcome Decision 6 exists to prevent.

8.7 What a command reports it wrote — Shipped — test-covered — Phase 2 (#3872)

Structural via reconcileReportedFields (src/state.cts) and covered by tests/state.test.cjs. No standing guard.

Question. Which fields appear in a command's updated array?

Owner. src/state.cts · the transaction diff.

Rule. updated is derived by comparing persisted frontmatter against the transaction's snapshot. A field appears in updated iff its persisted value changed. This restates ADR-3408 Decision 4 and is not new; what is new is that it holds for every field.

Rule — no field is excluded by classification. reconcileReportedFields's progress exclusion is deleted, not relocated. It exists to dodge the #1264 regression where progress was reported as updated on every write that merely preserved it — but under a real diff a preserved field is unchanged and does not appear, so the diff fixes #1264 by construction. The exclusion's only remaining effect is to suppress genuine changes (#3743).

Rule — reporting granularity is the dotted leaf path. progress.total_plans, not progress. This is already the output convention (#3818, #3743).

Consequence. "Reported but not persisted" (#3351) was made unrepresentable by ADR-3408 Phase 3. "Persisted but not reported" (#3345, #3743, #3818, #3835, #3836) survived through the exclusion list, and this rule closes it.

Prior art — the mechanism is already proven at one site. fix(#3685) (7b2f2c89f, src/phase.cts) replaced phase complete's fs.existsSync-derived roadmap_updated / state_updated flags with flags derived from the transaction's content diff, so a no-op write stopped being indistinguishable from a successful one. That is this rule at file granularity. Phase 2 applies the same derivation at field granularity in reconcileReportedFields, and should be read as generalizing #3685 rather than re-deriving it.

8.8 The STATE.md schema — Enforced — Phase 3 (#3873)

Executor: scripts/gen-state-md-docs.cjs --check in lint:generated-sync, plus tests/docs-state-md-locale-parity.test.cjs. scripts/lint-state-field-drift.cjs is retained under a different contract — see the Guard roster.

Question. Where is the set of STATE.md keys, their types, enums, cardinality, preservation policy and accepted parse shapes declared?

Owner. One .cts schema module. FIELD_CLASSIFICATION becomes a projection of it, not a sibling of it.

Rule — generated artifacts. gsd-core/templates/state.md and docs/reference/state-md.md are generated and committed, with a CI drift check. This follows the repo's established pattern (gen-inventory-manifest.cjs, gen-section-manifest.cjs, gen-context-index.cjs, gen-health-docs.cjs).

Rule — the locales. The schema-derived tables (field reference, status values, cardinality) and the section skeletons are generated into all five locales — docs/reference/state-md.md and its ja-JP, zh-CN, ko-KR, pt-BR siblings. Column headers come from a per-locale string table; prose sections stay hand-translated. A locale missing a section the schema declares fails CI rather than going unnoticed.

Why this rule is normative rather than a nicety: on next @ e40e9670f the English reference is 225 lines and all four translations are 203. The English ### Status lifecycle (ADR-2207) section is absent from every translation — and it documents the status enum whose clobbering is #3853. The drift is not hypothetical and it is not cosmetic.

Rule — parsers are checked, not generated. Parse functions stay hand-written. A test asserts they accept exactly the shapes the schema declares — no more, no fewer. Generating a parser is a substrate decision this epic does not take; the shape-proliferation family (#3784's three spellings of "plan N of M") is closed by declaring the accepted set, not by emitting the matcher.

Consequence for the guard. Field drift between the schema, the template and the docs stops being representable, because the artifacts are generated from the schema and a drift check refuses a stale one.

Amendment, 2026-08-26 (Phase 3, #3873) — this paragraph originally instructed deleting scripts/lint-state-field-drift.cjs (805 lines) on the grounds that it detected that drift. Verified against next: it does not, and never did. That script's own header declares it the drift guard for the STATE.md field-extraction fallback chain — epic #3180, issue #3187, ADR-3180 Decision 4 §7.7. It detects re-derivations of the "prefer the frontmatter scalar, else fall back to the body field" coercion ladder across src/** and the prompt layer. It contains no reference to FIELD_CLASSIFICATION, to the template, or to the reference docs. Nor does any other script own field/template/docs drift — the full scripts/ inventory carries none. The instruction named a guard surface that did not exist.

The guard is therefore retained, and tests/lint-state-field-drift-retained.test.cjs pins that decision so a future reader does not delete it on this ADR's earlier word. A key-set schema makes a key-set disagreement unrepresentable; it does nothing about a code-shape re-derivation of a coercion ladder. Those are orthogonal, and Decision 6 sanctions retiring a guard the change makes redundant — which this one is not.

This is the second guard-retirement claim in this ADR to rest on a wrong premise, after §8.6's (see its own amendment). Both described a guard surface their author believed existed. The pattern is worth naming: a retirement claim in this ADR is a hypothesis about a guard's contents, and Decision 6's ledger requirement should be read as obliging the implementing phase to verify that hypothesis before acting on it — not merely to count the result.

8.9 Each subsumed child is driven fail-first — Enforced (prospective) — every phase, ledger completed by #3951

Executor: scripts/lint-fix-has-regression-tests.cjs, wired into lint:ci. Read the scope precisely: it fires on new fix(#NNNN) commits; it does not assert that the 19 children listed below are covered.

Correction, #3987 — and then a correction OF that correction, which is the more instructive one.

The 2026-08-28 amendment said "17 of 19; #3364 and #3812 have none". #3987 first "corrected" that to 19 of 19. An isolated reviewer showed the correction was itself false, and false in a worse way than the original:

The original claim was CORRECT for the predicate it stated. #3987 silently swapped the predicate from cited to covered and then declared the count fixed. #3812 appears in zero files under tests/ — tests/gen-state-md-docs.test.cjs:374 is a generic generated-docs test naming no issue. Changing what a word means in order to make a ledger read better is a worse failure than the miscount it claimed to repair, and it happened inside an amendment whose subject is that a text match is not a fact.

The measured truth, by predicate:

predicate count detail
cites its issue number in tests/ 18 of 19 #3812 does not. #3364 does — tests/runtime-marker-resolution.test.cjs:107, asserting :115-119 (the earlier claim that it did not was the one genuine miscount).
behaviorally covered 19 of 19 #3812 by tests/gen-state-md-docs.test.cjs:374

§8.9 asks for a test naming each child, so 18 of 19 is the number that answers it. The two predicates are not interchangeable and must not be reported as one.

#3812 is additionally PARTIALLY DELIVERED and has been RE-OPENED (2026-08-28). The shipped fix declares cardinality for FRONTMATTER keys (current_phase/current_plan = optional, docs/reference/state-md.md:89,91); its stated acceptance was the ## Current Position body section, and :196-208 still has no normative single-valued/overwrite sentence and no pointer to ## Performance Metrics for history. The first draft of this entry said it was "flagged here so it can be re-opened" and then did not re-open it — a note is not an action, so the issue is now actually open again with the evidence attached.

Rule. #2986, #3372, #3364, #2540, #3231, #3349, #3360, #3358, #3365, #3356, #3352, #3427 — and the STATE.md set #3756, #3743, #3818, #3835, #3836, #3853, #3812 — each get a failing-first regression test driven green via gsd-test, plus a behavioral identity test asserting at the consumer's output per ADR-3180 Decision 4(b). A structural guard alone would not have caught these.

Rule. Following ADR-3180 §6's precedent, each phase names the issues it subsumes and records the evidence the symptom is gone; it does not unilaterally close them. Whether a subsumed issue is closed, re-scoped, or kept open for its own regression test is the maintainer's call at merge time.

Consequences

Breaking changes.

  • parseNamedArgs becomes strict (§8.4). A previously-silent typo now exits non-zero. Intended — the silent path corrupts STATE.md — but it changes observable CLI behavior. Needs a changeset and a Changed entry.
  • --pick returns 0 rather than empty for absent counts. Anything distinguishing empty-from-zero changes.
  • Frontmatter serialization output may differ cosmetically once a real parser owns it (§8.1). Round-trip values must be identical; golden fixtures will need regeneration.
  • updated arrays grow (§8.7). Callers that assumed progress never appears will now see progress.* leaves when it genuinely changed. This is the fix, not a regression.

Guard ledger, Phases 1–3.

Phase Guard Δ
1 (§8.6) lint-state-write-path-drift.cjs — seam-bypass writeStateMd( arm and its whole ratchet apparatus retired, baseline file deleted; composition-bypass arm retained and made terminal; raw-write check added net-new −665 lines, −1 file, +1 check — net shrink
2 (§8.7) none — the reporting diff makes no guard redundant 0, stated rather than omitted
3 (§8.8) lint-state-field-drift.cjs — retained, see §8.8's amendment; a generated-artifact drift check and a locale-parity check are added +2 checks, 0 retired — this phase GROWS
— lint-planning-snapshot-bypass-drift.cjs — extended to the write side by ADR-3180 Amendment 8 growth, declared

Net across the set: one guard retired, one increase recorded honestly. The increase belongs to a concurrent lane under a different ADR and is listed here so the accounting is complete rather than flattering.

Everything else is internal. Deleting an inline copy in favour of the canonical implementation changes behavior only where the copy was already wrong, which is the defect being fixed.

Alternatives considered

  • Keep fixing them individually. This is the status quo and is what produced the queue: 10 of the 30 surveyed issues are re-filings of an already-closed fix. Rejected on evidence.
  • Add more drift guards. Tried, at scale — 65 rules and guards. Decision 5's two demonstrations show a guard live at error, aimed at the exact defect class, that the shipped defect walked past. Detection matches the fingerprint of the last bug; it cannot cover a class. Rejected.
  • Keep the hand-rolled YAML to preserve the near-zero-dependency posture. The posture is defensible for an npx-distributed CLI, but the cost was never priced: owning the parser means owning escaping, CRLF, quoting and indentation permanently. §8.1's vendoring rule preserves the posture and retires the dialect. Rejected as stated; preserved as amended.
  • Adopt remark/mdast for the markdown half. Rejected: src/markdown-sectionizer.cts and src/markdown-table.cts already exist as canonical seams. The markdown problem is not a missing seam, it is a non-mandatory one — #3426/#3239 sit outside the enforcing glob. A coverage fix, not a substrate decision.
  • Fold derivation authority into this ADR. Rejected. ADR-3180 §7.5 already locks the sentence this rule generalizes, and splitting the derivation contract across two ADRs is the exact drift shape both epics exist to stop. It lands as ADR-3180 Amendment 8.
  • One epic per family (three epics). Rejected by the maintainer: a single owner prevents the three mechanisms from being fixed against each other while the work is in flight.

Software laws applied

  • Greenspun's Tenth Rule — the hand-rolled YAML dialect (§8.1) and the FIELD_CLASSIFICATION table's five accretions are both a general-purpose language being reinvented inside the application. §8.6's closed guard vocabulary exists so the table does not become an interpreter.
  • Goodhart's Law — Decision 6's ledger rule. "Net guard count falls" is a measure one phase away from becoming a target; the defence is that an increase must be declared in the same ledger.
  • Postel's Law — Decision 2's bright line. Strict on internal system-to-system boundaries where both ends are controlled; liberal on user documents.
  • Hyrum's Law — the updated array's growth under §8.7 is an observable-behavior change even though it is a bug fix, and is called out under Consequences rather than assumed benign.

Cross-references

  • ADR-3180 — read-side derivation ownership; Decision 4 (a)–(e) adopted verbatim; Amendment 8 owns derivation authority.
  • ADR-3408 — STATE.md write path; §8.3's sanctioned-exception list is normative for §8.6.
  • ADR-2143 / ADR-1372 — the document-parsing layer beneath this one.
  • ADR-3212 — the lexical layer beneath that.

Guard roster

Guard Status under this ADR
scripts/lint-state-write-path-drift.cjs retained, shrunk (§8.6) — seam-bypass writeStateMd( arm and its ratchet retired at Phase 1; composition-bypass arm retained and made terminal; raw-write check added net-new. See §8.6's amendment.
scripts/lint-state-field-drift.cjs RETAINED — the Phase-3 retirement instruction rested on a wrong premise about what this guard does; see §8.8's amendment. It guards the ADR-3180 §7.7 / #3187 coercion ladder, which no schema makes unrepresentable.
scripts/lint-vendored-deps.cjs not reusable as-is — generalized to a manifest by §8.1; see the correction below
local/no-external-require-in-bin reused as-is; enforces §8.1's packaging rule
local/no-adhoc-markdown-parsing DONE (#3951) — self-gating filename check fixed (it made the glob widening inert) and reach extended to tests/**/scripts/**; 80 violations resolved. #3426/#3239 were not closed by this — the widening structurally could not reach them — but both closed separately via #3977, which rerouted the test's parsers onto the ADR-2143 seam rather than adding detectors. See the follow-up in the ledger amendment.
local/no-adhoc-regex-escape DONE (#3951) — widened to MemberExpression with a .source-aware exemption keyed on the property; 18 safe sites exempt, 3 provenance-exempt, 6 real findings marked.
scripts/lint-frontmatter-scalar-broad-grep.cjs NOT a casualty of §8.1 — retained. See the correction below.
scripts/lint-phase-enumeration-drift.cjs RETAINED — verified at Phase 5 and not retired. #3882 migrated 2 of 23 exemptions; readdirSync is a raw Node API no seam makes unwritable, and 21 exemptions remain wired. See the ledger amendment.

Correction, 2026-08-26 (Phase 4, #3881) — two rows in this roster were wrong, and they are the FOURTH and FIFTH wrong premises in this ADR. Both were caught by applying the rule recorded in §8.1's amendment — a factual claim in this ADR is a hypothesis until the implementing phase executes it — on its first use.

lint-frontmatter-scalar-broad-grep.cjs is not a casualty of §8.1 and is retained. It has nothing to do with the TypeScript parser. It is DEFECT.FRONTMATTER-SCALAR-BROAD-GREP (#586 / PR #650): it scans fenced bash / sh blocks in gsd-core/workflows/*.md, agents/*.md and commands/**/*.md for shell grep "^key:" invocations that read a frontmatter scalar from the whole markdown body instead of scoping to the frontmatter block — the failure that once yielded passed+gaps_found+human_needed instead of passed and blocked a passing phase. The prompt layer does not call our parser; it runs grep in a shell. Vendoring js-yaml makes a shell grep no safer, so retiring this guard would be a pure coverage loss dressed as a guard-count win — the Goodhart outcome Decision 6 exists to prevent, and the third time in this epic that a retirement claim has pointed at a guard whose actual contents it did not describe.

lint-vendored-deps.cjs cannot be "reused as-is." All four of its checks name re2js literally, as does its REFRESH_COMMAND. Vendoring a second package by pasting a second hardcoded block would violate §8.3, "one implementation per rule", inside the epic that exists to end that. Phase 4 generalizes it to a table-driven manifest, preserving re2js's four checks unchanged.

A further wrinkle the roster did not anticipate: js-yaml ships no type declarations and @types/js-yaml is not installed, so the re2js precedent's verbatim .d.cts copy has no upstream to copy from. src/vendor/js-yaml.d.cts is hand-authored, declaring only load, dump, FAILSAFE_SCHEMA and YAMLException — which also makes anchors, aliases and custom types unreachable from typed code, a capability gate rather than a shortcut. It is therefore excluded from the byte-compare and pinned by a test instead.

LEDGER AMENDMENT, 2026-08-27 (#3951) — B6's "net guard count must fall" is amended, not achieved, and the epic's own prescribed fix for one widening was a no-op. This is the SIXTH wrong premise recorded in this ADR, found the same way as the other five: by measuring before building.

The count rose, and the attribution is the point. Measured 66ad3d625 (epic filing, 2026-08-14) → origin/next: scripts/lint-*.cjs 38 → 44, eslint-rules/*.cjs 24 → 25. 62 → 69, delta +7. But five of the seven are unrelated to this epic — three from #3753, plus #3582, #3409, #3619 — and one (lint-mutation-test-derivation-drift.cjs) was added BY a phase of it, #3881. The epic did retire one thing, sub-file: #3884 removed Detector A from lint-unreachable-guard-drift.cjs, ledger "net: −1 detector, 0 added", because §8.4's non-zero --pick exit made the forbidden idiom correct.

Every named casualty is load-bearing, and no dead guard exists. lint-state-field-drift.cjs and lint-frontmatter-scalar-broad-grep.cjs already carry retractions above; #3873 additionally landed tests/lint-state-field-drift-retained.test.cjs, which fails on deletion. lint-phase-enumeration-drift.cjs was verified at Phase 5 and retained: #3882 migrated 2 of 23 exemptions and its own commit message names six call sites that cannot migrate, because readdirSync is a raw Node API no seam makes unwritable — 21 exemptions remain wired. A sweep of all 22 rules and all scripts/lint-*.cjs found no provably dead guard. There is therefore no honest way to make the count fall; forcing it down would trade real coverage for a number, which is precisely the Goodhart outcome Decision 6 exists to prevent.

The B6(b) instruction as written was inert. eslint-rules/no-adhoc-markdown-parsing.cjs short-circuits create() to {} unless the path matches /(?:^|\/)src\/[^/]+\.cts$ — it self-gates on its own filename. Widening only the files: glob, which is what B6 says to do, ships a rule that still returns {} for every new path. Both halves had to move. The same regex was flat-only, so 28 .cts files in src/ subdirectories sat inside the registered src/**/*.cts glob and were silently skipped — a latent hole (0 violations there today), fixed by adopting the correct form already present at require-subprocess-timeout.cjs:196.

And #3426/#3239 are NOT reachable by that widening. tests/package-legitimacy-gate.test.cjs yields zero violations even with the gate bypassed: its hand-rolled scans are real but built from line filters and split('|'), not the regex-literal fingerprints this rule detects. The roster row above tracked them against the wrong mechanism.

FOLLOW-UP, 2026-08-28 — both are now CLOSED, and not by the route this amendment predicted. The paragraph above concluded they "need new detectors". That was one valid path and it is not the one taken: #3977 rerouted the test's table parsers onto the ADR-2143 seam, so there is no longer any ad-hoc parse for a detector to find. Removing the violation beat teaching the rule to see it — cheaper, and it leaves one parser instead of two. Recorded because the measurement here was right (the widening genuinely could not reach them) while the prescription was not the best available, and a reader should not build new detectors on the strength of it.

What the widenings actually cost and bought. no-adhoc-regex-escape widened to MemberExpression: 27 sites by AST walk — 18 safe X.source (the "~10" estimate was an undercount), 3 provenance-exempt _SOURCE constants reached through required modules, 6 real findings. no-adhoc-markdown-parsing widened to tests/** and scripts/**: 80 violations across 43 files, 70 routed through the existing seams and 10 suppressed. One of the 80 was a test that passed for the wrong reason — config-field-docs.test.cjs asserted notEqual(cell, '600') against the Type column rather than Default, so a guard against workflow.subagent_timeout regressing to the seconds default could never fire.

The rule Decision 6 should carry going forward: a guard ledger is a claim about COVERAGE, not a claim about COUNT. "Net count must fall" is measurable and wrong; "every guard is reachable, and each retirement names what makes its defect unrepresentable" is the property that was actually wanted. B6 is satisfied against the second reading and is recorded as amended against the first.