This commit is contained in:
230
docs/adr/4629-state-write-intent-beyond-frontmatter.md
Normal file
230
docs/adr/4629-state-write-intent-beyond-frontmatter.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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.
|
||||
@@ -289,6 +289,7 @@ Decided in principle, not yet ratified. Do not cite as settled architecture.
|
||||
| [ADR-3646](3646-per-task-content-resolution-seam.md) | Per-task external-tracker content-resolution seam | Proposed | — |
|
||||
| [ADR-3889](3889-process-exit-contract.md) | One exit-code registry — 0 and 1 are free, everything else is allocated | Proposed | — |
|
||||
| [ADR-3942](3942-emitted-drift-ack-commit-trailer.md) | The emitted-drift acknowledgment is PR-lifetime data — it belongs in a commit trailer, not the working tree | Proposed | — |
|
||||
| [ADR-4629](4629-state-write-intent-beyond-frontmatter.md) | STATE.md write intent beyond frontmatter — bounded, verified writes | Proposed | — |
|
||||
| [ADR-4650](4650-path-containment-and-filename-classification-seam.md) | One path-containment predicate and one filename-classification helper | Proposed | — |
|
||||
|
||||
### Superseded, Retired, and Legacy
|
||||
|
||||
Reference in New Issue
Block a user