Files
msd-core/docs/adr/3408-state-write-path-preservation.md
Tom Boucher 1218d76d62 refactor(#3468): dispatch state preservation on the declared policy, not the field (#3495)
* test(#3468): add write-path drift guard, ratcheted at its measured baseline

Guard-first, per ADR-3180 Amendment 3's standing rule that a phase builds
and runs its guard BEFORE its scope is fixed, and states its copy count as
'N found by the guard', never 'N per the epic'.

Measured, not assumed:

  Axis 1 (policy dispatch, ADR-3408 section 8.1) — 7 violations, RED by
  design. 5 field-name-keyed getFieldClassification('literal') branches
  plus 2 declared FieldPreservation members with no executor at all
  (derive, clear). This is the fail-first evidence for the refactor.

  Axis 2 (write seam, section 8.3) — 4 bypasses, ratcheted. Epic #3408
  scoped this at two writers; the whole-repo scan found four, and one the
  epic named (patchCore) is not among them because it bypasses via
  stateReplaceField rather than the seam calls. Fourth consecutive time an
  epic's copy count proved a lower bound.

Two detectors were written and removed again before this commit, both
recorded in the file header rather than silently dropped:

  - A prompt-layer detector that reported 5 backticked prose mentions as
    drift. That is ADR-3180 Amendment 3's recorded false-positive class,
    and CONTRIBUTING.md already settles it: a backticked command reference
    is a mention. Now gated on inline-code spans.

  - A stateReplaceField co-occurrence detector for section 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 the 'ratchet as a parking lot' gaming route
    Decision 5 names, so it is a DECLARED KNOWN GAP owned by Phase 2
    (#3469), which both fixes it and makes its detection tractable.

* test(#3468): failing-first coverage for policy dispatch and the loud failure

Matrix sections A, B and C from 50-test-matrix.md.

Expected RED against this tree, confirmed by static trace rather than
assumed:

  B1, B2, B3 — an unwired declared preserve-when-unchanged row must throw
  with code STATE_PRESERVATION_UNWIRED_ROW and a structured .field. Today
  src/state-transition.cts:314 silently continues.

  A4 — a whitespace-only snapshot is restored today, because the guard is
  .length > 0. Required behavior is skip.

Everything else is characterization, locking in behavior the refactor must
preserve. C1 is table-driven over every FIELD_CLASSIFICATION key; C2 pins
current_phase_name's exact outputs as literals, because its row is being
reclassified preserve-always to preserve-when-unchanged as a
behavior-preserving change and nothing else would catch a drift. C3 is a
seeded fast-check property (seed 3468, 200 runs, replay data on failure).

A22 is deliberately NOT a behavioral test. Whether 'derive' has an explicit
executor is not observable through applyStatePreservation's public API — it
is a structural property, and the drift guard's unimplemented_policy axis is
what enforces it. That split is ADR-3408 Decision 5's own pairing: the lint
is the structural metric, the test is the outcome metric, and neither is
reported alone.

* refactor(#3468): dispatch preservation on the declared policy, not the field

Implements ADR-3408 sections 8.1, 8.2 and 8.6.

applyStatePreservation is now one loop over FIELD_CLASSIFICATION dispatching
on the row's preservation value, with four small executors — one per
FieldPreservation member. No branch is selected by field name. Zero
literal-argument getFieldClassification calls remain.

Behavior-preserving for 16 of 20 input classes. The four that change:

  - An unwired declared preserve-when-unchanged row now THROWS
    (code STATE_PRESERVATION_UNWIRED_ROW, structured .field) instead of
    silently continuing. This fires only on an internal invariant violation
    with both ends in our own source; a drifted, malformed or unparseable
    user STATE.md must never reach it, which is section 8.2's bright line
    and what test B8 proves through the real CLI.
  - derive gained an explicit no-op executor. That is what makes the throw
    decidable: 'policy says do nothing' is now distinguishable from 'nobody
    wired this'.
  - current_phase_name's row is corrected from preserve-always to
    preserve-when-unchanged. The row was wrong, not the code — it has always
    been delta-gated on the body Phase line, so preserve-always had two
    divergent implementations. Behavior is unchanged and test C2 pins it.
  - A whitespace-only snapshot is no longer restored; the check is trimmed.

clear is deleted from the FieldPreservation union — no row used it and no
executor existed. Speculative Generality: a policy invented for a need that
never arrived. Verified zero dependents.

The caller folds six dedicated pre/post parameters into one bodyDeltas map
keyed by field, so all seven preserve-when-unchanged rows travel one channel
instead of two. Two shapes for one kind of data is why the executor needed
per-field branches at all.

Also fixed, found while reviewing the refactor rather than deferred:

  - applyPreserveIfPlaceholder opened with a field-name literal test, which
    section 8.1 forbids outright. The executor is idempotent, so the test
    bought nothing. The drift guard could not see it, so Axis 1 is widened
    to catch field-variable comparisons against literals — the guard
    reported zero while a violation sat in the file it polices, which is
    Goodhart's gaming-by-indirection.
  - loadBaseline conflated an unreadable baseline with an absent one. A
    guard whose own diagnostic collapses two states into one identical
    result reproduces the exact failure shape this epic exists to remove.

* docs(#3468): record Phase 1 validation as ADR-3408 Amendment 1

Amendment 1 records what Phase 1 found, per ADR-3408 section 8's rule that a
behavior it does not state is not decided:

- preserve-always had TWO divergent implementations; current_phase_name's
  row was wrong and is reclassified, behavior unchanged.
- section 8.6 resolved: clear is deleted, zero dependents.
- the closed guard vocabulary is real and has exactly one true member,
  because stopped_at's scoping turned out to be caller-side extraction.
- copy count found by the guard: 4 write-seam bypasses where the epic
  scoped 2, and patchCore — one of the two it named — is not among them.
- two detectors built and removed again, with their measured false-positive
  rates, so nobody re-attempts them.
- a DECLARED KNOWN GAP for section 8.3(b), owned by Phase 2.
- Decision 5's anti-gaming list earned itself twice in one phase.

Also adds the changeset fragment.

* test(#3468): fix review findings — try/finally, stale clear allowlist, ratchet owners

Standards axis, both hard violations:

  - tests/state-write-path-drift-guard.test.cjs wrapped stdout/argv/exitCode
    restoration in try/finally inside the test body. CONTRIBUTING.md:356
    forbids it outright, and the correct t.after() pattern was already in
    use two lines up in the same test.

  - tests/state-transition.test.cjs still listed 'clear' as an allowed
    FieldPreservation value in the row-enumeration test AND the
    getFieldClassification property test, after this PR deleted it. A stale
    allowlist weakens the property's negative space — it would accept a
    resurrected clear row as valid.

Contract tension, resolved rather than left:

  ADR-3408 section 8.3 requires each ratchet entry carry the issue owning
  its removal. All four shipped with owner: null. The guard was right not to
  INVENT one, but the owners are known from the phase plan, so recording
  them is not inventing: phase.cts -> #3469, state.cts and milestone.cts ->
  #3471, health-diagnostic.cts -> sanctioned-permanent.

  Rather than a JSDoc caveat, --baseline now MERGES prior owner values on
  the (file, source) key, so a mechanical regeneration can no longer
  silently discard curated provenance. Verified by regenerating twice.

* fix(#3468): sanitize attacker-controlled fields on every guard output path

Isolated security review, MEDIUM, confidence 8/10.

findSeamBypasses and findPromptSeamUses built findings with an UNSANITIZED
`file`, while the co-located `source` on the same object was correctly
wrapped in sanitizeForReport. On a fork PR a filename is exactly as
attacker-controlled as a source fragment — a repo can legally track a
filename carrying C1 control bytes or bidi overrides.

The raw value reached two paths: --json stdout, and the COMMITTED baseline
JSON via buildBaselineEntries. JSON.stringify neutralizes C0 controls but
does NOT escape C1 (0x7f-0x9f) nor the bidi/zero-width range
sanitizeForReport exists to strip — which is the precise threat the guard's
own header names. Only the human formatter was safe.

Sanitization now happens at CONSTRUCTION, so every consumer inherits it
rather than each output path having to remember. The same defect was present
on `field` and `policy` and is fixed alongside. Double-sanitization in the
formatter is left in place, verified idempotent: escaped output is ASCII and
cannot re-match the control/bidi classes.

Also: the guard was not referenced anywhere in package.json, so nothing ran
it. A drift guard nobody runs is not a guard, and ADR-3408 Decision 5 assumes
it runs. Wired into lint:ci beside its sibling drift guards; it was already
green on this tree, so the chain stays green.

* chore(#3468): re-curate ratchet after an upstream rewording of a tracked bypass

The rebase onto origin/next turned the guard red on its first real day, which
is the ratchet working rather than a defect.

c90ae479f fix(#3350) reworded cmdPhaseComplete's syncStateFrontmatter call
onto one line and changed its third argument. Because entries are keyed on
(file, trimmed source text) rather than a line number, that single upstream
edit registered as BOTH a stale acknowledgment and an unrecorded site — the
two-sided signal the design intends, forcing a human to look rather than
letting a tracked bypass drift out of view.

The owner-preserving merge behaved exactly as designed: three owners survived
because their keys were unchanged, and phase.cts's dropped to null because its
source text is genuinely a different key. Re-curated to #3469, the phase that
owns its removal.

Note for Phase 2: c90ae479f is #3350's fix landing independently on next —
one of the two instances Phase 2 was scoped to drive fail-first. Surfaced to
the epic rather than absorbed silently.

* test(#3468): derive B1's fixture from the table so it cannot go stale

Checkpoint 2 came back with 2 failures of 33803, both B1:

  actual   'current_phase_name'
  expected 'current_plan'

The implementation was right and the test was stale. B1 hand-built a
bodyDeltas literal intending current_plan to be the ONLY unwired row, but it
also omitted status, stopped_at and current_phase_name — all three of which
became preserve-when-unchanged rows in THIS PR. Table order puts
current_phase_name first, so the throw correctly named it.

B1 now builds from neutralBodyDeltas() and deletes exactly one key, which is
what its own comment always claimed it did. A future table change can no
longer silently make it assert the wrong field.

Audited every other bodyDeltas literal in the file: four exist, all correct —
two enumerate all seven rows explicitly, two pass {} where the emptiness is
the point of the test. Roughly thirty other sites already derive from the
helper.

Also renames the local unchchangedChanged to lastActivityDescChangedDeltas.
A typo'd identifier that happens to work is still a Mysterious Name; noted
during research and fixed now that this change touches the file.

* chore(#3468): re-curate ratchet and fold the seam channel into the shared helper

The rebase onto be9329b10 fix(#3374) was a true semantic conflict, resolved
rather than handed back, because the resolution was determinable:

That PR extracted the post-sync preservation pass into a shared
applyPostSyncPreservation helper — which is ADR-3408 section 8.3, i.e. a
piece of Phase 2's own deliverable, landing upstream. Its structure is kept
wholesale; this branch's contribution is applied INSIDE it.

That combination had to be checked rather than assumed. Upstream's helper
wires only FOUR bodyDeltas keys and still passes status / stopped_at /
current_phase_name through six dedicated parameters. This branch reclassifies
current_phase_name to preserve-when-unchanged, deletes those six parameters
from StatePreservationInput, and makes an unwired declared row THROW. Taking
upstream's file as-is would therefore have thrown on EVERY STATE.md write.

The helper now wires all seven rows through the single channel. Verified
7-to-7 against FIELD_CLASSIFICATION, with a clean tsc — which is the real
proof the dedicated parameters are gone, since they no longer exist on the
input type.

The ratchet also caught the same phase.cts call being reworded a second time,
reporting it as both a stale acknowledgment and an unrecorded site. Re-curated
to #3469. Recording the tradeoff plainly: keying on (file, source text) means
an upstream reword of a tracked line needs re-curation, where keying on line
numbers would churn on every unrelated edit. ADR-3180 Decision 4(e) chose
source text deliberately, and the owner-preserving merge added earlier covers
the common case where the text is unchanged.

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

---------

Co-authored-by: sim <sim@local>
2026-08-14 13:25:30 -04:00

33 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. 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.

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.

(Phase 3 records the §8.4 bucket decision here.)