* 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, from2ea5efc15(#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-basec3e667df3(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>
Architecture Decision Records
This directory contains Architecture Decision Records (ADRs) for GSD.
Each ADR documents one architectural decision: what was decided, why, and what consequences follow. ADRs are append-only. Amendments extend existing ADRs with a dated section rather than replacing them.
Reading this corpus
Start with the index below, and respect the status. The index is grouped so that the first table — Active decisions — is the set that governs the system as it stands. An ADR in Superseded, Retired, and Legacy is historical: it records what was once decided and names what replaced it. Do not cite it as current architecture.
Two things the index makes explicit, because getting them wrong has actually misled readers here:
- "Read first" on an active ADR points at a broader ADR that now frames it. A decision can be entirely correct and still not be the whole picture. The runtime capability descriptor (ADR-1016) is live and load-bearing, but ADR-1239 (EoS — GSD as an Embeddable Orchestration Engine) subsumes it as the declarative adapter and inverts its direction: GSD is the engine a host embeds, not an installer that projects onto a host. For how GSD meets a host, EoS is the current frame.
Proposedmeans not ratified — and it is kept honest. On 2026-07-17 the corpus was audited against the shipped tree and nine ADRs whose decisions had demonstrably shipped were ratified toAccepted, each carrying a dated Ratification section with the evidence (see ADR-857 for the fullest example). The ADRs that remainProposedareProposedfor a reason recorded in the file — an unmet acceptance criterion, an outstanding phase, or a successor ADR already planned — not through neglect. Trust the label; if you think it is wrong, prove it in a dated section and see Ratifying a staleProposed.
Naming Convention
New ADRs use issue#-prefix slug naming:
docs/adr/<issue#>-<kebab-slug>.md
Examples: 2264-golden-parity-redesign.md, 1239-gsd-embeddable-orchestration-engine.md.
Why
Two developers computing "next ADR number" locally against main will independently pick the same integer and both ship. The collision is already on disk — 0010-* exists twice and 0011-* exists three times. GitHub issue numbers are server-assigned and atomic: the moment you open an issue, that number is reserved globally. Two PRs that both edit the ### Fixed block of CHANGELOG.md always conflict on merge — two PRs that each use a distinct issue# as their ADR prefix never collide. Same shape, same solution.
Legacy naming is not Legacy status
Files 0001-* through 0012-* are preserved as immutable historical record of the old local-compute numbering. The duplicate 0010-* and the three-way 0011-* are documented residue of that convention — not patterns to imitate. Do not renumber them.
This is the single authoritative statement of the legacy range.
docs/contributor-standards.mdreferences it rather than restating it, so the two cannot drift.
Two other zero-padded files look legacy but are not: 0174-retire-gsd-sdk-package-boundary.md (issue #174) and 0656-research-module-seam.md (issue #656) are mis-padded modern ADRs — modern, issue-numbered files whose four-digit padding is a mistake. They are NOT part of the legacy sequential set above and are not "old local-compute numbering" residue.
This is a statement about filenames only. Many of those ADRs are Accepted and load-bearing today (ADR-0002, ADR-0004, ADR-0008, ADR-0009). An old filename says nothing about whether a decision still holds. The Legacy status in the table below is a separate claim — see the vocabulary.
Because 0010-* and 0011-* each resolve to more than one file, a bare cross-reference like "ADR-0011" is genuinely ambiguous. Link the file (see Lifecycle rules).
Full process
See CONTRIBUTING.md — "Proposing an ADR or PRD" for the end-to-end workflow: opening the issue, waiting for approval, naming the file, and submitting the PR.
PRDs live in docs/prd/, not here. (0011-review-default-reviewers-prd.md predates that directory and is kept in place as frozen historical record.)
Lifecycle rules
These are enforced by scripts/gen-adr-index.cjs, which runs in CI via npm run lint:generated-sync. A violation fails the build with the exact file and fix.
1. Every ADR declares one status from the canonical vocabulary
The first word of the Status field must be one of:
| Status | Means | Obligation |
|---|---|---|
Accepted |
Decided and in force. Cite it. | — |
Proposed |
Decided in principle, not ratified. Do not cite as settled. | If the work has demonstrably shipped, ratify it (below) — do not leave the label lying. |
Superseded |
A specific newer ADR replaced this decision. | Must name the successor as a file link. |
Retired |
What this ADR decided no longer exists at all, and no single ADR replaced it. | Say what was removed and when. |
Legacy |
Frozen historical record, kept for provenance; not a pattern to follow. | Say why it is frozen. |
Prose may follow the token (Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23); originally Accepted (2026-05-09)). Both the bullet form (- **Status:** Accepted) and the table form (| **Status** | Accepted |) are accepted.
2. Cross-references to other ADRs are file links, never bare ids
Write [ADR-0011](0011-skill-surface-budget-module.md), not ADR-0011. Bare ids are ambiguous for 0010/0011, and unlinked references cannot be checked.
If you mean an issue, write #857 — not ADR-857. (An ADR and its owning issue often share a number; that is intentional and not a conflict.)
3. Supersession and subsumption are symmetric
These are different relations. Do not conflate them:
Supersedes/Superseded by— the target is replaced. Its status becomesSuperseded.Subsumes/Subsumed by— the target still holds, but a broader ADR now frames it. Its status is unchanged; it becomes a component of the larger decision.
If A declares either relation toward B, B must record the reciprocal. A one-way pointer is the failure this corpus actually suffered: ADR-1239 declared it subsumed four ADRs, none of which said so, and none of which pointed back — so a reader landing on any of them concluded the superseded frame was the way forward.
Only an Accepted ADR is owed the back-link. A Proposed ADR's claim is prospective: it has not taken effect, so its target is not marked. On ratification, the check begins demanding the back-links.
4. The declared id matches the filename
An H1 of # ADR-0175: … in a file named 218-*.md is a rename that never finished. The id in the title must match the filename's prefix.
5. A trailing H1 status bracket must agree with the Status field
Many ADRs restate their status in the H1 — # ADR-1610: … [Accepted]. That bracket is the first thing a reader sees, and the index strips it when rendering the title, so a stale one used to be invisible to everyone but the reader it misled.
If the H1 ends in a bracket holding a status token, it must name the same status as the Status field. Comparison is case-insensitive and against the parsed token, so [Superseded] agrees with Status: Superseded by [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (2026-05-23).
A trailing bracket that is not a status token — [Draft], [WIP] — is treated as part of the title and left alone. If you want a bracket the gate ignores, do not spell it like a status.
6. Every relative link resolves
A link whose target does not exist on disk fails the check, naming the file, the line, and the unresolved target. This covers every markdown file in this directory, including this README and any file whose name breaks the convention above.
| Written as | Treated as |
|---|---|
[t](900-beta.md), [t](../prd/) |
resolved — a directory counts |
[t](900-beta.md#section) |
the file is resolved; the #fragment is not checked |
[t](https://…), [t](mailto:…), [t](//host/x) |
out of scope — absolute destinations are never fetched |
[t](#lifecycle-rules) |
out of scope — a same-document anchor is not a file reference |
[t](/docs/adr/x.md) |
resolved against the repository root, as GitHub does |
a link inside a ``` fence or `backticks` |
not a link — markdown does not render one there, so it is never resolved |
[text][ref] reference-style, <a href>, bare autolinks |
not supported; write an inline link |
Two consequences worth stating outright:
- Case matters, on every platform.
[t](0001-Alpha.md)pointing at0001-alpha.mdfails even on macOS and Windows, because it 404s on github.com and reds the Linux CI lane. The failure names the entry it found so the fix is obvious. - A link to a generated or ignored path fails. Nothing here consults
.gitignore; the question is only whether a reader following the link lands somewhere. Cite the hand-authored source rather than the build artifact.
If the gate rejects something you wrote
Reproduce it locally first — it is the same command CI runs, and it names the file, the line, and the target:
node scripts/gen-adr-index.cjs --check
Then work from the reason:
| What it says | What to do |
|---|---|
does not resolve — no such file or directory at … |
Fix the path. It is relative to docs/adr/, so a sibling ADR is just 900-slug.md. If the target genuinely does not exist yet, drop the link rather than leaving it pointing nowhere. |
…Did you mean X? — link targets are case-sensitive on github.com |
Match the on-disk name exactly. Your machine may open the file regardless; github.com and the Linux CI lane will not. |
escapes the repository |
The path resolves outside the repo. Link something inside it, or use an absolute URL — those are out of scope and never checked. |
is a symlink that escapes the repository |
An ADR file itself is a symlink pointing outside the repo. Commit a real file. |
H1 status bracket […] contradicts the Status field (…) |
Update whichever of the two is stale so they agree. The Status field is authoritative; the bracket is a restatement for the reader. |
A link that is an example, not a destination, belongs in backticks. The gate skips fenced blocks and inline code entirely, because markdown does not render a link there. That is the escape hatch for illustrative syntax — the table above is written that way, which is why it does not fail this check. An indented code block (four spaces) is not skipped; use backticks.
To consume the result from a script rather than by eye, use --json (below) and branch on each violation's stable reason code.
Ratifying a stale Proposed
A stale Proposed is not cosmetic: it tells contributors and agents that live architecture is an unbuilt idea. Fix it — but on evidence, not vibes.
The bar. All four must hold before flipping to Accepted:
- The decided mechanism demonstrably exists in the tree — name the files, symbols, and tests.
- The owning issue is closed as completed. A closed issue is not proof:
stateReasonof not planned / duplicate means the decision was dropped (that isLegacyorRetired, notAccepted). - No material part is unshipped. If the ADR defines phases and one is outstanding, or states its own bar for acceptance and that bar is unmet, it stays
Proposed. - No later ADR supersedes it, and no approved issue already plans its graduation as separate work.
The procedure. Set the status to Accepted — ratified <date> (originally Proposed <date>), add a dated ## Ratification section holding the evidence, then run node scripts/gen-adr-index.cjs --write. If the ADR claims to supersede or subsume others, the gate will now demand their back-links — that is the point. Ratify deliberately.
Two traps worth knowing, both hit during the 2026-07-17 audit:
- Shipped code is necessary, not sufficient. Eight ADRs had every named module, symbol, and test present and their epics closed — and still failed the bar: ADR-2264's own headline acceptance criterion is unmet in the tree, ADR-230's decided branch protection does not match the live API, ADR-660's namesake mechanism is performed by hand, and ADR-959 has an approved issue planning its graduation as its own ADR. Verify the decision, not just the code.
- "Supersedes" is often "subsumes". Read what the ADR means before the gate makes you act on what it says. ADR-857 said "Supersedes (generalizes)"; taken literally, ratifying it would have stamped two live seams (ADR-0011, ADR-58) as dead. The parenthetical was the truth; the field name was wrong.
Maintaining the index
The index is generated. Do not hand-edit it. Everything between the ADR-INDEX:START / ADR-INDEX:END markers is derived from the ADR files themselves:
node scripts/gen-adr-index.cjs # print the index
node scripts/gen-adr-index.cjs --write # regenerate it into this file
node scripts/gen-adr-index.cjs --check # CI: fail if stale or invalid
node scripts/gen-adr-index.cjs --json # same checks, machine-readable report
After adding an ADR, or changing any ADR's status or relations, run --write and commit the result. npm run lint:generated-sync runs --check in CI, so a missing or stale row fails the build rather than rotting silently.
--json runs the same validation as --check and writes a report to stdout instead of prose to stderr, with the same exit code. Each violation carries a stable reason code, so a tool consuming this never has to pattern-match an error message:
{
"ok": false,
"adrCount": 76,
"indexStale": false,
"violations": [
{ "file": "2704-example.md", "line": 41, "reason": "link_unresolved",
"target": "reference/x.md", "resolved": "docs/adr/reference/x.md" }
]
}
An unrecognized flag is rejected rather than ignored.
This replaces a hand-maintained table that had drifted to 40 of 65 ADRs — the entire capability family and EoS itself were missing from it, which is precisely why the ADRs a reader most needed were the ones they could not find.
Index
Active decisions
These govern the system as it stands. Cite these.
| ADR | Title | Status | Read first |
|---|---|---|---|
| ADR-0001 | Dispatch policy module as single seam for query execution outcomes | Accepted | — |
| ADR-0002 | Command Contract Validation Module | Accepted | — |
| ADR-0003 | Model Catalog Module as single source of truth for agent profiles and runtime tier defaults | Accepted | — |
| ADR-0004 | Planning Workspace Module as single seam for worktree and workstream state | Accepted | — |
| ADR-0006 | Planning Path Projection Module for SDK query handlers | Accepted | — |
| ADR-0008 | Installer Migration Module owns install-time upgrade safety | Accepted | — |
| ADR-0009 | Shell Command Projection Module owns runtime-aware OS command rendering | Accepted | — |
| ADR-0011 | review.default_reviewers config key scopes the no-flag /gsd-review fan-out |
Accepted | — |
| ADR-0011 | Skill Surface Budget Module owns install-time profile staging and runtime surface control | Accepted | ADR-857 |
| ADR-15 | Cross-AI Plan Convergence via Existing Orchestration Commands | Accepted | — |
| ADR-22 | Plan-vs-codebase drift guard: defaults and symbol-resolver seam | Accepted | — |
| ADR-58 | Runtime Install Policy Module owns the typed install-plan projection | Accepted | ADR-1239, ADR-857 |
| ADR-0174 | Retire @opengsd/gsd-sdk package boundary — single-runtime collapse | Accepted | — |
| ADR-218 | Harden release-workflow version validation — reject leading zeros and pre-check npm | Accepted | — |
| ADR-227 | Input validation must check semantic shape, not just type | Accepted | — |
| ADR-415 | Prevent stale-base reintroduction of retired runtime tokens | Accepted | — |
| ADR-443 | Unified cross-provider effort controls and fast-mode-aware routing | Accepted | — |
| ADR-452 | Adopt standard ESLint flat-config lint harness | Accepted | — |
| ADR-456 | Test-rigor architecture — deterministic scheduling, antagonistic tier, typed-surface mandate, and delete-bad-tests policy | Accepted | — |
| ADR-457 | Generation model for bin/lib/*.cjs type safety |
Accepted | — |
| ADR-550 | spec-phase probe pattern and prohibition contract | Accepted | — |
| ADR-0656 | Research Module — L2-hybrid seam for cached, curated-first research | Accepted | — |
| ADR-766 | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted | — |
| ADR-857 | Capability system — five-step loop as core, features as plug-ins behind Loop Extension Points | Accepted | — |
| ADR-894 | Capability declaration format + registry generation | Accepted | ADR-1239 |
| ADR-959 | Capability Command Contribution | Accepted | — |
| ADR-1016 | Runtime Capability Descriptor | Accepted | ADR-1239 |
| ADR-1235 | Migrate agent conversion to the descriptor-driven install path | Accepted | — |
| ADR-1239 | GSD as an Embeddable Orchestration Engine | Accepted | — |
| ADR-1244 | Capability Ecosystem: third-party authoring, versioned manifests, and URL import/upgrade/remove | Accepted | — |
| ADR-1372 | Canonical markdown-structure parsing — the markdown-sectionizer seam |
Accepted | — |
| ADR-1411 | Resolution must report provenance, not fall open silently | Accepted | — |
| ADR-1508 | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted | — |
| ADR-1517 | Reviewer instances — bounded config surface for same-adapter multi-model review | Accepted | — |
| ADR-1577 | Untrusted-input boundary + opt-in injection blocking | Accepted | — |
| ADR-1593 | Skill mapping & converter methodology across runtimes | Accepted | — |
| ADR-1610 | workflow & agent size-budget ratchet (per-file byte baseline + tier hard caps) | Accepted | — |
| ADR-1703 | Cross-platform portability enforcement as AST ESLint rules | Accepted | — |
| ADR-1769 | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Accepted | — |
| ADR-1787 | /gsd:next smart-entry front door delegates advancement to /gsd:progress --next |
Accepted | — |
| ADR-1817 | STATE.md rebuild — derivability contract (capstone transition) | Accepted | — |
| ADR-1820 | Spec-Optional Predicate Rail — the Spec-Section Detection Module, the fallback toggle, and the SPEC↔probe precedence contract | Accepted | — |
| ADR-1866 | agent_skills dual injection — orchestrator-side + agent-side self-load | Accepted | — |
| ADR-1990 | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Accepted | — |
| ADR-2008 | Generic gate-predicate evaluator | Accepted | — |
| ADR-2121 | Phase-Identifier Parsing Consolidation | Accepted | — |
| ADR-2143 | Markdown Table Model, Bounded Mutation, and Fail-Loud Consolidation (#1372 part 2) | Accepted | — |
| ADR-2164 | Statusline draws its data boundary at local, read-only sources | Accepted | — |
| ADR-2207 | STATE.md Status lifecycle — phase-completion writes an intermediate state; milestone-close owns termination |
Accepted | — |
| ADR-2313 | Codex Adopts the Passive / Session-Only Model Posture | Accepted | — |
| ADR-2346 | Command Dispatch Completion | Accepted | — |
| ADR-2363 | A capability's skill body is an instruction surface — trusted, unscanned, and disclosed | Accepted | — |
| ADR-2619 | Observability and shareable diagnostics — wire the dispatch seam, add the outbound trust boundary | Accepted | — |
| ADR-2629 | Phase effort is estimated against a calibrated smart-zone budget, not a static heuristic | Accepted | — |
| ADR-2719 | Emitted-artifact attribution — replace the committed parity fixtures with a computed conservation law | Accepted | — |
| ADR-2782 | Reviewer Lane — the cross-AI reviewer handoff becomes a declared capability surface | Accepted | — |
| ADR-2866 | Install-surface resolution — the install pipeline resolves (runtime × scope × trigger) as a value |
Accepted | — |
| ADR-2966 | Test the five-step loop as a continuous walk, not isolated points | Accepted | — |
| ADR-2980 | A payload-carried error key is a degraded result, not a fault |
Accepted | — |
| ADR-3180 | Planning Semantic Model — Single Owner per Derivation | Accepted | — |
| ADR-3212 | The Lexical Seam — Safe Pattern Construction, Line-Terminator Normalization, and Tokenizer-First Stateful Grammars | Accepted | — |
| ADR-3408 | STATE.md Write Path — One Declared Policy, One Write Seam | Accepted | — |
| ADR-3409 | Shell Guards Must Observe Their Own Failure Arm | Accepted | — |
| ADR-3473 | Enforcement by Construction — One Owner per Invariant | Accepted | — |
| ADR-3574 | Install materialization shares primitives, not one writer | Accepted | — |
| ADR-3625 | The platform seam keeps its own Windows binary resolution rather than adopting a spawn library | Accepted | — |
| ADR-3626 | CONTEXT.md seam claims carry a checkable enforcement pointer | Accepted | — |
| ADR-3660 | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | ADR-1239 |
Proposed
Decided in principle, not yet ratified. Do not cite as settled architecture.
| ADR | Title | Status | Read first |
|---|---|---|---|
| ADR-230 | Introduce next as a long-lived integration branch |
Proposed | — |
| ADR-612 | Bracket Phase-ID Convention | Proposed | — |
| ADR-660 | Release from the head of next; immutable release tags; @next dist-tag as the RC surface |
Proposed | — |
| ADR-1143 | Claude orchestration capability — Workflow tool (ultracode) as a runtime-gated loop execution backend | Proposed | — |
| ADR-1213 | Capability write side — the Capability State Writer | Proposed | — |
| ADR-1606 | prohibition-enforcement verify-time seam | Proposed | — |
| ADR-1671 | Dynamic context management platform | Proposed | — |
| ADR-1953 | Complexity-triggered refactor — the loop measures the entropy it just added | Proposed | — |
| ADR-3128 | Adaptive runtime evidence for GSD Debug | Proposed | — |
| ADR-3646 | Per-task external-tracker content-resolution seam | Proposed | — |
| ADR-3889 | One exit-code registry — 0 and 1 are free, everything else is allocated | Proposed | — |
| ADR-3942 | The emitted-drift acknowledgment is PR-lifetime data — it belongs in a commit trailer, not the working tree | Proposed | — |
Superseded, Retired, and Legacy
Historical record. Do not follow these — each names what replaced it, or why it was retired.
| ADR | Title | Status | Replaced by |
|---|---|---|---|
| ADR-0005 | SDK Architecture seam map for query/runtime surfaces | Superseded | ADR-0174 |
| ADR-0007 | SDK Package Seam Module owns SDK-to-get-shit-done-redux compatibility | Superseded | ADR-0174 |
| ADR-0010 | File Operation Engine Module owns safe runtime/config file mutations | Superseded | ADR-0009 |
| ADR-0010 | Skill Surface Budget Module owns install-time skill listing curation | Superseded | ADR-0011 |
| ADR-0011 | PRD — review.default_reviewers config key for /gsd-review reviewer selection |
Legacy | — |
| ADR-0012 | CommandRoutingHub as single dispatch seam for CJS command families | Superseded | ADR-0174 |
| ADR-2264 | Redesign golden-install-parity — single-source manifest builder + split invariant | Superseded | ADR-2719 |
| ADR-3524 | CJS↔SDK hard seam — one source of truth per Shared Module | Superseded | ADR-0174 |
Generated by scripts/gen-adr-index.cjs — run --write after adding or restatusing an ADR.
Seam map
Orientation for the module-ownership ADRs. This section is prose and hand-maintained; the index above is the authority on status.
How GSD meets a host — start at ADR-1239 (EoS). It is the current frame and subsumes the descriptor/projection ADRs (ADR-1016, ADR-58, ADR-3660, ADR-894) as adapters beneath it.
The SDK seam map is gone. ADR-0005 was once the entry point for SDK module ownership; it is superseded by ADR-0174, which retired the @opengsd/gsd-sdk package boundary entirely. There is no sdk/ tree. Read ADR-0174 for the single-runtime collapse; the seam-Module vocabulary survives under one src/.
ADR-0006 documents how query handlers project planning paths (cwd → effectiveRoot → .planning/<project>/...). Cross-reference the Planning Workspace Module (ADR-0004) for workstream pointer policy.
ADR-0008 documents the Installer Migration Module for safe install-time moves, removals, config rewrites, and user-data preservation.
ADR-0009 documents the Shell Command Projection Module seam for runtime-aware projection of installer-owned command text and projection IR. Its Phases 3–4 absorbed the File Operation Engine Module (ADR-0010).
ADR-0011 documents the Skill Surface Budget Module for install-time skill/agent profile staging (--profile=<name>, .gsd-profile marker, requires: closure) and the Phase 2 runtime /gsd:surface command.
ADR-1411 establishes the Resolution Provenance principle: context resolution (config loading, project-root anchoring, workstream resolution) must report its provenance rather than fall open silently to defaults. It is the resolution-side analog of ADR-227 (input-validation shape).