#4629·gsd-core

Epic: STATE.md writes declare intent and are verified — retire the opaque transformFn contract

Author: trek-eCreated Sep 11, 2026Updated Sep 21, 2026
Labelsarea: workflowtype: choreepic

An approved epic does not approve its children — each child is its own issue before code.

Epic: STATE.md writes declare intent and are verified — retire the opaque transformFn contract

The deliverable is the seam, not the individual fixes. The issues absorbed below are evidence that one piece of behavior has no single owner. They are the symptom list, not the work list. Repairing them where they were reported — N fixes for N issues — leaves the missing owner still missing, and the next occurrence is already being written somewhere else in the tree. An implementation that does that has not closed this epic, even with every symptom gone and CI green.

Closing this epic means three things land together: one declared StateWriteIntent, applied and verified by a single executor in place of the opaque transformFn: (content: string) => string contract exists; the duplicate implementations are deleted rather than kept in sync, because two copies that agree today are the same defect as two that disagree; and enforcement is by construction — the type system (writeStateMd requiring a StateTransaction) plus the existing terminal write-path guard (scripts/lint-state-write-path-drift.cjs, extended), not a re-introduced allowlist — so a reintroduced raw write cannot pass. The first two without the third un-consolidate quietly as soon as someone who has not read this issue touches the area. (Enforcement approach updated per ADR-4629 (#4645): a seeded allowlist was rejected — see The enforcement contract below.)

A defect that cannot be expressed through the seam is a finding about the seam's design and belongs in this issue as a comment — one special case is how the previous consolidation grew its second copy.

This is the shape that worked in #1372 (markdown sectionizer), #2143 (table, bounded mutation, fail-loud) and #2121 (phase identifier): each closed its bug class with a seam, a migration and an anti-divergence guard, none of them by fixing the reported symptoms. A first pass at #4634 proposed four independent fixes and no seam, which is the failure mode this scoping exists to prevent. Splitting delivery across several PRs is expected and welcome; splitting it so that the seam never lands is not.

Nine open confirmed-bug issues are the same defect wearing nine faces: a state verb writes something other than what it meant to write, and reports success anyway. #4535 states the family property outright, having measured it with a snapshot → verb → git diff protocol:

Three state-write defects measured while executing one phase to completion […] none of them is visible from the verbs' own success payloads, which is what makes them expensive.

This is not a parsing bug. The markdown read seam (#1372, src/markdown-sectionizer.cts), the table seam and bounded mutation (#2143, src/markdown-table.cts + withSection), and phase-identifier parsing (#2121, src/phase-id.cts) are all already consolidated and ratcheted. The defect lives one layer above them, in the write path.

Root cause, read from the code

readModifyWriteStateMd (src/state.cts:4465) is the single read-modify-write seam — 16 direct callers, plus re-exports into src/milestone.cts:69 and src/phase.cts:108. Its signature is the problem:

typescript
function readModifyWriteStateMd(
  statePath: string,
  transformFn: (content: string) => string,   // ← opaque
  cwd: string,
  options?: ReadModifyWriteOptions,
  clock?: StateLockClock,
): boolean

The seam transports bytes, not intent. A (content: string) => string cannot tell the executor which field the caller meant to set, or to what — so the executor cannot verify that the write achieved anything, and its return true means only "the file changed", never "the intended state is now on disk." Every verb's success payload is built on that boolean.

Three structural consequences, each of which is a child issue below:

  1. The no-op guard is silent (src/state.cts:4480, if (modified === content) return false). When a body write matches nothing, the transform returns the input unchanged and the verb proceeds. #4315 is exactly this: authoritativeFm lands on the frontmatter unconditionally while the body write no-ops, so current_phase and current_phase_name name two different phases at rc=0.
  2. Intent is re-derived from prose instead of ground truth. #3830's advancePlanCore reads Plan: X of Y out of prose, increments it, and writes it back without ever consulting the plans on disk — while query phase-plan-index in the same binary, on the same tree, at the same moment, returns the correct answer. Two commands disagree and the one that writes is the one that guesses.
  3. updated[] is the attempted field list, not the measured delta. #4419's full-section rewrite destroys hand-written prose and a caller's Resume file, and reports it as updated: ['Last session', …] — in the issue's words, "the call looks like a surgical field update."

The repo has already learned this lesson twice at smaller scale and stopped one layer short. The comments inside the seam say so: #3469/ADR-3408 §8.3 consolidated sync+preservation into syncAndPreserveStateMd "so the composition cannot diverge between the two", and #3872 notes that "an unenumerated option here is silently dropped." Both consolidated the composition. Neither consolidated the intent.

The seam this epic delivers

Replace the opaque transform with a declared intent that one executor applies and verifies:

  • StateWriteIntent — a caller declares the post-state it wants (field → value assertions, section-body replacements, and which of them are required vs best-effort). No caller hands over an anonymous string function.
  • One executor applies the intent, re-reads the written file, and diffs achieved-vs-intended. A required assertion that did not land is a loud failure, not a rc=0 with a cheerful payload.
  • updated[] is populated from the measured disk delta. A field that did not change cannot appear in it.
  • Ground-truth derivations (plan counts, phase completion) resolve through the existing query surface rather than through prose. Where prose and disk disagree, the verb fails or reconciles — it never increments the prose.

The point is that this is reusable code, not a convention. All 16 callers already route through one function; the verification lives there once. A tenth instance of this bug class then has nowhere to live: a verb physically cannot report a write it did not achieve.

The enforcement contract (by construction — not a re-introduced ratchet)

Enforcement rides the type system (writeStateMd requiring a StateTransaction, ADR-3473 §8.6) plus the existing terminal Axis-2 guard (findRawStateWrites / targetsStatePath in scripts/lint-state-write-path-drift.cjs), extended to the residual writers. A seeded-then-drained local/no-adhoc-state-write allowlist is deliberately not introduced: ADR-3473 §8.6 (#3871) retired exactly that baseline / stale-entry / --baseline machinery once the write seam's exceptions became unrepresentable in the type system, and ADR-3408 Amendment 1 built a stateReplaceField co-occurrence detector, measured it at 29 false positives to 1 true, and removed it. Re-adding it would walk the repo's own doctrine backwards. (Superseded the earlier "The ratchet" proposal per ADR-4629, #4645.)

Absorbed issues — evidence of the missing seam, NOT a work list

Each row is closed as a duplicate of this epic; its specifics are preserved here so nothing is lost.

Issue Defect Arm
#3830 advance-plan derives plan position from ## Current Position prose, never checks it against plans on disk; stale prose incremented, written back, reported "advanced": true intent from ground truth
#4315 planned-phase --name moves current_phase_name without current_phase when Current Position is prose — half a transition, rc=0 verified post-state
#4419 record-session replaces the whole ## Session body when the Last session label is absent, destroying prose and a non-default Resume file measured updated[]
#4535 Three state-write defects (roadmap.update-plan-progress ticking other plans' rows, five state.* subcommands corrupting progress.completed_phases, phase.complete) — none visible from the verbs' payloads the family thesis
#4551 state.add-blocker's unanchored /None/gi strip at src/state.cts:1687 runs over the whole body and silently corrupts existing prose (inverts a live finding's meaning) bounded, declared edits
#4564 cmdStateSnapshot's decisions/blockers selectors never match the shipped template — both arrays always empty, and state validate passes fail-loud on unreadable
#4316 applyPreserveIfPlaceholder's placeholder test is exact equality, so a decorated placeholder (milestone (ACTIVE — Name)) is never restored — regression of the closed #3171 matching against the shipped template
#4210 progress.percent composes as min(plan, phase) — the bar freezes for most of every phase and regresses when a phase gains plans derived-aggregate ownership
#4314 planned-phase writes the phase-local --plans count into the project-wide progress.total_plans aggregate derived-aggregate ownership

The last two define a second arm: progress.* gets one owner and stated invariants (phase-local is not project-wide; the reported fraction must not regress when a denominator grows). #4210 is explicit that the min() cap guards a real requirement — ROADMAP-declared-but-unrealized phases must not yield a false 100% — and that guarantee must survive the fix.

Done when

  • No caller of the state write path supplies an anonymous (content: string) => string
  • Every required assertion in a StateWriteIntent is verified against the re-read file before the verb returns success
  • updated[] is derived from the measured disk delta at every verb
  • progress.* has a single owner, with the non-regression and scope invariants under property test
  • Enforcement is by construction (type system + the extended terminal Axis-2 guard); no seeded local/no-adhoc-state-write allowlist is introduced (per ADR-4629)
  • Every absorbed issue above has a failing-first regression test that passes
  • Guards and selectors are tested against templates/state.md itself, not a hand-written fixture (#4564, #4316)

Non-goals

  • Repairing the absorbed issues individually at their existing call sites without building the seam. That is the pattern that produced them, and it leaves the epic open.

  • Re-opening markdown structure, table, or phase-identifier parsing — #1372, #2143 and #2121 own those and are closed

  • Changing the STATE.md template format or any user-facing field name

  • Removing the min() cap in progress.percent without preserving the requirement it serves

Generated with Claude Code