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
253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# 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](https://github.com/open-gsd/gsd-core/issues/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.
|