# ADR-4629: STATE.md write intent beyond frontmatter — bounded, verified writes - **Status:** Proposed - **Date:** 2026-09-11 - **Issue:** [#4629](https://github.com/open-gsd/gsd-core/issues/4629) is the scope authority (`epic` + `approved-enhancement` + `area: core`); this ADR carries its number, per the ADR-3180/3408/3473 convention. - **Supersedes:** nothing. - **Relationship to prior work:** the **fourth** application of ADR-3180's single-owner mechanism, and a **successor to ADR-3408** in the way ADR-3473 was — a new ADR carrying its own epic number that extends the mechanism to a surface the prior one scoped out, not an amendment to a closed epic. ADR-3180 owned read-side derivations; ADR-3408 owned the STATE.md **frontmatter** write path; ADR-3473 owned parsing/enumeration/return contracts and added "retire the guard once the seam makes it redundant." This ADR owns **the STATE.md write path past frontmatter** — body/section writes, prose-derived intent, cross-field invariants, and the completion-ratio **composition** — and adds the one property none of the four cover: **bounded mutation**. It explicitly **reverses ADR-1769 Decision 2's rejected option (iii)**. On the aggregate concern it does **not** supersede ADR-3180 §7.6 (which owns the rounding kernel and the scope discriminator); it **adds the composition rule §7.6 never wrote down** and inherits §7.6's guard. Symbol names are the durable anchors; line references are approximate ranges and drift. ## Context Nine `confirmed-bug` issues, all closed as duplicates into #4629, share one signature: **a STATE.md write persists something other than what the verb meant, and the success payload does not show it.** ADR-3408 §5 tabled this shape one layer down (frontmatter). The nine are the residue ADR-1769 Decision 2 left when it chose 10 named transitions over all 16 writers — this ADR is the record that the rejection was wrong for that residue, so an implementer reading ADR-1769 (and not this ADR) does not re-derive the narrower scope. Prior art that already owns adjacent surface, which #4629's body did not cite: | ADR | Decided | Status | |---|---|---| | ADR-1769 | intent-based transitions over scattered RMW callbacks; `transitionCore(content, intent, deps)` | Accepted (10 transitions migrated) | | ADR-1817 | STATE.md rebuild derivability contract | Accepted | | ADR-3180 §7.6 | completion-ratio **rounding kernel** + scope discriminator (rules 1–4) | Accepted | | ADR-3408 | one write seam (`readModifyWriteStateMd`, `src/state.cts:4465`); report from `postFm` (§8.4, `reconcileReportedFields`) — **frontmatter only** | Accepted; phases #3468–#3471 closed | | ADR-3473 | enforcement by construction; retire the guard once the seam makes it redundant (§8.6) | Accepted; phases landed | ## Decisions **1. Extend the intent surface to the residual writers (the ADR-1769 D2 reversal).** Residual `readModifyWriteStateMd` callers on the opaque `transformFn: (content) => string` contract migrate to a declared-intent path. The interface-balloon cost ADR-1769 weighed is now outweighed by measured defects. **2. `StateWriteIntent` EXTENDS `StateTransaction`; not a parallel type.** `src/state-transition.cts:432` already carries a frozen transaction (`snapshot`, `bodyDeltas`, `resync`, `explicitProgressField`, `deriveProgressKeys`). Body/section intent and required-vs-best-effort assertions extend that type. **3. Bounded mutation is a first-class verified property, checked against a DECLARED intent scope.** #4551 and #4419 are collateral-damage defects: the intended field/section changed; damage is elsewhere. Only a snapshot → apply → diff asserting the write stayed within its declared scope catches them (#4535's protocol). A writer declares its scope: most declare a narrow one (named fields/sections). A writer that legitimately rewrites broadly declares a **broad-scope intent** and is checked against *that* — it is audited, not exempted. `milestoneSwitch` (a `kind:'open'` transition that rewrites the whole frontmatter by contract) is the worked case: it declares a whole-frontmatter scope, so bounded mutation still verifies its write matches its stated intent. The two `kind:'rebuild'` writeStateMd callers (`cmdStateSync`, `REGENERATE_STATE`) are the maximal case — unbounded by contract (ADR-3408 §8.3), and §8.3 says so rather than implying the type exempts more than the two writers it names. **4. Report scope extends the measured-delta contract to body/section — it does not rebuild it.** ADR-3408 §8.4 computes `updated`/`failed` from `postFm` after preservation via `reconcileReportedFields` (seven commands), for frontmatter. This extends measured-delta reporting to declared body/section edits through the same helper. **5. Enforcement by construction — no re-introduced ratchet; the scan surface is declared.** A seeded `local/no-adhoc-state-write` allowlist is rejected (Alternatives). Enforcement rides the type system (`writeStateMd` requiring a `StateTransaction`) plus the existing terminal Axis-2 guard (`findRawStateWrites` / `targetsStatePath` / `RAW_WRITE_CALL_START_RE`, `scripts/lint-state-write-path-drift.cjs:606,564`), extended. Its scan surface is declared (§ Scan surface). **6. The completion-ratio composition + the write-side aggregates get an owner — scoped, not wholesale.** `progress.*` is NOT claimed wholesale: ADR-3180 §7.6 already owns the rounding kernel and scope discriminator, and `scripts/lint-completion-ratio-drift.cjs` already guards re-derivation. This ADR owns only (a) the **composition** §7.6 never stated (§8.5 below), inheriting §7.6's guard, and (b) the **write-side aggregates** `total_plans` (#4314) and `completed_phases` (#4535) — which need nothing beyond the seam: bounded mutation and measured-`updated[]` already catch them. ## §8 The behavior contract — THIS SECTION IS THE SOURCE OF TRUTH - Where this section and the code disagree, the code is the defect. - A behavior not stated here is **not decided** — 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**, via a PR editing this section + the Amendments log. - Each rule carries a **Status**: *Enforced* or *Required — Phase N* (equally binding). **§8.1 Intent declaration — *Required — Phase 2*** - **Owner.** `StateWriteIntent` extending `StateTransaction` (`src/state-transition.cts`). - **Rule.** No residual `readModifyWriteStateMd` caller supplies an anonymous `(content) => string`; each declares field/section assertions marked required vs best-effort, and its mutation scope (narrow or broad). **§8.2 Verified post-state (achieved == intended) — *Required — Phase 2*** - **Rule.** Every **required** assertion is verified against the re-read file before success; a missed required assertion is a loud failure, never rc=0. `updated[]` for body/section writes is the measured disk delta via the extended `reconcileReportedFields`. **§8.3 Bounded mutation — *Required — Phase 2*** - **Rule.** A write changes nothing outside its **declared** scope; a change beyond it is a loud failure. A broad-scope writer (e.g. `milestoneSwitch`, whole frontmatter) is checked against its broad declared scope, not exempted. The two `kind:'rebuild'` writeStateMd callers (`cmdStateSync`, `REGENERATE_STATE`) are the maximal, unbounded case by contract; the typed `StateTransactionKind` names those two and no more. - **Failure signal.** A write whose disk delta exceeds its declared scope. **§8.4 Report bucket for best-effort assertions — *Open question, Required — Phase 3*** - **Question (undecided; the forcing function).** §8.1 introduces required-vs-best-effort assertions; §8.2 states only what happens to *required* ones. A best-effort section assertion that no-ops — does it appear in `failed[]`, or is it silently absent? This is the ADR-3408 §8.4 "not found vs found-then-restored" bucket question recurring for body/section writes. It is **decided in Phase 3 and recorded as an amendment here** — not resolved silently in a PR. **§8.5 Completion-ratio composition — *Required — Phase N*** - **Question.** How do plan-level and phase-level completion compose into one reported milestone percentage? - **Owner.** The composition entry point — `computeProgressPercent` (`src/state-document.cts`) — under ADR-3180 §7.6's owner. §7.6 rules 1–4 remain in force and are not restated; this adds the composition §7.6 does not state. - **Rule.** A reported milestone percentage satisfies all three: - **(a) No false completion from partial scope.** It MUST NOT report 100 while any ROADMAP-declared phase of the milestone is unrealized or incomplete. A declared phase holding no plan files contributes **0** to the numerator, never a vacuous 1. *(This is the guarantee `min()` served; §7.6 rule 2 covers only the empty-denominator case.)* - **(b) Monotone under completing work.** Holding the declared phase set fixed, the reported percentage is non-decreasing as work completes; growth of a plan denominator inside an already-counted phase MUST NOT reduce credit already earned. **A genuine increase in declared scope — a new phase added to ROADMAP — MAY lower the percentage; that is honest reporting, not a regression.** - **(c) Plan-level resolution inside the in-flight phase.** Completing a plan in the current phase MUST move the reported percentage. - **Note.** (a) and (c) are **jointly unsatisfiable by `min(planFraction, phaseFraction)`** — once `planFraction` exceeds `phaseFraction` the output pins for the rest of the phase (#4210 measured `percent` constant while plans went 26/39 → 39/39). The composition must **change**, not the cap be tuned. - **Failure signal.** (a) is a plausible wrong value (§5 family shape); (b)/(c) are silent (every path exits 0). Pinned by literal-output tests, not a guard scan. - **Pinned control (literals, per ADR-3408 Amendment 1's C2 precedent).** `computeProgressPercent(11, 11, 1, 3, 'complete') === 33` — phase 1's plans all complete, phases 2–3 declared with no plan files (#4210 case C, the direct test of (a)). Reads 33 today; MUST still read 33 after any composition change. ## Scan surface (declared — ADR-3180 Decision 4(d)) Every **authored** surface that can express the derivation, never the emitted output: `src/**/*.cts`, plus the prompt layer `gsd-core/workflows`, `commands`, `agents`, `skills` (which shell out to `state patch` and post-process). `gsd-core/bin/**` is emitted from `src/` — scanning it double-reports every `src/` finding and inflates the "N found by the guard" census, so it is **excluded**. ## Guard roster | Concern | Owner | Guard | Scan surface | Status | |---|---|---|---|---| | No raw STATE.md write outside the seam | `writeStateMd` requires `StateTransaction` (type) | `findRawStateWrites` (Axis 2, terminal) `lint-state-write-path-drift.cjs:606,564` | authored (above) | Enforced (extended here) | | Composition not re-assembled | `syncAndPreserveStateMd` | `findCompositionBypasses` (terminal, #3871) | `src/**` | Enforced | | Completion-ratio re-derivation | ADR-3180 §7.6 kernel/discriminator | `lint-completion-ratio-drift.cjs` (**inherited**; §7.6 rules 1–2) | `src/**` | Enforced | | Bounded mutation (declared scope) | verifying executor | positive-control test (drives it red) | n/a (test) | Required — Phase 2 | | §8.5 composition (a)/(b)/(c) | `computeProgressPercent` | literal-output tests (pinned control above) | n/a (test) | Required — Phase N | ## Migration order (guard/type first; phased; blast radius CRITICAL) `get_impact` rated `readModifyWriteStateMd` CRITICAL. Per ADR-3408 §6: (1) extend the terminal Axis-2 guard + `StateTransaction`/`StateWriteIntent` type surface (no behavior change); (2) the bounded-mutation verify with its positive control; (3) migrate residual writers in batches, each with its failing-first regression; (4) the §8.5 composition, pinned by the literal control. Sequential — each batch sits inside one CRITICAL blast radius. ## Consequences - **Positive.** A verb cannot report a write it did not achieve, nor silently damage a region outside its declared scope; broad-scope writers (`milestoneSwitch`) become audited rather than unchecked; the reported milestone percentage stops lying (§8.5). The nine defects lose their home in one epic. - **Cost.** The declared-intent surface is wider than the opaque transform; residual writers each declare an intent scope. This is ADR-1769 D2's interface-balloon cost, now paid **deliberately**. - **Residual risk.** The two `kind:'rebuild'` writers remain unbounded by contract (§8.3 states it). §8.4's best-effort bucket is undecided until Phase 3. ## Software laws applied (via `/skills-from-the-artificer`) - **Greenspun's Tenth Rule** — *moved §8.3's shape.* Bounded mutation rides declared intent scope + the closed, typed `StateTransactionKind`, not an open per-write predicate or an allowlist. - **Postel's Law** — *moved where the verify lives.* Bounded mutation is the strict-internal boundary (a verb is strict about what it writes), the same boundary ADR-3408 Decision 2 drew, enforced at the seam. - **Hyrum's Law** — *moved the migration contract.* Residual-writer migration changes observable Tier-2 output (`updated[]`; verbs' payloads callers and the verifier read); each migration PR discloses the delta. - **Gall's Law** — *moved the migration order.* Sequenced phases (guard/type → verify → batched migration → composition), like ADR-3408's five, not a big-bang rewrite across the CRITICAL blast radius. - **Goodhart's Law** — *moved the guard roster.* "0 raw-write bypasses" is a lagging metric; the roster pairs it with the declared authored-only scan surface and a positive control, never the zero alone (ADR-3408 §5). ## Governance The nine defects are closed as duplicates, so this ADR + its children are the only record. File the children as tracked `approved-enhancement` issues (an approved epic does not approve its children), each naming the absorbed defect(s) it fixes with a failing-first regression as its evidence column. The epic stays open until the final phase merges. ## Alternatives rejected - **Amend ADR-3408.** Closed epic; §8 owners all frontmatter; `CONTRIBUTING.md` requires one issue = one ADR-or-PRD = one PR; ADR-3473 set the successor-not-amendment precedent. - **Split `progress.*` into its own epic.** Rejected — splitting at a new granularity is the same move ADR-1769 D2 made, and it lets #4629 close with #4210/#4314 alive elsewhere, so "Done when" stops meaning the defect class is gone. The ownership collision that motivated it resolves by scoping Decision 6 (§8.5), not by splitting. - **Seeded-then-drained `local/no-adhoc-state-write` ratchet.** Retired machinery (ADR-3473 §8.6, #3871) and a measured-and-removed detector (ADR-3408 Amendment 1: `stateReplaceField` co-occurrence, 29 FP:1 TP). - **A parallel `StateWriteIntent` type.** Two owners for one concept — the defect class this epic closes. - **Exempting whole-document writers by kind.** Leaves `milestoneSwitch` (a `kind:'open'` transition) unchecked; a broad-scope declared intent audits it instead. ## Non-goals - Re-opening markdown structure / table / phase-id parsing (#1372, #2143, #2121 own those). - Changing the STATE.md template format or any user-facing field name. - Removing the `min()` cap without preserving the requirement it serves — §8.5 replaces the composition while keeping guarantee (a). - Superseding ADR-3180 §7.6 — this inherits it and adds the composition rule it never stated. ## Cross-references ADR-1769 (intent transitions — this ADR reverses its Decision 2 (iii)); ADR-1817 (rebuild derivability); ADR-3180 §7.6 (rounding kernel + scope discriminator, inherited by §8.5); ADR-3408 §8.3/§8.4 (the frontmatter write seam + report contract this extends); ADR-3473 §8.6 (successor precedent + the retired ratchet). ## Amendments *(none yet — stub per convention; amendments to §8 land here.)* ## Citation notes (symbol anchors, for the child issues) - #4551: `src/state.cts:1711`, scoped to the Blockers `sectionSpan.body` slice — unanchored **within** the section, not the whole body. A "whole body" test is a false premise. - #4314: `plannedPhaseCore` (`src/state-transition.cts:~2193–2290`); the `total_plans` write is inside it. Cite the symbol, not the line. - Caller counts are method-dependent (~9–11 `transitionCore` vs ~16–18 `readModifyWriteStateMd`); the load-bearing fact is the gap = the residue.