docs(#3467): adr-3408 state.md write-path behavior contract (#3474)

* docs(#3467): adr-3408 state.md write-path behavior contract

* docs(#3467): correct write-seam caller list and adr heading depth

Review findings from the Standards and Spec axes, fixed in place:

- ADR behavior contract demoted from H2 to `### 8` with `#### 8.x`
  subsections, matching ADR-3180's `### 7` / `#### 7.1` precedent the
  front matter claims to follow.
- CONTEXT.md placed the STATE.md factory-reset primitive at
  verify.cts:1925. It moved to health-diagnostic.cts:337 when
  cmdValidateHealth migrated onto the rule table (#3309); verify.cts
  now has no writeStateMd call. The design intent was correct — only
  the address was stale.
- A repo-wide scan found three direct writeStateMd callers, not two:
  cmdStateSync, cmdMilestoneComplete, and the REGENERATE_STATE remedy.
  The last is documented as a sanctioned permanent exception — it is
  a factory reset, so preservation would restore the values it was
  invoked to discard.
- phase.cts comment citation corrected to :2953-2957.
- Amendment 4 attribution corrected: the recorded owner-file exemption
  failure is roadmap-parser.cts; the state.cts transfer is this ADR's
  own extrapolation.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-14 11:03:48 -04:00
committed by GitHub
parent 8bbb3eaabc
commit dbc8b4077a
4 changed files with 282 additions and 1 deletions

View File

@@ -59,7 +59,7 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout
Module owning STATE.md parse, field extraction, field replacement, status normalization, frontmatter reconstruction, and `## Current Position` section scoping (`stateCurrentPositionSlice`, #1956 — the one owner of that scope for the read path; `state.cts`'s `matchCurrentPositionSection` is a thin alias over it, and the `drift-guard phase-status` seam consumes it, so the #2956 archive-shadowing fix cannot be re-derived into a second copy — the byte-exact mutation path served by `state-transition.cts`'s `locateCurrentPosition`/`sliceCurrentPositionSection` is a deliberately separate, un-consolidated locator, #3187). `stateFieldValue` (#3187) is the single owner of the #1760 frontmatter-then-body field fallback chain, consolidating the 14 re-derivations of that ladder onto one scope-carrying (`complete`/`truncated`/`unscoped`/`unreadable`) primitive. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`. **Commit provenance (#2573):** `state_head` records the full sha STATE.md was written against, stamped by `syncStateFrontmatter` and omitted entirely outside a git repo. `readStateHeadFreshness(cwd, stateHead)` (`src/state.cts`) is the single derivation consumed by both `validate.health` (W024) and smart-entry — it returns `{ state_head, current_commit, commits_behind, commit_stale }` with **tri-state** `commit_stale`: `null` = unknown (no stamp, no git, or a stamp that is not an ancestor of HEAD after a history rewrite), `false` = known fresh, `true` = the codebase has moved. Mirrors the graphify commit-staleness contract deliberately. It is a freshness PROXY, never a drift measurement: `rev-list` counts unrelated commits and the stamp restamps on every state write, so a low count means STATE.md was written recently, not that its contents are accurate — it must never gate.
### STATE.md Transition Module
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` caller in `milestone.cts:552` (phase.cts's former direct caller has since been migrated away); verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call (`verify.cts:1925`). Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). `state_head` (#2573) is classified `{ source: 'free', preservation: 'derive' }` — an ambient git read recomputed on every write, like `last_updated`; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't.
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` (phase.cts's former direct caller has since been migrated away). **Three direct `writeStateMd` callers remain as of `next` @ `5452f1a70`: `cmdStateSync` (`state.cts:3682`), `cmdMilestoneComplete` (`milestone.cts:865`), and the `REGENERATE_STATE` remedy (`health-diagnostic.cts:337`)** — the last is the factory-reset primitive, which rebuilds STATE.md from scratch and therefore deliberately wants no preservation. ADR-3408 §8.3 governs all three; Phase 1's whole-repo drift guard, not this line, is the authoritative count. *(Location correction: this entry previously placed the factory-reset primitive at `verify.cts:1925`. It moved to `health-diagnostic.cts` when `cmdValidateHealth` migrated onto the rule table (#3309); `verify.cts` now contains no `writeStateMd` call. The design intent was unchanged — only the address was stale.)* Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). `state_head` (#2573) is classified `{ source: 'free', preservation: 'derive' }` — an ambient git read recomputed on every write, like `last_updated`; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't.
### STATE.md Status Lifecycle (ADR-2207)
The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → `<version> milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal).

View File

@@ -545,6 +545,16 @@ When multiple executors run within the same wave, two mechanisms prevent conflic
1. `--no-verify` commits — Parallel agents skip pre-commit hooks (which can cause build lock contention, e.g., cargo lock fights in Rust projects). The orchestrator runs `git hook run pre-commit` once after each wave completes.
2. **STATE.md file locking** — All `writeStateMd()` calls use lockfile-based mutual exclusion (`STATE.md.lock` with `O_EXCL` atomic creation). This prevents the read-modify-write race condition where two agents read STATE.md, modify different fields, and the last writer overwrites the other's changes. Includes stale lock detection (10s timeout) and spin-wait with jitter.
#### The STATE.md Write Path
Locking decides *who* writes. A separate contract decides *what survives the write*.
STATE.md carries the same fact in two places — YAML frontmatter and the document body — and the body is authoritative. Every write therefore re-derives frontmatter from the body, which raises the question the write path exists to answer: when a re-derived value disagrees with the one already in frontmatter, which wins?
`FIELD_CLASSIFICATION` (`src/state-transition.cts`) answers it per field, declaring a `preservation` policy — `preserve-when-unchanged`, `preserve-always`, `preserve-if-placeholder`, `derive`, `clear` — that `applyStatePreservation` executes after `syncStateFrontmatter` re-derives.
**[ADR-3408](adr/3408-state-write-path-preservation.md) is the normative contract** for that path: one executor per declared policy, one write seam, and reports computed from what was actually persisted rather than from what the caller intended to write. Where the contract and the code disagree, the code is the defect. It is the write-side counterpart of [ADR-3180](adr/3180-planning-semantic-model-single-owner.md), which gave each read-side derivation a single owner.
---
## Data Flow

View File

@@ -0,0 +1,270 @@
# 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](https://github.com/open-gsd/gsd-core/issues/3408) is the **scope authority** (`epic` + `approved-enhancement`), which is why this ADR carries its number. [#3467](https://github.com/open-gsd/gsd-core/issues/3467) is the Phase-0 tracking sub-issue this PR closes — the epic stays open until Phase 4 merges. Convention follows [ADR-3180](3180-planning-semantic-model-single-owner.md) and [ADR-3128](3128-adaptive-runtime-evidence.md).
- **Supersedes:** nothing.
- **Relationship to prior work:** the **write-side mirror** of [ADR-3180](3180-planning-semantic-model-single-owner.md), 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](https://github.com/open-gsd/gsd-core/issues/3468)) → Phase 2 (one write seam, [#3469](https://github.com/open-gsd/gsd-core/issues/3469)) → Phase 3 (report from `postFm`, [#3470](https://github.com/open-gsd/gsd-core/issues/3470)) → Phase 4 (stale-but-present + identity test + ratchet to 0, [#3471](https://github.com/open-gsd/gsd-core/issues/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](3180-planning-semantic-model-single-owner.md) — the read-side precedent this mirrors; its Decision 4 (a)–(e) is adopted verbatim
- [ADR-2121](2121-phase-identifier-parsing-consolidation.md) — 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](https://github.com/open-gsd/gsd-core/issues/3467), [#3468](https://github.com/open-gsd/gsd-core/issues/3468), [#3469](https://github.com/open-gsd/gsd-core/issues/3469), [#3470](https://github.com/open-gsd/gsd-core/issues/3470), [#3471](https://github.com/open-gsd/gsd-core/issues/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
*(None yet. Phase 1 records its `clear` resolution (§8.6) and its guard's measured baseline here; Phase 3 records the §8.4 bucket decision.)*

View File

@@ -252,6 +252,7 @@ These govern the system as it stands. Cite these.
| [ADR-2980](2980-payload-carried-error-is-a-degraded-result.md) | A payload-carried `error` key is a degraded result, not a fault | Accepted | — |
| [ADR-3180](3180-planning-semantic-model-single-owner.md) | Planning Semantic Model — Single Owner per Derivation | Accepted | — |
| [ADR-3212](3212-lexical-seam-consolidation.md) | The Lexical Seam — Safe Pattern Construction, Line-Terminator Normalization, and Tokenizer-First Stateful Grammars | Accepted | — |
| [ADR-3408](3408-state-write-path-preservation.md) | STATE.md Write Path — One Declared Policy, One Write Seam | Accepted | — |
| [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
### Proposed