* docs(#3881): answer §8.1's open question and correct three wrong premises ADR-3473 §8.1 carries a blocking open question with a forcing function: it must be answered before any implementation PR for the rule opens. Answered here as (a), a string-coercing adapter, with the measurement that settles it. The sequencing note bet that §8.8's schema would make (b) tractable. Measured against merged reality it does not: only 33 of extractFrontmatter's 78 non-test call sites read STATE.md, and two of the five compensating mechanisms §8.1 lists survive real types, leaving ~31 lines across 3 call sites as the actual prize. Also corrects three claims verified false while answering it. §8.1's justifying sentence names #3349 and #3360 as defects a real parser would fix; both are already fixed on next, confirmed by executing the compiled parser rather than reading it. The guard roster calls lint-frontmatter-scalar-broad-grep.cjs an expected casualty of this rule, but it guards shell grep idioms in workflow bash fences and never touches our parser. The same roster calls lint-vendored-deps.cjs reusable as-is; it is hardcoded to re2js throughout. The last two were caught by applying the rule this amendment records -- a factual claim in this ADR is a hypothesis until the implementing phase executes it -- on its first use. Refs #3881 * docs(#3881): record that §8.1's fork is ill-posed and (a) is not implementable An adversarial pass on the Phase 4 design established by execution that extractFrontmatter is not a YAML parser but a line-oriented scanner whose output is a function of raw source text. Four spellings of the same value collapse to one js-yaml tree but produce four distinct legacy strings, one of them mangled. No adapter over a tree can choose among outputs the tree does not distinguish, so fork (a) -- keep a string-coercing adapter so the existing contract holds -- cannot be built. For any document with a non-scalar value, (a) collapses into (b); about 26 percent of frontmatter-carrying documents have one. Also records three design defects and one new attack surface, all confirmed by execution: catching a parse failure and returning {} would delete the frontmatter block on the next write at eight call sites that conflate empty with unparseable; an empty value yields null where legacy yields {}, and reconstructFrontmatter omits null-valued keys, so the shipped state template's empty progress key would vanish; the #1882 truncation probe is parseYamlRegion itself rather than a pre-parse heuristic, so it cannot both stay unchanged and survive that deletion; and FAILSAFE_SCHEMA still resolves aliases, expanding seven lines to 22.8 MB. The rule is not deferred. The measurement is the deliverable and the re-scoping is recorded as an open question with a forcing function, per section 8's own rule. Refs #3881 * test(#3881): failing-first rows for block scalars, unicode keys and the missing #3594 matrix Creates tests/feat-3594-parser-adversarial-frontmatter.test.cjs, the file the fixture README instructs contributors to register fixtures in but which never existed. Section C: table-driven ownership check over tests/fixtures/adversarial/frontmatter/ so a fixture with no matrix entry fails loudly; six existing fixtures (duplicate-keys, crlf-mixed, unclosed-block, unicode-keys-and-values, null-byte-value, huge-bounded) each get the invariant its README states. B1 blockScalarValueIsNotTheBlockIndicator: parsing commands/gsd/add-tests.md must give argument-instructions the instruction text, not the literal '|'. RED today. B2 blockScalarDoesNotInventATopLevelKey: same parse must not produce a top-level Example key scraped from inside the block body. RED today. B3 unicodeKeyRoundTripsAsIs: the 相 key in unicode-keys-and-values.md must survive parsing; today it is silently dropped. RED today. Refs #3881 * chore(#3881): vendor js-yaml and generalize the vendored-deps guard to a manifest Packaging step for ADR-3473 §8.1: makes js-yaml available to gsd-core/bin/** without promoting it out of devDependencies (promoting broke every installed tree, #3496). gsd-core/bin/lib/vendor/js-yaml.cjs is a verbatim copy of node_modules/js-yaml/dist/js-yaml.js (the self-contained UMD dist bundle, not index.js), exposing load/dump/FAILSAFE_SCHEMA/YAMLException with zero require() calls of its own. src/vendor/js-yaml.d.cts is hand-authored, not copied, because js-yaml ships no upstream .d.ts and @types/js-yaml is not installed. It is deliberately narrow, declaring only the four symbols in use, so anchors/aliases/custom types/loadAll are unreachable from typed code -- a compile-time enforcement of ADR-3473 §8.1's refusal to expand alias resolution for security reasons. Because it has no upstream counterpart it is excluded from the byte-compare. scripts/lint-vendored-deps.cjs is refactored from a script hardcoded to re2js into a table-driven VENDORED manifest (one row per package: upstream/vendored .cjs paths, optional .d.cts paths, twin kind upstream-verbatim vs hand-authored) so a second vendored package does not require a second hardcoded check block, per ADR-3473 §8.3 'one implementation per rule'. The four existing re2js checks (vendored .cjs vs node_modules, vendored .d.cts vs node_modules, src/vendor twin vs bin-side twin, devDependency version pin vs installed version) are preserved unchanged; verified pass/fail identical before and after the refactor, and the guard's ability to fail was re-proven with a deliberate one-byte append to both re2js.cjs and js-yaml.cjs, then restored. docs/INVENTORY.md and docs/INVENTORY-MANIFEST.json (via gen-inventory-manifest.cjs --write, run after build:lib) register vendor/js-yaml.cjs. gsd-core/bin/lib/vendor/README.md documents both vendored packages and the two twin kinds. Refs #3881 * feat(#3881): parse .planning frontmatter with the vendored js-yaml ADR-3473 §8.1: extractFrontmatter's read path is no longer a hand-rolled line scanner. parseYamlRegion, escapeDoubleQuoted, unescapeDoubleQuoted and parseQuotedScalar are deleted (not patched); parsing now goes through the vendored js-yaml (./vendor/js-yaml.cjs) under { schema: FAILSAFE_SCHEMA, json: true }. Everything js-yaml does not do is layered on top, in one place, carrying the seven design-doc consequences: 1. Empty value: a null js-yaml value is coerced to {} (matching legacy's own empty-value contract) so reconstructFrontmatter — which omits null-valued keys — still round-trips a bare `key:` line instead of deleting it. Verified live: progress: with no value survives parse -> reconstruct -> re-parse. 2. Unparseable no longer collapses to a bare {}: a new FRONTMATTER_UNPARSEABLE Symbol (exported), keyed exactly like the existing #3257 FULL_LINE_COMMENTS channel, is carried on the {} returned for malformed/refused YAML. Invisible to Object.keys/entries/JSON.stringify/for-in, so the 70 call sites that never inspect it are unaffected; wiring the 8 hasFrontmatter sites to consult it is a separate change, not done here. 3. Non-scalar object-list items (the four spellings of `- test: a b` that js-yaml collapses into one tree shape) are rendered as a canonical `key: value[, key2: value2]` string per item, keeping the existing array-of-strings value SHAPE. A full corpus differential over all 1702 tracked markdown files found 11 residual divergences from the legacy parser (enumerated in the PR/report), most of them the parser now being MORE correct (a dropped quoted top-level key, the block-scalar/phantom-key defect, a dropped Unicode key). 4. The #1882 truncation probe still runs the one real parser, but derives its key count from js-yaml's own thrown error and mark.line when the whole region doesn't parse cleanly (the dominant real truncation shape: fence opened, well-formed keys, no closing fence). Verified against both the clean-parse and the exception-fallback path. 5. The #3257 comment channel now attributes each pending column-0 comment against js-yaml's own parsed top-level key list (matched by literal key text, in document order) instead of the legacy ASCII-only key regex, so a comment above a Unicode key attaches correctly. 6. Anchors, aliases and merge keys are refused outright (a raw-text pre-scan, since FAILSAFE_SCHEMA still resolves them) — corpus occurrences today: zero. A 7-line billion-laughs fixture is verified refused rather than expanded. 7. A literal U+0000 is swapped for a private-use sentinel before the parse and restored in every resulting string afterward, since js-yaml rejects NUL unconditionally under every schema. escapeDoubleQuoted is deleted and reimplemented via js-yaml's dump() (forced double-quoted style), with control-char hex escapes lowercased to keep serialized output byte-stable (#1779 emitted lowercase); it keeps its exported name and signature for its two other call sites (commands.cts, runtime-artifact-conversion.cts), which need no change. frontmatterDeepEqual, the comment channel, sliceTopLevelFrontmatterSegments, regenerateFrontmatterKey's guard, noOpObjectListSetError and parseMustHavesBlock are all unchanged — retiring them is fork (b) and is not this phase. Refs #3881 * fix(#3881): quote template placeholders and preserve unparseable frontmatter SECURITY.md/UI-SPEC.md/VALIDATION.md wrote frontmatter placeholders as bare {N}/{phase-slug}/{date}, which is valid YAML flow-mapping syntax under the vendored js-yaml parser, not the literal placeholder text intended. Quote them so they parse as strings. Wire the FRONTMATTER_UNPARSEABLE Symbol (exported but unused) at the 8 call sites in state.cts/state-transition.cts that compute hasFrontmatter via Object.keys(extractFrontmatter(...)).length > 0 and reassemble the document without a frontmatter block when false. That check conflated 'no frontmatter' with 'unparseable frontmatter' (both parse to {}), so a document with a merge-conflict marker or refused alias in its frontmatter had that block silently dropped on write. Each site now preserves the exact raw bytes stripFrontmatter removed when the marker is set, leaving the genuinely-empty case unchanged. Refs #3881 * test(#3881): consequence and boundary coverage for the js-yaml migration Rows: A1 emptyValuedKeySurvivesAWrite, A2 unparseableDocumentKeepsItsFrontmatterBlock, A3 unparseableIsDistinguishableFromEmpty, A4 nonScalarValuesCanonicalize, A5 truncationProbeStillFiresOnAnOpenFence, A6 commentsStayOnTheirOwnKey, A7 anchorsAndAliasesAreRefused, A8 aliasExpansionCannotExhaustMemory, F1 UNTERMINATED_KEY_THRESHOLD boundary, F2 alias/nesting refusal bound, F3 frontmatter size boundary (huge-bounded.md + larger). Adds tests/fixtures/adversarial/frontmatter/anchor-alias-bomb.md and its entry in the feat-3594 fixture matrix. Refs #3881 * docs(#3881): document the vendored parser, correct a stale rationale, add a vendoring how-to Refs #3881 * docs(#3881): correct the frontmatter glossary entry Two errors in the entry as first written: it named parseYamlRegion as part of the read path when that function is deleted, and it recorded the eight hasFrontmatter call sites as unwired follow-on work when they were wired in e35ac2a2c. Also records the scope caveat that the CLI write path rebuilds the frontmatter block independently, so the marker binds at the transform layer. Refs #3881 * docs(#3881): record the semantic-migration decision and the counted guard ledger The maintainer chose the full semantic migration over splitting the rule into its own epic or patching the scanner, so section 8.1 is answered as "the fork was ill-posed and the migration is semantic" rather than as (a) or (b). Also replaces the pre-implementation guess that this phase would shrink the guard surface with the counted result: excluding vendored third-party lines the hand-maintained surface is net +307, and frontmatter.cts grew by 68 lines despite four functions being deleted, because the compatibility layer over js-yaml is larger than the scanner it replaced. Section 8.1's stated benefit is therefore not delivered as written; what improved is the kind of code maintained, not the amount. Decision 6 requires recording that rather than netting it away. Refs #3881 * chore(#3881): changeset for the vendored YAML parser migration Refs #3881 * test(#3881): golden parity, round-trip property and packaging coverage Refs #3881 * fix(#3881): refuse anchors structurally and fold in review findings ADR-3473 §8.1 review findings, addressed inline: Finding 1 (BLOCKER): refuseAnchorsAndAliases was a raw-line regex that matched only the bare-key spelling (key: &x). A quoted key ("a": &x), a flow mapping ({b: &x}) and a flow sequence ([&x, *x]) all define/use the SAME anchor mechanics while never matching that line shape, so the exact expansion the guard exists to stop went straight through unrefused (a 303-byte quoted-key bomb expanded to ~35.8MB). Replaced with js-yaml's own `load` `listener` callback, which reports `state.anchor` for every event belonging to an anchored node in every spelling, and throws from inside the callback to abort before any expansion (~1-2ms vs full expand-then-discard). A merge key with an alias is still refused (merge always requires a previously anchored node, so the alias itself trips the listener); a bare merge key with NO alias is no longer separately refused, documented as intentional: FAILSAFE_SCHEMA never resolves `!!merge`, so it carries no expansion risk. Table-driven tests added for all four bypass spellings + merge key, plus a quoted-key-spelled billion-laughs fixture registered in the adversarial matrix and README. Finding 2: src/vendor/js-yaml.d.cts's docblock falsely claimed anchors/ aliases were "simply UNREACHABLE from typed code" through the twin. Corrected to state the truth: anchor/alias resolution is document-level `load` mechanics reachable through exactly the declared surface, and refusal is enforced at RUNTIME (Finding 1's listener), not by the type surface. Finding 3 (MAJOR): the null-byte sentinel (U+E000) round-trip was non-injective — restoreNullBytesDeep rewrote every U+E000 in the parsed tree back to NUL, including one the document author legitimately wrote, silently corrupting it. Now refuses outright whenever the raw region already contains U+E000 (consistent with the existing anchor/merge-key refusal path), making the substitution provably injective. Tests added for a real NUL alone (preserved), a pre-existing U+E000 alone (refused, not corrupted), and both together (refused, not merged into one byte). Finding 4 (MAJOR): scripts/lint-vendored-deps.cjs's `srcTwin` field was dead for a hand-authored row (only read inside the upstream-verbatim branch) — exactly how Finding 2's stale docblock drifted unnoticed. Added checkHandAuthoredTwin: every value-level export the twin DECLARES must be an actual own property of the vendored runtime module at require-time. Tests added, including a sensor that a declared-but-nonexistent export IS caught. Finding 5: the existingFm/hasFrontmatter/stripFrontmatter/fmPrefix/ unparseableFm/reassemble preamble, copy-pasted at 7 sites in state-transition.cts plus a sixth hand-inlined copy in state.cts's cmdStateCompletePhase, is now one exported helper (beginFrontmatterReassembly) every site routes through, including the hand-inlined one. Three call sites (beginPhaseCore, patchCore, updateCore) keep a literal `body = stripFrontmatter(content)` assignment alongside the helper call so scripts/lint-state-write-path-drift.cjs's single-hop backward scan (which does not chase aliases) still sees the strip; stripFrontmatter is pure/idempotent so the extra call changes nothing observable. Finding 6: corrected the frontmatter.cts docblock's stale "wiring is a separate change" claim (the 8 call sites are wired on this branch) and the changeset's backlink from (#3473) to (#3881). Finding 7: fixed the lint:ci failures blocking the gate — an @typescript-eslint/only-throw-error violation from throwing a bare Symbol as the anchor-detected signal (now a real Error subclass), unused-var warnings left over from the Finding 5 refactor, a lint-test-file-count cap exceeded by two migration-specific test files (allowlisted with justification), and the lint-state-write-path-drift false positive from Finding 5's helper (fixed above). tests/frontmatter-golden-parity.test.cjs:117's execFileSync already carried an explicit timeout; no change was needed there. Golden fixture: added a golden entry for the new anchor-alias-bomb-quoted.md fixture ({} — matches what the legacy line scanner would also produce, since it independently dropped every quoted top-level key). No other corpus document diverges: real .planning/ documents carry zero anchors/aliases/merge keys/U+E000 today. Refs #3881 * fix(#3881): fold in second-round review findings Finding 1 (BLOCKER): tests/frontmatter.test.cjs pinned the pre-migration ASCII-only key regex for the Unicode fixture; updated to require the 相 key's value now that js-yaml has no such restriction. Audited the rest of the file for other pre-migration pins (block scalars, quoted keys, flattened values, empty values, duplicate keys, unclosed blocks, null bytes) by execution against real fixtures; found none regressed. Finding 2: parseYamlRegion and escapeDoubleQuoted renamed to parseGuardedYamlRegion and escapeDoubleQuotedScalar in src/frontmatter.cts so no function still answers to the deleted hand-rolled scanner's name (ADR-3473 §8.1 "deleted, not patched"). escapeDoubleQuotedScalar's three external call sites (src/commands.cts, src/runtime-artifact-conversion.cts) updated in the same change — a mechanical rename, not an ADR-amendment matter. Finding 3 (BLOCKER): fixed a real crash and a silent data-loss bug found by execution. A top-level key named constructor/__proto__/toString/ valueOf/hasOwnProperty crashed reconstructFrontmatter (bracket read resolving an inherited Object.prototype member); a key literally named __proto__ was silently DROPPED entirely (bracket assignment on an ordinary {} invoked the inherited __proto__ setter instead of creating a data property). Fixed by building every parsed Frontmatter object with Object.create(null), and replacing an `in` check with hasOwnProperty.call in propagateCommentChannel. Added round-trip tests for all five hostile keys, each with its own leading comment. Finding 4 (MAJOR): escapeDoubleQuotedScalar's docstring falsely claimed full byte-stability across the migration. Verified by execution: BEL/NUL/ NEL/NBSP/LS/PS/BOM now emit YAML-named escapes instead of the old hex/raw- literal forms. Proved round-trip equivalence (each escape re-parses to the exact source codepoint) and corrected the docstring. Found and fixed a related real defect while verifying: a lone UTF-16 surrogate was emitted BARE (scalarNeedsDoubleQuoting didn't trigger), producing genuinely unparseable YAML that silently collapsed to {} on re-read — extended scalarNeedsDoubleQuoting to route surrogates through the quoted+escaped path. Finding 5 (MAJOR): countKeysBeforeTruncation went silent on 4 real truncation shapes (unquoted colon, open flow collection, mis-indented sibling key, refused anchor). Root cause: the mark-based prefix recovery excluded the very line whose key needed counting, and a mark-less refusal never entered the recovery branch at all. Fixed by taking the max of two lower bounds: the longest parser-verified line-prefix, and a raw-text count of key-shaped lines (reusing the same key-shape pattern this file already uses for isFrontmatterShaped). Extended test-matrix row A5 table-driven over all 4 regressed shapes. Finding 6: the design doc's claim that no test owned the #3594 adversarial fixture corpus was false — consolidation epic #1969 had already folded it into tests/frontmatter.test.cjs. An earlier commit on this branch re-created a standalone duplicate under that false premise; folded its genuinely-new coverage (fixture-ownership check, anchor-bomb fixtures, block-scalar B1/B2 rows) into frontmatter.test.cjs and deleted the duplicate file. Corrected the false claims in 40-design.md §3.3.1 and the ADR's §8.1 note, including the roadmap-sibling claim (no such file exists). Finding 7: the golden serializer sorted object keys, making it structurally blind to the key-order-parity invariant ADR-3473 §8.1 actually claims. Made it order-preserving and regenerated the golden fixture from a standalone compile of the legacy (pre-#3881) parser at ddde001af; the current parser matches it with zero undocumented divergences, confirming key-order parity genuinely holds. Extended row A2 table-driven across 6 of the remaining 7 transitionCore kinds (all pass) plus documented, by execution, a newly-discovered 8th-site regression: state.cts's cmdStateCompletePhase calls the same preservation helper but its result is clobbered by a later unconditional resync — filed as a distinct finding rather than fixed here (touches syncAndPreserveStateMd, outside this change's verified scope). Refs #3881 * fix(#3881): preserve unparseable frontmatter through the CLI write path Characterization (executed, before/after shown): case (b), not (a). The frontmatter FENCE survives — `state complete-phase` on a conflict-marked STATE.md returns success and a well-formed, freshly-derived frontmatter block, not a document with no frontmatter at all. But the block's actual content (the merge-conflict markers, and with them any signal to a human that the document was in conflict) is silently discarded and replaced. Root cause was two clobber sites, not one: 1. syncStateFrontmatter (src/state.cts) re-parses the already-preserved `transformedContent` from readModifyWriteStateMd, finds {} + the FRONTMATTER_UNPARSEABLE marker, and unconditionally rebuilt a fresh frontmatter block from the body anyway. 2. Even after (1) is fixed, applyPostSyncPreservation's own postFm/applyStatePreservation/authoritativeFm-reassertion machinery re-extracts frontmatter from syncedContent, restores curated fields from the pre-write snapshot, and reconstructs a NEW block again — confirmed live via `state begin-phase`, which still lost the markers after fixing (1) alone. Both are now guarded by the same predicate (isUnparseableFrontmatter, checking FRONTMATTER_UNPARSEABLE): when the ORIGINAL frontmatter did not parse and the caller is not on ADR-3408 §8.3's closed "body wins" list, both functions return their input content unchanged rather than re-deriving over it. The closed list (cmdStateSync #905, /gsd-health --repair's REGENERATE_STATE, both routed only through writeStateMd, which never reaches applyPostSyncPreservation and passes sanctionedPermanentEmptyFallback=true to syncStateFrontmatter) is untouched — neither widened nor narrowed; verified by execution that `state sync` still overwrites the conflict-marked block exactly as before. Other verbs sharing the same readModifyWriteStateMd path were checked and were equally affected before this fix: state update, query state.patch, and state begin-phase all lost the conflict markers (RED, shown by execution), and all three now preserve them (GREEN). Covered table-driven in tests/feat-3881-yaml-parser-consequences.test.cjs's new A2b describe block, which drives the real CLI verbs via runGsdTools — not just the pure transitionCore layer the earlier A2 rows exercised — plus a control asserting state sync's body-wins contract is unchanged. Refs #3881 * fix(#3881): restore the parse surface's prototype and fix remote-runner failures Root cause of the bulk of the 88 remote-runner failures: extractFrontmatter/parseGuardedYamlRegion handed back Object.create(null) trees for prototype-pollution safety, but assert.deepStrictEqual compares prototypes, so every assertion against a plain object literal failed (57 frontmatter.unit.test.cjs + 5 frontmatter.test.cjs + others). Fixed by keeping the internal construction null-prototype (unchanged) and converting to a plain-prototype tree via Object.defineProperty (never bracket assignment, so __proto__/constructor/toString keys stay safe) at the parseGuardedYamlRegion/unparseableResult return boundary only; the internal FULL_LINE_COMMENTS Symbol channel is copied by reference, not recursed, so its own __proto__-safety is untouched. Per-class fixes: (1) bomAcrossArtifactTypes was the same prototype bug, no separate code change needed. (2) frontmatter-cli #1660: added objectListFieldWouldLoseData, a broader lossy-field detector alongside the existing byte-identical noOpObjectListSetError -- js-yaml's flattenObjectListItem now correctly includes every sub-key of an object-list item (a real bug fix over the legacy scanner, which silently dropped every field but the first), so a set that drops that now-included data is no longer byte-identical to the original and needs its own guard. (3) uat.test.cjs: updated the pinned expectation for the human_verification quote-stripping artifact -- js-yaml resolves quoting correctly where the legacy regex left an unbalanced quote; documented as an intentional, non-lossy behavior change. (4) smart-entry: added a fallback-only loadWithAmbiguousColonRepair so a column-0 key: value line whose value itself contains an unquoted colon (the #2571 hand-edited-STATE.md shape) round-trips instead of failing the whole frontmatter block closed. (5) frontmatter.unit.test.cjs bracket-array leniency: added a second fallback, repairMalformedInlineArrays, restoring the legacy scanner's tolerant inline-array handling (consecutive/blank commas, unclosed bracket) -- both repairs run ONLY after the primary parse already threw, so well-formed documents are unaffected. (6) prompt-injection-scan: src/frontmatter.cts had a literal U+FEFF BOM embedded in a comment illustrating the #2977 fix; replaced with the U+FEFF text escape. (7) eslint-glob-coverage: allowlisted the new src/vendor/js-yaml.d.cts vendored type declaration, same precedent as the existing re2js.d.cts entry. (8) frontmatter-golden-parity: git ls-files *.md now runs with -c safe.directory=* (process-scoped) so it survives the remote runner's dubious-ownership check without a persistent git config write. Refs #3881 * chore(#3881): backfill changeset PR number Refs #3881 * test(#3881): make golden parity resistant to unrelated tree churn A corpus-wide snapshot keyed to every tracked *.md file was coupled to mutable-by-design files: .changeset/*.md's pr:0 -> real-PR-number backfill is a required workflow step, not a parser change, yet it turned this suite red. Training people to 'just regenerate the golden' on that kind of failure defeats the point of the snapshot. Exclude .changeset/** from the golden corpus entirely, tolerate tracked *.md files with no golden entry (they postdate the capture) instead of failing on them, keep hard failures for a golden entry whose file has vanished from the tree and for any real parity divergence, and add a coverage floor so the enumeration cannot quietly degrade to comparing a handful of files. Golden regenerated by recompiling the legacy pre-migration parser (git show ddde001af:src/frontmatter.cts) standalone, independent of the current parser, over the same non-changeset corpus. Refs #3881 * test(#3881): make the parser golden hermetic instead of tree-keyed This repo merges ~21 commits/day; a 14-day sample measured 937 touches of the exact files (commands/gsd/*.md, gsd-core/workflows/*.md, agents/*.md, docs/*.md) the prior golden pinned by tracked path. Any PR editing one of those files' frontmatter for reasons unrelated to the parser (an argument-hint addition, an allowed-tools tweak) turned the suite red, and the reflex fix -- "regenerate the golden" -- overwrote the very snapshot meant to catch a real regression. Excluding .changeset/** was not enough; the design itself was wrong: a regression fixture must not be keyed to mutable repo paths, and a single 376-entry JSON every such PR touches is also a guaranteed merge-conflict surface. Rebuilt the fixture to carry its own documents: each of 51 entries stores a stable id, literal documentText (shrunk from a real ddde001af-era corpus document), and an expectedParse captured independently from the pre-migration legacy parser (git show ddde001af:src/frontmatter.cts, compiled standalone against its byte-identical sibling modules). The test reads no tracked path, shells out to no git command, and enumerates no tree -- a PR editing commands/gsd/help.md cannot affect it. Every entry's reconstruction was verified at capture time to reproduce both the current and legacy parser's output on the original document; 0 of 51 candidates were dropped by that check (1, the deliberately-unterminated unclosed-block.md adversarial fixture, has no closing fence to truncate at and is stored unshrunk). Kept the 5 documented DIVERGENCES rows (now diverges:true entries) and the D2 order-preserving structural serializer that keeps the comparison from passing vacuously; dropped the tree-enumeration helpers, the coverage floor, the post-capture-skip logic, and the vanished-file check -- all artifacts of the path-keyed design. Refs #3881 * fix(#3881): resolve vendored-deps paths independently of cwd shape Five rows in tests/lint-vendored-deps-manifest.test.cjs failed on windows-latest CI: the test passed absolute scratch-file paths into compareFiles()/checkRow(), whose helpers joined every input onto ROOT via path.join(ROOT, rel), producing garbage when the input was already absolute. It surfaced on windows-latest specifically because GitHub's Windows runners checkout the repo on a different drive than TEMP, so path.relative(REPO_ROOT, tmpFile) returned the absolute path unchanged (no relative traversal is representable across drives) rather than the relative form the test assumed. The remote gsd-test runner this repo gates pushes on is Linux-only and could never have caught this; GitHub CI's windows-latest job is the only signal that does, and it did. Fixed the helper itself (scripts/lint-vendored-deps.cjs's new resolvePath()) to treat an already-absolute input as absolute-in, absolute-out instead of silently mis-joining it, and updated the test to pass the scratch file's absolute path directly rather than relying on a relative conversion that is not always representable. Kept every mutation-sensor assertion intact and added coverage proving resolvePath is a no-op for relative inputs and correctly passes absolute ones through unchanged. Refs #3881 * fix(#3881): warn when state sync regenerates over unparseable frontmatter state sync (ADR-3408 §8.3's sanctioned regenerate path) correctly overwrites an unparseable frontmatter block per its 'body wins' contract — that overwrite behavior is unchanged here. The defect was the silence: synced:true/exit 0 gave no signal that the existing block (including git merge-conflict markers) could not be parsed and was destroyed, per ADR-3473 §8.5 ('a derived conclusion may not be reported as authoritative when the derivation dropped input it could not resolve') and §8.4 ('failure is a value'). Adds a gsd: warning — ... (#3881) line on stderr, matching the existing #3573 precedent, and surfaces the same disclosure in the JSON result's existing changes[] array so a machine consumer sees it too. Exit code and synced:true are left unchanged — sync did what its contract says. REGENERATE_STATE (/gsd-health --repair's sibling on the same sanctioned-regenerate list) is DESTRUCTIVE-risk and unconditionally refused by applyRepairs's dispatcher before runRepairAction ever runs (src/health-diagnostic.cts), so it is not a live path today and is not in scope for this fix. Refs #3881 * fix(#3881): exit non-zero when a state command returns an error Refs #3881 * chore(#3881): changeset for the state exit-code fix Refs #3881 * fix(#3881): honor the documented --project-dir flag Refs #3881 * revert(#3881): restore exit-0 result envelopes for state errors Reverts 9638f2936 and its changeset. The change was wrong and the revert is the correction. This repo distinguishes two error mechanisms deliberately. error() in src/io.cts writes to stderr and calls process.exit(1) -- the hard-failure path. output({error: ...}) writes a JSON result envelope to stdout and returns normally with exit 0. The reverted commit converted 23 result-envelope sites into hard failures, which is a different contract, not a bug fix. tests/state-contract.test.cjs's errorPathDoesNotPublish asserts the envelope contract directly -- a failing command exits 0 with a JSON error envelope and must not publish state.json -- and the remote matrix run caught it along with four cases in the QA scenario walk. Thirteen tests in tests/state.test.cjs that the original commit rewrote were encoding that real contract, not the bug it claimed; they are restored. Whether an error envelope on stdout with exit 0 is the right CLI design is a genuine question, and it is section 8.4's rule ('failure is a value') with its own phase. It is not something to flip inside this PR. Refs #3881 * chore(#3881): backfill changeset PR number for the project-dir fix Refs #3881 * test(#3881): keep the frontmatter mutation shard inside its time budget The Stryker (frontmatter) shard hit the documented 15-minute (900s) shard cap. Root cause is NOT row-level spawn overhead (contrast the #2790/ core-utils precedent): the three shard test files' own logic runs in ~413ms total (356+30+27ms) with all 392 assertions passing. Instead, src/frontmatter.cts grew from ~825 to 1496 lines (+671/-187) migrating to the vendored YAML parser, proportionally growing the mutant count Stryker generates for gsd-core/bin/lib/frontmatter.cjs. Stryker's command runner bills the full 'node --test <3 files>' invocation once per mutant, and node:test's default per-file process isolation forks a child process for each of the three files on every one of those invocations — pure fork overhead multiplied by a much larger mutant population. Fix: scripts/mutation-matrix.cjs COVERED.frontmatter now declares isolation: 'none', and .github/workflows/mutation.yml passes --test-isolation=${{ matrix.isolation }} (defaulting to 'process' — i.e. unchanged behavior — for the other 8 shards, which were not individually audited for cross-file state leakage under shared-process execution). Measured locally via node:test's run() API on the exact 3-file set: isolation:'process' took ~593ms vs isolation:'none' ~478ms for the same 392 passing assertions. The true CI-shard number can only be confirmed on the GitHub Actions run (Stryker cannot run locally, and 'node --test' is hard-blocked in this environment). Refs #3881 * test(#3881): register the vendored-parser tests in the frontmatter mutation shard stryker.config.mjs's own rule ("Keep this list in sync with the tests arrays in scripts/mutation-matrix.cjs COVERED") was violated: #3881 grew src/frontmatter.cts from ~825 to 1496 lines but its new tests (tests/feat-3881-yaml-parser-consequences.test.cjs, tests/frontmatter-golden-parity.test.cjs, tests/frontmatter-roundtrip.property.test.cjs, and +167 lines in tests/frontmatter.test.cjs) were never added to the frontmatter shard's tests array, so Stryker's mutants in the new vendored-js-yaml adapter had nothing constraining them. PR #3888 measured 55.8% against the 65 floor (748 killed / 593 survived / 17 timeout) and the shard was separately cancelled at 15m04s against the 15-minute per-shard cap. Registers all four files (each earns its slot on evidence of a unique constraining assertion, documented inline), gives the shard a measured/projected 180-minute budget via a new per-module timeoutMinutes field threaded through mutation.yml's job-level timeout-minutes the same way isolation is threaded, and removes the prior isolation:'none' override (re-measured at this file-set size, its savings are within run-to-run noise, not worth the unaudited cross-file-state-leakage risk). Refs #3881 * feat(#3881): derive the mutation test list and ratchet the score floor Refs #3881 * test(#3881): ratchet five stale mutation floors and close the frontmatter gap Raised five module minScore floors per CI run 33012034388 (floor(achieved)-1): config-schema 75.51%->74, prompt-budget 88.95%->87, context-composer 79.92%->78, context-utilization 92.31%->91, active-workstream-store 87.42%->86. Updated both scripts/mutation-matrix.cjs COVERED entries and tests/mutation-matrix-ratchet.test.cjs RATCHET_BASELINE in the same diff per the ratchet's own contract. Closed the frontmatter shard's 63.03%-vs-65 gap with new behavioral tests in tests/feat-3881-yaml-parser-consequences.test.cjs, each paired with a documented near-miss: frontmatterDeepEqual's array-order/length/type-mismatch/key-order semantics (via spliceFrontmatter's no-op guard), scalarNeedsDoubleQuoting's leading/trailing-whitespace and dash/surrogate triggers (via reconstructFrontmatter), repairAmbiguousColonValues' already-quoted vs ambiguous-colon repair paths (via extractFrontmatter), and the null-byte sentinel round-trip surviving at region offset 1. Did not lower minScore. Refs #3881 * test(#3881): decouple the ratchet test from real module floors The CLI end-to-end rows in tests/mutation-score-ratchet.test.cjs hardcoded config-schema's real floor (52), which commit 973321541 legitimately ratcheted to 74 -- breaking a test pinned to the exact value the mechanism under test exists to change. Add an injectable --matrix seam to scripts/check-mutation-score-ratchet.cjs and point the CLI rows at a synthetic module + synthetic floor built via a temp fixture, so the rows are indifferent to any real module's floor moving while still exercising the same fail/pass behaviour. Refs #3881 * refactor(#3881): parse must_haves with the vendored parser and drop re-implemented leniency Refs #3881 * fix(#3881): restore the ambiguous-colon repair its hand-edited-STATE.md contract needs A tracked-document sweep of 910 *.md files cannot see this dependent: repairAmbiguousColonValues's one real caller is user hand-edited STATE.md content that never lives in this repo's tree, only on end users' machines, and is pinned by tests/smart-entry.unit.test.cjs. Restores the function plus its post-throw fallback path (loadWithAmbiguousColonRepair) only; repairMalformedInlineArrays and splitLegacyInlineArrayItems stay deleted, reverified against the full frontmatter test shard. Adds a frontmatter-level regression row in tests/feat-3881-yaml-parser-consequences.test.cjs so the dependency is visible where the function lives. Closes #2571 Refs #3881 --------- Co-authored-by: sim <sim@local>
51 KiB
ADR-3473: Enforcement by Construction — One Owner per Invariant
- Status: Accepted. Phase 0 ships this file alone; every rule in §8 is Required — Phase N until its phase lands.
- 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
parseNamedArgsdiscards unrecognized and positional tokens with no error; the flag resolvesnullandcmdStatePlannedPhaseruns anyway (#3358).phases.list --pick summaries_totalreturns an empty string with exit 0, so the$(… || echo "0")idiom never fires (#3365).routeQuickTasksAppendwrites a row that contradicts the rowquick.mdspecifies (#3356).review.mdwritesREVIEWS.mdeven when every reviewer lane fails, then deletes the evidence (#3352).resolveDependencyIdreturnsnullfor an unresolvabledepends_ontoken; every call site treats it ascontinue, 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).acknowledgeDeferredItemreturnsokhaving 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:
local/no-crlf-fragile-splitis registered at error onsrc/**/*.cts, widened from tests to source by ADR-3212 Phase 2 specifically to stop CRLF parse bugs. #3360 is a CRLF parse bug insrc/frontmatter.ctsthat shipped under it — the rule fingerprints a bare\nin a regex; the defect is\sat a^under/m, which matches\nbut contains none. The guard structurally cannot see it, and lint is green.local/no-adhoc-markdown-parsingis registered in exactly one config block,src/**/*.cts— nottests/, notscripts/, notbin/. #3426 and #3239 are hand-rolled section and table scans insidetests/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 onsrc/**/*.ctsis 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: Enforced or Required — Phase N.
8.1 One YAML parser — Required — Phase 4
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: truebecomes"true", andFRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValuesis 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, andnoOpObjectListSetError(#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:
extractFrontmatterhas 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_SCHEMASstill declares four kinds with no type declaration for any of them, andSTATE_FIELD_SCHEMAhas 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. OnlysliceTopLevelFrontmatterSegments, the[object Object]guard andnoOpObjectListSetErrordie: ~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_SCHEMAresolves only!!str/!!seq/!!map, so every scalar returns a string by spec.gap_closure: truestays"true"andFRONTMATTER_SCHEMAS['plan-gap-closure'].requiredValueskeeps matching with no call site changed. Verified acrosstrue,null,~,1.5,0x10, a date,yes,on,.inf,NaNand 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.
extractFrontmatteris 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:
- Returning
{}on a parse failure is destructive, not benign. Eight call sites acrossstate-transition.cjsandstate.cjscomputehasFrontmatter = Object.keys(extractFrontmatter(...)).length > 0and, 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.)- An empty value silently drops its key. Legacy parses
progress:to{}; js-yaml yieldsnull;reconstructFrontmatteromits any null-valued key.gsd-core/templates/state.mdships an emptyprogress:, so passing null through deletes it on the next write.- 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 raisesbad indentationand yields 0 keys, so it stays silent). Keeping it hand-rolled recreates precisely the parallel-surface divergencefrontmatter.cts's own comment warns against.A new attack surface the fork never considered.
FAILSAFE_SCHEMAstill 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 — whichfrontmatterDeepEqualandreconstructFrontmatterthen 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.ctsalone 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+1growth per read-modify-write until OOM): four successive round-trips of a value containing"and\return it byte-identical every time, length stable.unescapeDoubleQuotedis a genuine inverse, shipped under #3497.- #3360 (
\sat^under/meating the\nof a CRLF pair →[]for every CRLFmust_havesblock): 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.mddeclaresargument-instructions: |; the legacy parser returns the block indicator"|"as the value, discards the instruction text, and invents a phantom top-level keyExamplefrom 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 — Required — phase unassigned
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 — Required — Phase 6
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 canonicalgenerateSlugInternal(src/core-utils.cts:107) yieldsprivet-mirwhilecmdGenerateSlug(src/commands.cts:200) yields""; at the truncation boundary the copy leaves a trailing hyphen (#2849's regression, still live in the copy)." resolveRuntimereads the install marker in one place with one cache"It reads no marker at all. src/runtime-slash.cts:132resolvesGSD_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 agentsfails on semantic drift"Both halves false. generateCodexAgentToml(bin/install.js) still readsCODEX_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 nosandbox_modeor tool-contract assertion.The
shortFormToIdrule below is accurate — no such tier exists onnext, andresolveDependencyId(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.
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 — Required — phase unassigned
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 — Required — phase unassigned
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 — Required — Phase 1
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.writeFileSyncagainst the state path)", and that sentence was wrong on both halves. Verified againstnextwhile 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.
writeStateMdacquires the STATE.md lockfile before it writes; a rawfs.writeFileSyncagainst 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 — anopen()transaction preserves, arebuild()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
clearpreservation policy when it turned out no row used it and no executor existed — contract names X, X does not exist, delete the naming.clearwas 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-assemblingsyncStateFrontmatter+applyPostSyncPreservationinstead of routing through the owned composition, which is ADR-3408 §8.3's rule and the exact shape #3469 found live incmdPhaseComplete— 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 — Required — Phase 2
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 — Required — Phase 3
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 againstnext: 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 acrosssrc/**and the prompt layer. It contains no reference toFIELD_CLASSIFICATION, to the template, or to the reference docs. Nor does any other script own field/template/docs drift — the fullscripts/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.cjspins 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 — Required — every phase
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.
parseNamedArgsbecomes 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 aChangedentry.--pickreturns0rather 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.
updatedarrays grow (§8.7). Callers that assumedprogressnever appears will now seeprogress.*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/mdastfor the markdown half. Rejected:src/markdown-sectionizer.ctsandsrc/markdown-table.ctsalready 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_CLASSIFICATIONtable'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
updatedarray'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 |
widened past src/**/*.cts per Decision 5 (coverage fix, tracked on #3426/#3239) |
local/no-adhoc-regex-escape |
widened to MemberExpression/TSAsExpression with a .source-aware exemption (§8.3) |
scripts/lint-frontmatter-scalar-broad-grep.cjs |
NOT a casualty of §8.1 — retained. See the correction below. |
scripts/lint-phase-enumeration-drift.cjs |
expected casualty of §8.2 — verify before retiring (Phase 5) |
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.cjsis not a casualty of §8.1 and is retained. It has nothing to do with the TypeScript parser. It isDEFECT.FRONTMATTER-SCALAR-BROAD-GREP(#586 / PR #650): it scans fencedbash /sh blocks ingsd-core/workflows/*.md,agents/*.mdandcommands/**/*.mdfor shellgrep "^key:"invocations that read a frontmatter scalar from the whole markdown body instead of scoping to the frontmatter block — the failure that once yieldedpassed+gaps_found+human_neededinstead ofpassedand blocked a passing phase. The prompt layer does not call our parser; it runsgrepin 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.cjscannot be "reused as-is." All four of its checks namere2jsliterally, as does itsREFRESH_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-yamlis not installed, so the re2js precedent's verbatim.d.ctscopy has no upstream to copy from.src/vendor/js-yaml.d.ctsis hand-authored, declaring onlyload,dump,FAILSAFE_SCHEMAandYAMLException— 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.