Files
msd-core/docs/adr/3408-state-write-path-preservation.md
Tom Boucher e2f4c16d9e refactor(#3469): one composition for the STATE.md write seam (#3501)
* docs(#3469): amend ADR-3408 section 8.3 — the pipeline has sanctioned exceptions

Section 8.3 read 'Every STATE.md write applies the pipeline.' That is false by
design for two commands, and acting on it would have inverted a shipped
feature.

Preservation makes curated frontmatter win over a re-derived body value.
state sync exists to do the opposite — #905's 'body annotation beats existing
frontmatter when both are present'; it re-derives frontmatter FROM the body.
REGENERATE_STATE is a factory reset that rebuilds STATE.md from scratch.
Applying the pipeline to either would re-lock exactly what the command was
invoked to replace.

This issue's own scope line, inherited from the epic, said to route the direct
writeStateMd callers through the pipeline. For cmdStateSync that would have
shipped silently, with every gate green, because no test asserts that sync
LETS the body win. Caught by reading the helper's docstring and then verifying
the claim against the code — a stale comment had already misdirected this epic
once.

Both commands are now named in a closed exception list and are permanent
ratchet entries.

Consequence recorded rather than left to bite Phase 4: the 'drive the ratchet
to 0 and delete the file' target in this ADR and in #3471 is wrong. Two
entries are permanent, so the correct end state is 2, and the honest report is
'0 removable bypasses, 2 sanctioned'. A guard reaching 0 here would only do so
by having stopped looking at two real writers.

* refactor(#3469): one composition for the write seam, not one per caller

Implements ADR-3408 section 8.3 as amended.

syncAndPreserveStateMd is now the single composition of syncStateFrontmatter
and applyPostSyncPreservation. readModifyWriteStateMd and cmdPhaseComplete
both CALL it instead of each assembling the two steps themselves.
cmdPhaseComplete keeps its own writePlanningFileSet envelope — the
composition returns content, it does not take over the write, so STATE.md
still commits atomically with ROADMAP and REQUIREMENTS.

Assembling the stages at a call site is a re-derivation even when every step
calls an owner. Upstream's fix(#3374) routed cmdPhaseComplete through
applyPostSyncPreservation but left it calling syncStateFrontmatter directly
first, so the composition was duplicated and free to diverge with both guards
green. That is ADR-3180 Amendment 2's finding repeating on the write side.

cmdMilestoneComplete gains preservation. It wrote through writeStateMd, so it
got sync and no preservation — the identical shape #3374 reported for
phase.complete, and flagged upstream as a follow-up in the helper's own
docstring. This is that follow-up.

Divergence is now visible: preservation_warnings names each field restored
over a disagreeing derived value. Deliberately NOT named warnings —
cmdPhaseComplete already exposes warnings as a prose string array, and two
sibling commands carrying that name with different element types is
Generative Fix Divergence, the class this epic exists to remove.

patchCore stops running stateReplaceField over the whole document. One
observable consequence, intended per design row 9: a frontmatter-shaped patch
key with no body counterpart now reports failed instead of silently
succeeding, because the old whole-document match was literally hitting the
YAML line case-insensitively.

The guard closes Phase 1's DECLARED KNOWN GAP as promised rather than
re-deferring it: section 8.3(b) detection is tractable now the composition
exists. Scoped by two factors to avoid Phase 1's measured 29-to-1 false
positive rate — a variable field-name argument AND a content argument whose
nearest preceding assignment is not stripFrontmatter. Verified 0 findings and
0 false positives across all 33 call sites, plus 5 synthetic shapes. It also
detects the re-assembly shape above.

Ratchet: 4 entries to 2, both sanctioned-permanent. cmdStateSync's owner
changes from #3471 to sanctioned-permanent per Amendment 2 — routing it
through preservation would invert the #905 contract.

Also fixed inline rather than deferred: cmdMilestoneComplete's STATE.md read
now happens inside withStateLock. It previously read outside any lock before
writeStateMd took its own, leaving a TOCTOU window under concurrent writers.

* test(#3469): characterization coverage for the single write seam

Matrix sections A-E. Criterion 6 was amended by maintainer decision — all five
instances closed by point fixes while Phase 1 was in flight — so these are
characterization tests at the consumer's output per ADR-3180 Decision 4(b)/(c),
paired with the drift guard's count, never either alone.

Section C is the one that earns its keep. cmdStateSync is a sanctioned
permanent exception: state sync exists to re-derive frontmatter FROM the body,
so preservation there re-locks exactly what the command was invoked to
replace. C1 pins that the body wins; C4 pins that this phase left the command
byte-identical. Nothing else in the suite would notice if a future change made
sync start preserving, and the natural reading of 'one write seam' is to make
precisely that change.

Section E pins the guard's false-positive scoping. E4 (updateCore's
strip-then-replace) and E5 (sectionBody-scoped calls) must NOT be reported —
the naive detector measured 29 false positives to 1 true positive in Phase 1.
E7 is the inverse: a sanctioned-permanent entry disappearing must FAIL,
because a guard reaching zero here would only do so by having stopped looking
at two real writers.

Also corrects a stale test that asserted patchCore's old whole-document
behavior, which this phase deliberately changes.

One honest limitation, flagged rather than papered over: A1's 'byte-identical
to pre-refactor' cannot be diffed against real pre-refactor bytes from inside
the suite. It is implemented as the seeded fast-check property that
cmdPhaseComplete's composed output equals readModifyWriteStateMd's for the
same inputs — the strongest available proxy, not the literal claim.

* docs(#3469): refresh the seam glossary entry and add the changeset

Two spec-review gaps, both real.

CONTEXT.md's STATE.md Transition Module entry named three direct writeStateMd
callers including cmdMilestoneComplete. This phase routed that one through the
composition, so the line was false the moment the refactor landed.

Worth recording plainly: I wrote that sentence in Phase 0, correcting an
older stale pointer in it, and my own Phase 2 change invalidated it again
within the same epic. That is the exact drift this epic exists to remove,
demonstrated on the epic's own documentation — and it is why the entry now
ends by saying the whole-repo drift guard, not this line, is the authoritative
count.

The entry now records the composition (syncAndPreserveStateMd) and states that
exactly two direct callers remain, both SANCTIONED PERMANENT rather than debt.

Changeset: type Changed, because milestone complete's observable output moves.
Tier-2 per ADR-3180 Decision 3 — a stale body line no longer wins over fresher
frontmatter, and the command gains preservation_warnings. Docs requirement is
met by the ADR amendment already in this diff.

* test(#3469): register property-test temp-dir cleanup at creation time

Standards review, minor but real: the new fast-check property cleaned up its
temp dirs in a loop AFTER fc.assert returned. A genuine property failure
throws, so that line never ran and every dir from the failing run — including
all of fast-check's shrinking iterations — leaked.

The failure path is exactly when a littered machine hurts most, and a failing
property test is the case the test exists for.

Cleanup is now registered with t.after() at dir-creation time, so teardown
happens however the test exits. Not try/finally — CONTRIBUTING.md:356 bans it
inside test bodies, which is why the after-the-assertion shape existed in the
first place.

Swept the rest of the branch's test diff for the same shape; phase.test.cjs
already uses registered teardown and nothing else matched.

* fix(#3469): patchCore routes frontmatter writes instead of dropping them

Checkpoint returned 10 failures of 33880. One implementation defect, three
test defects, one stale test — all fixed, and the implementation defect is the
one that matters.

patchCore stripped frontmatter and then reconstructed it VERBATIM, applying no
patches to it. An arbitrary custom frontmatter key with no body counterpart and
no FIELD_CLASSIFICATION row — risk_level in the upstream fix(#3351) test —
therefore always reported failed and silently never wrote. It worked before,
via the old whole-document match on the raw YAML line.

That is a regression against this phase's own design row 9, which requires
frontmatter changes to ROUTE THROUGH the seam — still work, policy-governed —
not to stop working. Removing a capability is not routing it. An upstream test
caught it, which is the argument for running the checkpoint before believing
the refactor.

patchCore now partitions by frontmatter shape, decided structurally from the
parsed frontmatter's own keys rather than a naming heuristic:
  - classified keys still report failed — policy owns them and a raw patch may
    not bypass it;
  - unclassified keys apply to the frontmatter object and report updated —
    Phase 1's behavior-table row 19, a field with no row is not this contract's
    business;
  - body-shaped keys are unchanged.

The property 'failure' was my own test breaking the repo's Clock Seams rule.
The two paths agree byte-for-byte; the only difference was last_updated,
stamped from the wall clock on two invocations milliseconds apart, so it could
never pass. Time is now frozen with mock.timers across both — not by excluding
last_updated from the comparison, which would have silently stopped comparing
a field the composition writes.

B4's fixture could not discriminate: normalizeStateStatus maps any text
containing 'complete' to 'completed', and milestone complete's own new body
value derives to exactly that — which was also the fixture's stale value. The
stale value is now 'executing' so the assertion can tell 'body correctly won'
from 'stale survived'.

B5's fixture tripped a pre-existing unstarted-phase guard before reaching any
write-seam code; it now has the matching phase directory.

D9 asserted the old exempt set. readModifyWriteStateMd now calls one symbol
rather than assembling two, so it needs no exemption; syncAndPreserveStateMd
is the sole legitimate composition site.

* fix(#3469): patchCore resolves body-first, so the body wins a name collision

Re-verification returned 2 failures of 33880, both D4 — the hostile row for a
key that exists as BOTH a frontmatter key and a body field.

The partition checked frontmatter first, so 'status' — classified in
FIELD_CLASSIFICATION and also present as a body 'Status:' line — routed to the
frontmatter branch, was rejected as classified, and reported failed.

Wrong order. Patching 'status' means the body field, and upstream fix(#3351)
says so in its own comment: 'the legitimate working case for state.patch is
display-cased BODY fields — Status, Current Plan, Phase.' The body is
authoritative in this model; frontmatter is the projection. D4 asserted
exactly that and was right.

Resolution order is now body, then frontmatter:
  1. resolves to a body field -> apply to body, updated
  2. else an own key of the frontmatter:
       classified   -> failed  (policy owns it)
       unclassified -> apply to frontmatter, updated
  3. else -> failed

Verified by probe against the compiled lib for all four cases rather than
asserted: risk_level (frontmatter-only, unclassified) still lands;
current_phase still fails; display-cased Status unchanged; D4's lower-cased
status now lands via the body with the frontmatter untouched.

The current_phase case was the one that could have regressed silently, so its
fixture was read rather than assumed — D1's body carries 'Phase: 3 (alpha)'
and no 'Current Phase:' line, so body-first cannot reach it.

* chore(#3469): backfill pr number in changeset fragment

---------

Co-authored-by: sim <sim@local>
2026-08-14 16:04:09 -04:00

37 KiB
Raw Blame History

ADR-3408: STATE.md Write Path — One Declared Policy, One Write Seam

  • Status: Accepted. Phase 0 ships this file alone; every rule in §8 is Required — Phase N until its phase lands.
  • Date: 2026-08-14
  • Issue: #3408 is the scope authority (epic + approved-enhancement), which is why this ADR carries its number. #3467 is the Phase-0 tracking sub-issue this PR closes — the epic stays open until Phase 4 merges. Convention follows ADR-3180 and ADR-3128.
  • Supersedes: nothing.
  • Relationship to prior work: the write-side mirror of ADR-3180, which gave each read-side derivation one owner and proved it with drift guards. Every owner in ADR-3180 §7 is a read derivation; no ADR owns the write path. This applies the same mechanism to it, and adopts ADR-3180's Decision 4 constraints (a)–(e) verbatim rather than restating them.

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

Context

src/state-transition.cts declares a per-field preservation policy in FIELD_CLASSIFICATION, and its docstring states the contract:

Adding a new STATE.md field = one row here, not 9 transition edits.

applyStatePreservation — the function that consumes the table — half keeps that promise, and the half it does not keep is where six confirmed-bug instances live.

The divergent surface

Concern Declared Enforced Divergence
preserve-when-unchanged for current_phase, current_plan, paused_at, last_activity_desc FIELD_CLASSIFICATION a generic table loop (#3258 / PR #3447) plus empty-only "#905" guards in syncStateFrontmatter on the writeStateMd / cmdStateJson paths two enforcement points that "agree by construction" per a 19-line code comment
preserve-when-unchanged for status, stopped_at FIELD_CLASSIFICATION two hand-written if blocks policy change is not a one-row table edit
preserve-always for current_phase_name, progress FIELD_CLASSIFICATION two hand-written if blocks as above
derive, clear FIELD_CLASSIFICATION / the FieldPreservation union no executor at all; clear has no row either a declared policy nothing implements
the write seam itself readModifyWriteStateMd plus a direct syncStateFrontmatter call in cmdPhaseComplete, and stateReplaceField over frontmatter in patchCore two writers outside the policy
what a command reports it wrote — an intent list captured before sync never reconciled against what was persisted

The failure mode that hides all of it

This is ADR-3180's signature shape, on the write side: the failure and the success are output-identical.

Path Returns on failure Issue
a declared row whose caller wired no body-source delta continue — indistinguishable from a policy that correctly did nothing the #3258 class
phase.complete harvesting a stale body Stopped at: a well-formed stopped_at naming phase N−1, warnings: [] #3374
state.planned-phase re-deriving current_phase from stale body prose a well-formed 35.1 where the caller passed 35.3 #3395
phase.complete overriding current_phase_name only a well-formed pair describing two different phases #3350
state.patch reporting fields it did not persist {"updated":[…],"failed":[]}, and the file was rewritten so mtime/hash/git diff --quiet all agree #3351

Every row is 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 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 six point fixes

The shape is "policy declared in a table, enforcement hand-rolled per call site." A row can be added and nothing objects if no branch implements it. Fixing the instances individually leaves the seventh free to land — and ADR-3180's §7 preamble states exactly why a written rule is the missing piece:

a reviewer with no written rule to check a call site against can only ask "does this look like the others", which is how a fifth copy passes review.

That reasoning held three consecutive times on the read side: ADR-3180's phases found 26 copies where the epic scoped 3, 5 where it scoped 3, and 54 where it scoped 4.

Decision

1. One executor per declared policy — dispatch on the row, not the field

applyStatePreservation selects its branch from the row's preservation value. Every member of the FieldPreservation union has exactly one implementation:

Policy Executor
preserve-when-unchanged restore the pre-write snapshot when the field's body source did not change in this write
preserve-always restore the pre-write snapshot, subject to the row's guards
preserve-if-placeholder restore when the derived value is absent, the template placeholder, or punctuation-led
derive explicit no-op — the sync's value stands
clear remove the field, or the union member is deleted (Phase 1 decides; see §8.6)

derive gets an executor precisely because it is a no-op: naming it is what lets Decision 2 tell "policy says do nothing" apart from "nobody wired this."

Field-specific guards are row metadata drawn from a CLOSED vocabulary. status's 'unknown' sentinel and stopped_at's ## Session body scoping become named guards the executor interprets — not a reason for a hand-written branch, and not an open predicate slot.

Rejected: an open per-row guard predicate. It is the most natural next edit and the one that converts the table into an interpreter. Greenspun's Tenth Rule, applied to a table that has already accreted five times (preserve-always #1743/#1695, preserve-if-placeholder #948/#2135, state_head #2573, deriveProgressKeys #2440, bodyDeltas #3258). Adding a guard kind is an amendment to this ADR, not a table edit.

Rejected: keep the hand-written branches, better documented. That is the status quo, and src/state-transition.cts:287-305's reconciling comment is the evidence that documenting a hand-written branch does not bind the next one. ADR-3180 Decision 1 on "keep in sync" comments applies verbatim: "it is evidence the risk was known, not that it was controlled."

2. An unenforced declared row is a LOUD failure — and this line does not extend to user documents

A declared preserve-when-unchanged row reaching the executor with no wired body-source delta throws. Today it is if (!delta) continue (src/state-transition.cts:314) — silently unenforced at runtime, caught only by an invariant test.

The bright line, stated because conflating its two sides would be severe:

Bad input Response
Internal invariant violation — a declared row with no caller wiring 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 STATE.md never throw. Preserve per policy and warn; behavior otherwise unchanged

ADR-3180 Decision 2 rejected throwing, reasoning that "these paths are read during normal progress rendering, and throwing converts a display degradation into a command failure." That is correct for external input and is preserved here for the second row. It does not govern the first: Postel's own guidance for internal system-to-system boundaries, where both ends are controlled, is stricter on both sides.

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

3. One write seam — a pure pipeline plus an I/O wrapper

readModifyWriteStateMd's sync + preservation stage is extracted into a pure content → content function. readModifyWriteStateMd becomes that function plus its read / lock / no-op-guard / write envelope.

This is what makes "one write seam" achievable without breaking anything. cmdPhaseComplete bypasses the seam today for a legitimate reason its own comment gives (src/phase.cts:2953-2957):

it does NOT go through readModifyWriteStateMd because STATE.md is committed atomically with ROADMAP/REQUIREMENTS

writePlanningFileSet commits three files as one unit. Deleting the call site outright would trade a preservation bug for an atomicity bug. Calling the pure pipeline gives the command its atomic write and the policy.

patchCore stops running stateReplaceField over frontmatter (src/state-transition.cts:1600-1601), unlike updateCore, which already strips it first (:1631) and is the correct shape.

A caller needing a different I/O envelope calls the pipeline. It never re-assembles one. Assembling the stages at a call site is a re-derivation even when every step calls the owner — ADR-3180 Amendment 2 found exactly that shape on the read side, where two sites re-assembled a window from the owner's primitives and had already diverged.

4. Report from postFm, after preservation

Every updated / failed / warnings array a command returns is computed after applyStatePreservation, by comparing persisted frontmatter against the pre-write snapshot. A field appears in updated iff its persisted value changed.

"Reported but not persisted" (#3351) and "persisted but not reported" (#3345) both become unrepresentable.

This matters more than a cosmetic report: #3351 records that the file is still written — last_updated is bumped — so mtime, hash and git diff --quiet all confirm the lie. The updated array is the only field-level signal a caller has.

5. The anti-divergence contract — and how its metric is gamed

scripts/lint-state-write-path-drift.cjs reports 0 write-path bypasses, paired with a behavioral identity test asserting at the consumer's output. ADR-3180 Decision 4 (a)–(e) is adopted verbatim and not restated.

"0 bypasses" is a measure about to become a target. The routes are enumerated here so a future reader can check a green guard against them:

Gaming route Defense
Scan only src/ 4(d) — the scan surface is declared and includes the prompt layer (gsd-core/workflows, commands, agents, skills), which can shell out to state.patch and post-process
Route the bypass through a wrapper or a differently-named local 4(b) — the paired behavioral test
Call the pipeline, then mutate postFm locally 4(c) — assert at the consumer's output, never the owner's return value
Exempt the owner file 4(d) — owner functions are exempt; the owner file is not. ADR-3180 Amendment 4 records a whole-file owner exemption failing in roadmap-parser.cts; the risk transfers here because state.cts is likewise both the owner and the largest bypass surface, which is this ADR's extrapolation rather than a fact ADR-3180 states
Bank every site in the ratchet and never shrink qa-smell-ratchet.cjs invariants: a recorded site that no longer fires also fails; each phase shrinks the baseline by exactly its removals; each entry names the issue owning its removal
Write the guard last, against an already-clean tree The guard ships in Phase 1, ratcheted. ADR-3180 Amendment 5 is titled "the guard nearly reported a zero it had not earned"

"0 bypasses" is a lagging output metric. The leading indicator is the ratchet's shrink matching each phase's removals, and the identity test's consumer coverage. Neither number is ever reported alone — 4(b)'s constraint, made explicit here rather than inherited, because reporting the zero by itself is the whole failure mode.

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. Migration order — the guard first, the seam second

Locked: Phase 0 (this ADR) → Phase 1 (executor + ratcheted guard, #3468) → Phase 2 (one write seam, #3469) → Phase 3 (report from postFm, #3470) → Phase 4 (stale-but-present + identity test + ratchet to 0, #3471).

Stacked and sequential, never parallel: get_impact(direction=both, depth=6) against next rates readModifyWriteStateMd CRITICAL (185 affected symbols, lower bound; 38 files; 25 processes) and syncStateFrontmatter CRITICAL (154). Every symbol these phases name sits inside one blast radius, so a parallel phase would edit symbols inside a sibling's.

Phase 3 follows Phase 2 because it reports on the pipeline Phase 2 makes canonical. Phase 4 is last because its ratchet-to-zero is only meaningful once Phases 1–3 have shrunk it.

7. Scope boundaries

In scope: the five concerns in §8; the guard and identity test; boundary coverage per CONTRIBUTING.md; at least one test per concern asserting the path can fail.

Out of scope: .planning/ on-disk formats; the document-parsing layer (#2143); concurrency and cross-process atomicity (#3311 — a different failure mode needing locking, not a preservation table; 6b34557ba fix(#3311) landed independently).

The child defects. Phases 1–4 drive #3258, #3374, #3350, #3351 and #3395 fail-first. 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, made with the evidence in front of them.

Recording it explicitly because both silences are failures: a phase that demonstrably removes a defect's symptom while claiming to change nothing is a shipped lie, and a phase that closes an issue the epic disclaimed is scope it never had.

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

Decisions 1–7 answer how the write path 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. A Required rule is as binding as an Enforced one; the only difference is whether the tree satisfies it yet.

8.1 Policy dispatch — Required — Phase 1

Question. Given a STATE.md field and its FIELD_CLASSIFICATION row, what decides its value after a write?

Owner. src/state-transition.cts · applyStatePreservation, dispatching on row.preservation.

Rule. Exactly one executor exists per FieldPreservation member. No branch is selected by field name. Field-specific conditions are named guards from a closed vocabulary; a new guard kind is an amendment to this ADR.

Failure signal. §8.2.

8.2 An unenforced row — Required — Phase 1

Rule. A declared preserve-when-unchanged row reaching the executor with no wired body-source delta throws. This applies to internal invariant violations only. A user document that is drifted, malformed, or unparseable never throws — §8.5 governs it.

Rule. Where both conditions hold at once, the invariant violation is reported first: it is a defect in our source, and reasoning about the user's document under a broken policy table is meaningless.

8.3 The write seam — Required — Phase 2

Question. What is allowed to write STATE.md?

Owner. src/state.cts · the pure sync + preservation pipeline, and readModifyWriteStateMd as its I/O wrapper.

Rule. Every STATE.md write applies the pipeline unless the command's own contract is to let the body win (see the exception list below). A caller needing a different I/O envelope — cmdPhaseComplete's atomic three-file commit via writePlanningFileSet — calls the pipeline and supplies its own envelope. It does not re-assemble the stages, and it does not skip them. Assembling the stages at a call site is a re-derivation even when every step calls the owner.

Sanctioned permanent exceptions — a closed list; adding to it is an amendment. Preservation makes curated frontmatter win over a re-derived body value. Two commands exist precisely to do the opposite, and applying the pipeline to them would invert the feature rather than fix a bug:

Command Why preservation must NOT apply
state sync (cmdStateSync) Its contract is #905's "body annotation beats existing frontmatter when both are present" — sync exists to re-derive frontmatter from the body. A preservation pass re-locks the stale frontmatter the command was invoked to replace.
/gsd-health --repair's REGENERATE_STATE A factory reset that rebuilds STATE.md from scratch. Preservation would restore exactly the values it was invoked to discard.

Both are permanent entries in the ratchet with owner: sanctioned-permanent, never debt. A guard reporting them is reporting correctly; a change that removes one is a regression, not progress.

This paragraph is Amendment 2. The rule previously read "Every STATE.md write applies the pipeline", which is false by design for both rows and would have had Phase 2 route cmdStateSync through preservation — inverting a shipped feature with every gate green.

Rule. current_phase and current_phase_name are written as a pair. A transaction that computes one from a phase number writes both from that same number, so the two cannot describe different phases (#3350).

Consumers that must route through the owner. readModifyWriteStateMd's 16 callers, cmdPhaseComplete (src/phase.cts), patchCore and updateCore (src/state-transition.cts), plus the three direct writeStateMd callers a whole-repo scan finds: cmdStateSync (src/state.cts:3682), cmdMilestoneComplete (src/milestone.cts:865), and the REGENERATE_STATE remedy (src/health-diagnostic.cts:337). Each is a named ratchet entry carrying the issue that owns its removal, never an unrecorded pass.

REGENERATE_STATE is a sanctioned permanent exception, not debt. It is /gsd-health --repair's factory reset: it backs up STATE.md, then rebuilds the document from scratch (src/health-diagnostic.cts:313-339). Preservation would defeat its entire purpose — restoring the curated values it was invoked to discard. It is recorded here so a future reader does not "consolidate" it, and so the guard's baseline can never silently absorb it.

How this list was gotten wrong once already, recorded because it is the exact trap this ADR exists to close. CONTEXT.md's STATE.md Transition Module entry placed the factory-reset primitive at verify.cts:1925. It is not there — it moved to health-diagnostic.cts when cmdValidateHealth migrated onto the rule table (d1760e3c3 refactor(#3309)), and src/verify.cts now contains no writeStateMd call at all, only two stale comment references at :1332 and :1364. The design intent that entry recorded was correct; only its address was stale — and a first pass at this ADR, trusting the entry, over-corrected in the opposite direction and reported the primitive as retired. The entry is fixed in this PR.

Two rules follow, and they are why this box is normative rather than a footnote:

  1. Phase 1's guard derives its baseline from a whole-repo scan, never from this list or from CONTEXT.md. The list exists to be checked against the scan; a discrepancy is the scan's finding, not the list's.
  2. A stale address does not retire a decision. Read the code before concluding a recorded exception is gone — ADR-3180 Amendment 3's lesson, which this PR proceeded to demonstrate on itself: "Read the code, not the write-up."

Guard. scripts/lint-state-write-path-drift.cjs.

8.4 The report — Required — Phase 3

Question. What does a command's updated / failed array mean?

Rule. It names the fields whose persisted value changed, computed from postFm after preservation. A field the caller asked for that did not change is not updated. Whether "not found" and "found, written, then restored by policy" are distinguishable buckets is decided in Phase 3 and recorded as an amendment here — it is a behavior this section does not yet state, so per §8's own rule it is not decided.

8.5 Stale-but-present — Required — Phase 4

Question. The body source disagrees with frontmatter and the derived value is non-empty. Who wins?

Rule. The declared policy decides, on the same terms as an empty derived value. preserve-when-unchanged restores the curated frontmatter value when that field's body source did not change in this write. The empty-only "#905" guards in syncStateFrontmatter are deleted, not kept in sync — one enforcement point on every path, including writeStateMd and cmdStateJson.

Rule. Preservation is visible. When policy restores a curated value over a disagreeing derived one, the command emits a divergence warning. Silence is the defect #3374 reported (warnings: []), not the fix.

Rule. A drifted body is an ordinary, expected user-document state. It is what this contract preserves against — never an error, never a throw (§8.2).

8.6 clear — OPEN QUESTION, forcing function: Phase 1

clear is a declared FieldPreservation member. No row uses it and no executor exists. That is §8.1's defect one level up: a declared policy nothing implements.

Phase 1 either implements and tests it, or deletes the union member. Phase 1's drift guard fails while a declared policy has no executor, so this cannot be satisfied by consolidating four of five. The outcome is recorded as an amendment here.

Consequences

Positive. "Declared in the table, enforced somewhere else" becomes unrepresentable. phase.complete writing a stopped_at that names the previous phase, and a current_phase / current_phase_name pair describing two different phases, become structurally impossible rather than individually patched. A command's updated array becomes trustworthy without a read-back, which is what makes the remaining phases observable from outside.

Negative / accepted costs. One new scripts/ file, shipping in the npm package and installer with its inventory and manifest ripples. Tier-2 output changes will break downstream consumers parsing current command output — deliberately, per ADR-3180 Decision 3, each with its own breaking-change call-out, changeset fragment and docs update. Phase 2 carries a CRITICAL blast radius and cannot be sliced further without breaking the seam in half. The stacked ordering means wall-clock is the sum of four code phases.

Risks. The guard cannot see re-derivation through dynamic dispatch; the identity tests are the backstop and only cover the shapes they were written for (CONTRIBUTING.md § Fixture provenance (#2371)). The closed guard vocabulary of Decision 1 may prove too narrow for a future field — the amendment path is the mitigation, and the alternative (an open predicate slot) is the failure this ADR exists to prevent.

Alternatives considered

  1. Fix the six instances individually, no contract. Rejected — that is the status quo whose failure mode #3408 documents, and #3258 is direct evidence: four rows were fixed and the mechanism that let them diverge was not.
  2. Amend ADR-3180 with a write-path section. Rejected — it is a closed epic whose §7 owners are all read derivations, and CONTRIBUTING.md requires one issue = one ADR-or-PRD = one PR.
  3. Design the contract inside Phase 1's PR, skip this ADR. Rejected for ADR-3180 Alternative 4's reason: Phases 1–4 all depend on it, so it would be set by whatever was convenient in the first code PR, with no reviewable design step.
  4. One mega-PR consolidating all four concerns. Rejected — a CRITICAL blast radius in a single reviewable unit, gsd-test failures unattributable to a concern, and a violation of one-concern-per-PR.
  5. Make frontmatter authoritative over the body. Rejected — the body-is-truth model is deliberate and src/state.cts says so; #3374 is explicit that it is "not a request to make frontmatter authoritative."
  6. Keep both enforcement points and add a parity test. Rejected — ADR-3180 Decision 1: "A parity test proves the copies agree today; it does not stop copy N+1."

Software laws applied

Cross-referenced via /skills-from-the-artificer. Three fired; all three changed this ADR.

  • Greenspun's Tenth Rule — changed the design. The table has accreted five times, which is the "nobody decided to build a language — they just kept solving the next problem" trajectory. It is not a rules engine today (five frozen policies, no conditionals/loops/variables, no user extensibility, all consumers in-repo). The open per-row guard predicate in this ADR's first draft is what would have made it one. Produced Decision 1's closed guard vocabulary and the amendment requirement. Greenspun's prescribed response to an accreting ad-hoc system — "extract, formalize, or replace" — is this ADR.
  • Postel's Law — changed the design. Produced Decision 2's bright line. ADR-3180's rejection of throwing governs external input; an unwired declared row is an internal invariant violation with both ends controlled, which Postel puts on the strict side. Also produced §8.5's divergence warning — "liberal but visible" is the direct answer to #3374's warnings: [].
  • Goodhart's Law — changed the design. "0 write-path bypasses" is a measure becoming a target. Produced Decision 5's enumerated gaming routes and the rule that the guard's zero is never reported without the identity test's result beside it.

Considered and not applicable to this deliverable: Hyrum's Law and Gall's Law govern Phases 1–4's Tier-2 output changes and incremental sequencing and are recorded in Decisions 3, 5 and 6, but Phase 0 ships no behavior. choose-boring-technology — no new dependency; five in-repo guard precedents. conways-law — no ownership boundary at stake.

Cross-references

  • ADR-3180 — the read-side precedent this mirrors; its Decision 4 (a)–(e) is adopted verbatim
  • ADR-2121 — the proven guard mechanism both ADRs extend
  • scripts/lint-state-field-drift.cjs — the guard pattern Decision 5 models
  • scripts/qa-smell-ratchet.cjs — the ratchet invariants Decision 5 adopts
  • scripts/lib/drift-scan.cjs — the shared tree-walk / confinement / sanitizer every guard uses
  • CONTRIBUTING.md § Prohibited: Raw Text Matching on Test Outputs — why reports are typed IR, not prose
  • CONTRIBUTING.md § Fixture provenance (#2371) — why the identity test alone is insufficient
  • Phase sub-issues: #3467, #3468, #3469, #3470, #3471

Guard roster

One row per concern. A blank owner is a concern whose contract is locked (§8) but whose owner does not exist yet.

Concern Owner Guard Scan surface Status
Policy dispatch (§8.1) state-transition.cts (Phase 1) lint-state-write-path-drift.cjs src/ contract only
Unenforced row (§8.2) state-transition.cts (Phase 1) same guard src/ contract only
Write seam (§8.3) state.cts (Phase 2) same guard src/, gsd-core/workflows, commands, agents, skills contract only — ratcheted from Phase 1
The report (§8.4) state-transition.cts (Phase 3) Phase 3 src/ contract only
Stale-but-present (§8.5) state.cts (Phase 4) same guard, baseline 0 src/ contract only

Amendments

Amendment 1 — Phase 1 (#3468) validation: the contract held; a policy had two implementations

Decisions 1–5 needed no change. Every input class Phase 1 hit was expressible in the contract as written, and the closed guard vocabulary of Decision 1 proved sufficient at exactly the size predicted. What the contract did not anticipate was a divergence one level below the policy.

preserve-always had two divergent implementations, and one contradicted its own row. Neither #3408 nor this ADR named it; it fell out of reading the code rather than the write-up.

Field Declared Implemented as Gate
progress preserve-always preserve-always !resync, no delta condition
current_phase_name preserve-always preserve-when-unchanged body Phase: delta, no resync gate

Resolution: the row was wrong, the code was right. current_phase_name is reclassified to preserve-when-unchanged. Its delta-gated behavior is regression-bound by #1743, #1695 and #2736, so changing the code to match the label would have broken deliberate decisions; changing the label is behavior-preserving and makes the table honest, which is the point of the epic. Matrix test C2 pins its exact outputs as literals, because a behavior-preserving change is precisely where a silent mistake hides.

§8.6 resolved: clear is DELETED. No row used it and no executor existed — verified as the single occurrence of the token in src/state-transition.cts, with zero dependents and a clean tsc. Speculative Generality: a policy invented for a need that never arrived. FieldPreservation now has four members, each with exactly one executor.

The closed guard vocabulary is real and small. Decision 1 mandated named guards over a predicate slot. Implementation found exactly one true executor-side guard in the entire codebase — status's 'unknown' sentinel — because stopped_at's ## Session scoping turned out to be caller-side extraction, not an executor condition. Shipped as FieldGuard = 'non-sentinel-unknown' plus FieldMergeStrategy = 'progress-ratchet' for the #2440/#2969 merge. Two single-member unions is deliberate: the alternative is the open predicate slot Greenspun's Tenth Rule rejects, and adding a member remains an amendment here.

Copy count, per Amendment 3's standing rule — "N found by the guard", never "N per the epic":

#3408 scoped Guard found
Write-seam bypasses (Axis 2) 2 4
Policy-dispatch violations (Axis 1) not scoped 7 — 5 field-name branches, plus derive and clear with no executor at all

The four are cmdStateSync, cmdMilestoneComplete, cmdPhaseComplete, and REGENERATE_STATE. patchCore — one of the two this ADR named — is not among them, because it bypasses via stateReplaceField rather than the seam calls. The epic's list was both short and partly wrong, a fourth consecutive confirmation that a hand-maintained list is a lower bound.

Two detectors were built and removed again, recorded because the guard is the instrument this epic trusts:

  1. A prompt-layer detector reported 5 backticked prose mentions as drift — ADR-3180 Amendment 3's recorded false-positive class. CONTRIBUTING.md already settles it: a backticked command reference is a mention. Now gated on inline-code spans.
  2. A stateReplaceField co-occurrence detector for §8.3(b), measured at 29 false positives to 1 true positive — it matched the function's own definition and ~20 calls on frontmatter-free body slices. Banking 29 non-defects to catch one is Decision 5's own "ratchet as a parking lot" route, so shipping it would have made the guard violate this ADR.

DECLARED KNOWN GAP, owned by Phase 2 (#3469). §8.3(b) — patchCore writing over frontmatter — is not detected. It needs genuine dataflow ("is this argument the whole document or a body slice?"), not co-occurrence. Phase 2 both fixes the defect and makes detection tractable, because once the pure pipeline exists the invariant simplifies to "no transition core calls stateReplaceField on unstripped content". Stated here so the gap has a named owner rather than being a silence.

Decision 5's anti-gaming list earned itself twice, in one phase. After the refactor the guard reported policyDispatchViolations: 0 while applyPreserveIfPlaceholder still opened with if (field !== 'milestone_name') return — a branch selected by field name, which §8.1 forbids outright, in a syntactic form Axis 1 did not match. That is the "route it through a differently-named local" row of Decision 5's table, live. The executor was already idempotent, so the test bought nothing and was deleted; Axis 1 is widened to catch field-variable comparisons against literals, with its remaining evasion (renaming the loop variable) declared in the guard's header rather than left implicit. Separately, the guard's own loadBaseline conflated an unreadable baseline with an absent one — a diagnostic collapsing two states into one identical result, which is this epic's defining failure shape reproduced inside the tool built to detect it. Both fixed in Phase 1.

Complexity, measured before and after. applyStatePreservation was 177 lines, cyclomatic 61, cognitive 107, risk_level: critical. It is now a 27-line dispatch loop over four executors of 28, 44, 26 and 3 lines. This was Kernighan's Law's contribution to the design — the justification for the refactor was debuggability, not line count, and the largest remaining unit is the one holding the genuinely intricate #2440/#2969 ratchet.

Amendment 2 — Phase 2 (#3469): §8.3 was over-broad, and the ratchet's target was wrong

Decisions 1–5 held. §8.3's rule did not.

"Every STATE.md write applies the pipeline" is false by design. state sync and REGENERATE_STATE exist to let the body win; preservation exists to stop the body winning. §8.3 now carries the closed exception list above, and both are permanent owner: sanctioned-permanent ratchet entries.

This was not a theoretical over-reach. #3469's own scope line, inherited from the epic, said to route the direct writeStateMd callers through the pipeline — which for cmdStateSync would have inverted the command, silently, with every gate green. The correction came from reading applyPostSyncPreservation's docstring and then verifying the claim against the code, because a stale comment had already misdirected this epic once (CONTEXT.md pointed at a verify.cts:1925 call that had moved to health-diagnostic.cts).

Consequence: Decision 5's and Phase 4's "ratchet to 0" target is wrong. This ADR's guard roster and #3471 both say Phase 4 drives the baseline to empty and deletes the file. It cannot — two entries are permanent. The correct end state is 2 permanent entries, not 0, and the honest report is "0 removable bypasses, 2 sanctioned". A guard that could reach 0 here would only do so by having stopped looking at two real writers.

What Phase 2 actually found in the tree, after fix(#3374) (#3491) landed part of §8.3 upstream mid-epic:

  1. cmdPhaseComplete re-assembles the pipeline. Upstream routed it through applyPostSyncPreservation, but it still calls syncStateFrontmatter directly first. Every step calls an owner, so Axis 2 and an owner-level test both stay green while the composition is duplicated between the adapter and readModifyWriteStateMd, free to diverge. This is ADR-3180 Amendment 2's composition-level re-derivation, repeating on the write side, and §8.3 already forbade it by name.
  2. cmdMilestoneComplete is the remaining real exposure — it writes through writeStateMd, so it gets sync and no preservation, the identical shape #3374 reported for phase.complete. Upstream flagged it as a follow-up in the same docstring; Phase 2 is that follow-up.
  3. Phase 1's declared known gap closes here, as promised rather than re-deferred: §8.3(b)'s frontmatter-write detection becomes tractable once the composition exists, because the invariant simplifies to "no transition core calls stateReplaceField on unstripped content".

Criterion 6 context. All five of the epic's named instances were closed by point fixes while Phase 1 was in flight, so Phase 2 and Phase 4 are driven by characterization tests at the consumer's output (Decision 4(b)/(c)) rather than fail-first tests. Weaker, and stated as such.

(Phase 3 records the §8.4 bucket decision here — though see the epic: fix(#3351) (#3487) appears to have subsumed most of Phase 3.)