Files
msd-core/docs/adr/1769-state-md-transition-module.md
Tom Boucher 21f0b4316f refactor(#1796): ADR-1769 Path A — finish STATE.md preservation consolidation (#1799)
Extract readModifyWriteStateMd's post-sync preservation block into a pure,
field-classification-table-driven applyStatePreservation in the STATE.md
Transition Module. progress / status / stopped_at now join current_phase_name
as table-governed (getFieldClassification), so a preservation-policy change is
a one-row table edit instead of a per-call-site patch.

This realizes the consolidation ADR-1769 / CONTEXT.md already claimed shipped
('Absorbs readModifyWriteStateMd post-sync preservation block') and routes the
#1264 preservation policy through the single field-classification table — the
bug class is now structurally guarded by the table, not just the call-site
shouldResync flag.

Behavior is byte-identical to the pre-amendment inline block (Hyrum-safe — the
15 readModifyWriteStateMd callers' observable preservation is unchanged):
- state/frontmatter/transition + bug regression suite: 847 pass
- phase/milestone/verify (other RMW consumers): 582 pass
- codex (gpt-5.5/high) adversarial review: CLEAN (58,564-case equiv sweep)

ADR-1769 amendment appended documenting #1796.

Closes #1796
2026-06-27 22:49:52 -04:00

13 KiB
Raw Blame History

ADR-1769: STATE.md Transition Module — intent-based transitions over scattered RMW callbacks

  • Status: Accepted (Phase 7 closeout — all 10 lifecycle/maintenance transitions migrated; bug cluster #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 retired)
  • Date: 2026-06-27 (Phase 0); Phase 7 closeout 2026-06-27
  • Issue: #1769 — epic
  • Supersedes: the policy portions of syncStateFrontmatter (src/state.cts:1667–1743), readModifyWriteStateMd's post-sync preservation block (src/state.cts:2008–2119), and the format-detection duplication across 14 RMW callbacks in state.cts plus direct writeStateMd callers in phase.cts:1770 and milestone.cts:352. Does NOT supersede state-document.cjs's parse/extract/replace primitives — those are deep and stay.

Context

CONTEXT.md's "STATE.md Document Module" entry describes a deep leaf: parse, extract, replace primitives, no persistence or locking. The friction lives in the adapter layer above it.

STATE.md writes happen via three mechanisms today:

  1. readModifyWriteStateMd(statePath, transformFn, cwd, options, clock) — the RMW seam. Called from 14 sites in state.cts, each passing a closure that re-encodes format detection, field preservation, and section mutation.
  2. Direct writeStateMd / syncStateFrontmatter calls in milestone.cts:352 (milestone complete), verify.cts:1899 (regenerateState recovery), and phase.cts:1770 (phase complete state-half, inside writePlanningFileSet).
  3. cmdStateBuildFrontmatter — Encoding 3 of the preservation policy, used by the read path (cmdStateJson).

The preservation policy ("which fields win when frontmatter and body disagree?") is encoded in three places that drift independently:

  • syncStateFrontmatter (state.cts:1667–1743) — preserves status when derived='unknown', preserves milestone_name vs. placeholder, preserves stopped_at/paused_at/current_phase/ current_phase_name/current_plan/progress when derived is empty.
  • readModifyWriteStateMd post-sync block (state.cts:2069–2113) — restores progress block when resync=false, preserves status/stopped_at when body source field unchanged (#1230 delta heuristic).
  • cmdStateBuildFrontmatter (state.cts:1469+) — own copies of the status/progress preservation rules.

The same field has different rules in different encodings. progress has two rules. status has three. stopped_at has two.

Bug cluster this produces: #1760 (prune no-ops), #1761 (sync writes wrong progress), #1743 (patch clobbers curated field), #1695 (patch clobbers current_phase_name), #1264 (resync=false restore), #1255/#1257 (format mismatch), #3242 (curated-progress ratchet). Every fix is per-call-site and doesn't touch the other 13.

The friction shape: ADR-857/1372/1508/3660 correctly identified the leaf modules and gave them depth. The remaining friction is in the adapter layer that didn't finish thinning. The recurring pattern is "seam adopted for locating, hand-rolled code retained for mutating" — tokenizeHeadings was adopted, replaceSection was not.

Decision

Introduce a STATE.md Transition Module as a sibling/super-module of the STATE.md Document Module. Seven design decisions, resolved via /grilling:

1. Module shape: Transition Module owns the full transaction

The Module owns lock → read → apply transition → preserve policy → write. Interface is intent-based: beginPhase(statePath, phaseNum), advancePlan(statePath, planId), etc. The 14 RMW callbacks collapse to 14 one-line transition calls.

Rejected: (B) Field Policy Module (locks stay outside) — leaves lock/scan bugs tangled with policy bugs. (C) Widen state-document — shallow; format-detection + I/O concerns leak in.

2. Method set: 10 transitions (lifecycle + maintenance + milestoneComplete)

beginPhase, advancePlan, completePhase, plannedPhase, milestoneSwitch, milestoneComplete, patch, sync, prune, update.

Rejected: (i) Lifecycle only (5) — leaves maintenance bugs #1760/#1743 alive. (iii) All 16 writers — interface balloons, hurting depth.

3. I/O shape: pure core + injected deps

transitionCore(content, intent, deps) → newContent where deps = { progressProvider, writer, locker, clock }. A thin adapter wires the real I/O. Tests pass stubs.

Rejected: (a) Absorb all four I/O concerns — too much fs surface in the core. (b) Own transaction + lock; delegate disk scan — awkward coupling.

This shape also enables phase.cts:1770's use case: the transition core is called inside writePlanningFileSet(writes) (multi-file transaction), not just inside writeStateMd. Pure function = leverage across multiple orchestration shapes. Concretely: in Phase 3, completePhase runs inside the multi-file ROADMAP+REQUIREMENTS+STATE transaction without the transition core knowing it is inside a transaction. The core only sees (content, intent, deps) → newContent; the orchestrator handles lock acquisition, multi-file atomicity, and write ordering. This isolates the atomicity risk to the orchestration layer, where the existing writePlanningFileSet already owns it.

4. Policy model: field-classification table

Each STATE.md field has a row: { source: body|disk|external|curated|free, preservation: derive|preserve-when-unchanged|preserve-always|clear }. Transitions declare which body fields they touch; the core consults the table.

Rejected: (α) Per-transition hardcoded policy — duplicates the rule × 10 transitions = today's pain. (γ) Per-transition DSL — adds learning cost, still drifts.

The table kills the bug class by construction: patch consulting the table sees current_phase_name is curated and refuses to overwrite unless explicitly named. #1743 impossible.

5. External writers: 2 migrate, 1 stays

  • milestone.cts:352 (milestone complete) → migrates to milestoneComplete transition.
  • phase.cts:1770 (phase complete state-half) → migrates to completePhase transition, called as pure function inside the existing multi-file transaction.
  • verify.cts:1899 (regenerateState recovery) → stays as direct writeStateMd. Factory-reset primitive, not a transition; nothing to preserve.

6. Core scope: writes only

Core owns the table + 10 transitions + format detection + preservation + frontmatter/body invariant. Body section structure (## Current Position, ## Session, etc.) lives as a constants block at the top of the core.

Outside the core:

  • Append-only transitions (addDecision, addBlocker, etc.) — stay on today's RMW. They don't touch curated fields; routing them through the core adds interface width without buying depth.
  • Read path (cmdStateBuildFrontmatter, Encoding 3) — different concern (interpretation, not preservation). Stays separate.
  • verify.cts:1899 regenerateState — factory reset, not a transition.

Rejected: (B) Widen to reads — grows the table past preservation into interpretation. (C) Absorb append-only — interface balloons to ~17 methods.

7. Migration: substrate first, then transition-by-transition

Per ADR-1372 §T6's "high risk, load-bearing, surgical, last" rating, big-bang is the wrong shape. The migration sequence:

  • Phase 0: ADR + CONTEXT.md update (this PR).
  • Phase 1: Substrate — transition core skeleton + table + section constants + beginPhase migration + characterization tests.
  • Phase 2: advancePlan.
  • Phase 3: completePhase + phase.cts:1770 migration (proves pure-core-inside- multi-file-transaction).
  • Phase 4: plannedPhase, milestoneSwitch.
  • Phase 5: milestoneComplete + milestone.cts:352 migration.
  • Phase 6: patch (covers #1743, #1695).
  • Phase 7: sync, prune, update (covers #1760, #1761).

Per-transition discipline: characterization tests first (capture current behavior including the bug-preservation we want to keep, e.g. #1230's delta heuristic), then migrate, then verify old tests still pass, then add bug-fix tests for what the migration fixes.

Rejected: (A) Big-bang — half-finished migration is what we're fixing. (C) Substrate alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure mode).

Consequences

Positive:

  • Bug cluster killed structurally: #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 each become impossible-by-construction or one-row table fixes.
  • ADR-1372 §T6 completed: replaceSection from the markdown sectionizer becomes the body-mutation primitive inside the transition core.
  • Test surface improves: tests pass deps stubs, no .planning/phases/* fixtures required for policy tests.
  • phase.cts:1770 and milestone.cts:352 stop reimplementing format detection and section regexes (the // allow-adhoc-markdown: pre-seam section write-modify lint suppressions in milestone.cts are deleted).

Negative:

  • 10 transitions × characterization tests = substantial test volume in Phase 1 substrate.
  • The field-classification table is a new shared artifact — adding a new STATE.md field means one table row, but a new field class means updating every transition that declares which classes it touches. (Single-enum: 5 classes; expected to be stable.)
  • Migration is sequenced (8 phases, 8 PRs) — the codebase carries both old and new shapes between Phase 1 and Phase 7. Each PR is independently shippable; the old shape is fully retired only at Phase 7.

Neutral:

  • readModifyWriteStateMd and writeStateMd stay exported during the migration; they're used by the 7 append-only callbacks that aren't migrating.
  • cmdStateBuildFrontmatter (Encoding 3) stays as the read path; future work may converge it with the table, but that's out of scope here.

Alternatives considered

  1. Widen STATE.md Document Module to absorb the policy. Rejected — Document Module owns pure parse/extract/replace; widening pulls format detection and preservation policy into the leaf, hurting its depth.
  2. Per-transition hardcoded policy (option α in design). Rejected — duplicates the rule per transition; same field has different rules in different transitions = today's pain.
  3. All 16 writers in core (option iii in design). Rejected — interface balloons to ~17 methods, hurting depth. Append-only transitions don't touch curated fields.
  4. Big-bang migration (option A in design). Rejected — ADR-1372 §T6 rates this surface "high risk, surgical." Half-finished migration is what we're fixing.
  5. Substrate alongside, leave callbacks (option C in design). Rejected — parallel worlds don't converge. ADR-857's "decompose Core" produced today's half-finished state precisely because new code moved and old code stayed.
  6. Defer until ADR-1372 §T6 finishes independently. Rejected — §T6 has been deferred for over a year precisely because there's no consumer for replaceSection in the state path. The Transition Module is the consumer; the two land together.

Phases

Phase Scope Closes issue Bug coverage
0 ADR + CONTEXT.md update #1769 —
1 Substrate + beginPhase #1771 #1255, #1257, #3242
2 advancePlan #1782 —
3 completePhase + phase.cts:1770 #1784 —
4 plannedPhase + milestoneSwitch #1786 —
5 milestoneComplete + milestone.cts:352 #1789 —
6 patch #1791 #1743, #1695
7 sync, prune, update #1793 #1760, #1761

Amendments

#1796 — Finish the preservation consolidation (Path A)

Date: 2026-06-28 · Status: Accepted

Surfaced by an /adr-phase-coverage audit of this ADR (issue #1796): the Consequences claim that the module "Absorbs … readModifyWriteStateMd's post-sync preservation block" was not realized by Phases 0–7. The block stayed inline in readModifyWriteStateMd (state.cts); only current_phase_name's preservation was table-driven. #1264 was fixed by a call-site shouldResync guard rather than the field-classification table, so the bug class was not "killed structurally" as the Consequences (line 161) claimed.

Resolution — Path A ("finish the consolidation"): the post-sync preservation block is now the pure, field-classification-table-driven applyStatePreservation in src/state-transition.cts, consulted via getFieldClassification for all four preserved fields — progress, status, stopped_at, current_phase_name (previously only the last was table-driven). readModifyWriteStateMd calls it. This makes the CONTEXT.md "Absorbs … post-sync preservation block" claim accurate and routes the #1264 preservation policy through the single field-classification table (one policy source, not three drifting encodings).

Behavior is byte-identical to the pre-amendment inline block (Hyrum-safe — 15 callers' observable preservation is unchanged); the full state / frontmatter / transition regression suite (847 tests, including the #1264, #1743, #1695, #1760, #1761, #3242, #1230 characterization blocks) passes unmodified. A codex (gpt-5.5 / high) adversarial review returned CLEAN — no behavior drift across a 58,564-case equivalence sweep, no security surface introduced.

The shouldResync call-site guard remains — it is the transition's declaration of "am I re-deriving from disk?" What changed is that the preservation policy it feeds is now centralized and table-driven rather than re-encoded per writer, which is the consolidation this ADR originally specified.