Files
msd-core/docs/adr/1817-state-md-rebuild-derivability-contract.md
Tom Boucher dfff25f1bd docs(#1817): add adr-1817 state.md rebuild derivability contract (#1828)
* chore(context): scrub nul byte from consent-store predicate

CONTEXT.md line 206 (Capability Consent Store predicate) contained a
literal NUL byte between ${realpath(projectRoot)} and <id> documenting
the disk-key join format. The byte was intentional but made the file
binary-detected, breaking grep/rg searches (hit while preparing ADR-1817).

Replace with the 4-char \x00 escape. Preserves the byte-level disk-key
documentation; surrounding prose 'prototype-pollution-safe NUL-joined
keys' carries the semantic context. file(1) now reports 'Unicode text'.

* docs(#1817): add adr-1817 state.md rebuild derivability contract

Phase 0 of approved feature #1817 (epic). Lands the design contract for
the new `rebuild` transition in the STATE.md Transition Module (ADR-1769):

- ADR-1817 (new): `rebuild` is the capstone 11th transition. Six design
  decisions: (1) core substrate, non-toggleable, same tier as the other
  10; (2) section taxonomy — re-derivable (`## Current Position` prose
  from frontmatter, `## By-Phase Progress` table from disk) vs preserved
  (`## Session`, `## Decisions`, unknown sections) vs de-duplicated
  (`## Session Continuity Archive`); (3) orphaned data is logged + dropped
  with a structured audit entry in `## Rebuild Log` (ADR-1411 provenance
  principle); (4) idempotency is a hard guarantee — a no-mutation rebuild
  appends no log entry; (5) non-overlapping scope with `sync` (3
  frontmatter fields, auto-triggered); (6) orthogonal to
  `auto_prune_state` (rebuild reconciles with current canonical sources,
  prune removes by retention policy).

- CONTEXT.md (STATE.md Transition Module section): list `rebuild` as the
  11th intent; add contract predicates mirroring the ADR.

- docs/adr/README.md: add ADR-1817 to the index (Accepted).

Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's
per-field transitions. Phased per ADR-1817: this PR (Phase 0) closes
#1817; Phase 1 (#1827) lands `rebuildCore` + intent dispatch + drift-class
unit tests; Phase 2 (#1826) lands `cmdStateRebuild` CLI + dry-run +
integration tests + docs + changeset.

* docs(#1817): reword state-doctor alternative to satisfy docs-parity lint

The docs-parity-live-registry test scans every docs/*.md (including
docs/adr/) for slash-command tokens and asserts each one resolves to a
live command in the registry. The rejected-alternative #4 in ADR-1817
mentioned a hypothetical `/gsd:state-doctor` workflow, which tripped
the lint (`unknown command token(s): [/gsd:state-doctor]`).

Reword to 'standalone state-doctor workflow' (no slash prefix). The
extractor is aggressive — backticks and space-preceding tokens are both
extracted per the test's own polarity-invariant cases — so the only
sound fix is to not form a slash token at all for hypothetical names.

Verified locally: `node --test tests/docs-parity-live-registry.test.cjs`
now passes 31/31 (was 30/1).
2026-06-29 15:27:22 -04:00

280 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-1817: STATE.md rebuild — derivability contract (capstone transition)
- **Status:** Accepted (Phase 0 — ADR + CONTEXT.md update; lands ahead of Phases 1–2)
- **Date:** 2026-06-29
- **Issue:** [#1817](https://github.com/open-gsd/gsd-core/issues/1817) — epic
- **Builds on:** [ADR-1769](1769-state-md-transition-module.md) (STATE.md Transition Module). Adds the 11th transition (`rebuild`) on top of ADR-1769's 10 lifecycle/maintenance intents.
- **Supersedes:** nothing. Extends ADR-1769's transition set; does not revisit its design.
## Context
ADR-1769 landed the STATE.md Transition Module with 10 intent-based transitions
(`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`,
`milestoneComplete`, `patch`, `sync`, `prune`, `update`) and a field-classification
table that killed the per-call-site preservation-policy bug cluster (#1760, #1761,
#1743, #1695, #1264, #1255, #1257, #3242). Each transition touches **individual
fields**.
A second bug class survived ADR-1769: **body-structure drift** that no current
command can reconcile. `syncCore` (the lightest-weight transition) only patches
three frontmatter fields — `Total Plans in Phase`, `Progress`, `Last Activity` —
and intentionally does not re-derive body structure. `buildStateFrontmatter`
re-derives all frontmatter from body + disk scan but does not touch the body
itself. The result: **the body can diverge from ground truth indefinitely while
`gsd-tools state sync` reports `synced: true`.**
Observed drift signatures (full list in epic #1817):
- `## Current Position` prose fields (Phase, Status, Current Plan) contradict
frontmatter after a milestone switch or prune.
- `## By-Phase Progress` table has orphaned rows for phases from a prior
milestone or rows with zero-padded phase IDs that were renamed.
- Template-placeholder field values (`[phase name]`, `[date]`) left in place
when an AI agent wrote partial state.
- Duplicate `## Session Continuity Archive` blocks from repeated
`state record-session` calls on a corrupt file.
- `stopped_at` in frontmatter sourced from the wrong section (an archive block
rather than the current `## Session` block) — bug #2444's guard only applies
in `buildStateFrontmatter`, not to the body itself.
- `progress.total_phases` in frontmatter correct, but `Phase: [N] of [M]` prose
still shows the old milestone's count.
Three open issues sit in this defect class: **#1776** (`cmdStatePrune` phase
fallback matches any `| Phase | N |` table cell, not `## Current Position`
prose → requires scoped body extraction), **#1761** (`state sync` writes wrong
progress when ROADMAP lacks versioned milestone headings → a rebuild would
re-derive from disk + ROADMAP together), **#1591** (`phase.complete` mis-parses
`<details>`-wrapped roadmaps and garbles counters → a rebuild can reconcile
from canonical disk sources rather than parsing ROADMAP mid-transition).
The deeper shape: **every body-level drift bug today requires a per-bug regex
fix.** Ten-plus closed issues (#1658, #1659, #1668, #1446, #1230, #948, #549,
#500, #363, #316) each added a narrow guard that didn't prevent the next
variant. A `rebuild` transition that re-derives the **canonical body sections**
from ROADMAP + phase dirs + session history would resolve the entire class
without per-bug patches.
## Decision
Add `rebuild` as the **11th intent** in `transitionCore` (ADR-1769 §6 "Core
scope: writes only"). Six design decisions, resolved via `/grilling`:
### 1. The `rebuild` intent is a maintenance transition, same tier as the other 10
`rebuild` is a STATE.md Transition Module method, dispatched from
`transitionCore`'s switch alongside `sync`, `prune`, `update`, etc. Pure core
`(content, intent, deps) → newContent`, same shape as ADR-1769 §3.
**Capability tier (ADR-857 analog):** `rebuild` is **core substrate**, not a
Feature Capability. It is non-toggleable, lives inside the transition module,
and is not subject to ADR-857 capability consent/overlay. This mirrors ADR-550
§83's "verifier↔predicate contract is core/non-toggleable" rule for the
verification seam: the derivability contract documented here is the state-seam
equivalent.
*Rejected:* (B) Implement `rebuild` as a Feature Capability that users opt into
— rejected because the bug class is core STATE.md correctness, not optional
behavior. (C) Keep `rebuild` outside the Transition Module (a sibling
utility) — rejected: it must own the same lock→read→apply→preserve→write
transaction ADR-1769 §1 specified for the other 10 transitions; a sibling
utility would re-encapsulate that machinery and drift.
### 2. Section taxonomy: derived vs preserved
Each STATE.md body section is classified as **re-derivable** or **preserved**.
`rebuild` consults the taxonomy; it never re-derives a preserved section and
never preserves a re-derivable one verbatim when the canonical source
disagrees.
| Section | Class | Source of truth | Rebuild behavior |
|---|---|---|---|
| `## Current Position` prose | re-derivable | Frontmatter (which `buildStateFrontmatter` already derives correctly from disk + ROADMAP) | Re-derive each prose field from the corresponding frontmatter field; replace verbatim. |
| `## By-Phase Progress` table | re-derivable | Phase dirs on disk (same source as `buildStateFrontmatter`'s disk scan) | Re-derive the entire table from disk; drop orphaned rows. |
| `## Session` block | preserved | Human-curated current-session data | Preserve verbatim. (Only the *current* `## Session` block; archived sessions are de-duplicated per §4.) |
| `## Decisions` | preserved | Human-curated decision log | Preserve verbatim. Staleness is `pruneCore`'s concern, not rebuild's. |
| `## Session Continuity Archive` | preserved, de-duplicated | Prior session snapshots | Keep the most-recent N (configurable; default 3); drop duplicates; preserve kept entries verbatim. |
| `## Rebuild Log` | appended (new section) | The rebuild transition itself | Append one entry per rebuild that mutated the file; never re-derive or edit prior entries. |
| Any other `## …` section | preserved (unknown) | Human-curated | Preserve verbatim. Rebuild does not recognize or rewrite sections outside the taxonomy. |
**Section ordering is invariant.** Rebuild rewrites the *content* of
re-derivable sections in place; it does not reorder sections, insert new
sections (other than `## Rebuild Log` if absent), or remove sections.
*Principle:* derive what is derivable, preserve what is curated, log what is
dropped. (Postel's Law applied to a state file: be liberal in what you accept
— any drifted input — and conservative in what you send — canonical form for
derived, verbatim for preserved.)
*Rejected:* (α) Treat all sections as re-derivable — rejected: destroys
human-curated content (session notes, decisions). (β) Treat all sections as
preserved — rejected: this is the status quo; the drift class survives.
### 3. Orphaned data: log + drop (audit trail mandatory)
When `rebuild` encounters canonical-source-disagreement that requires dropping
data, it MUST append a structured entry to `## Rebuild Log` recording:
- `timestamp` (ISO-8601, from the injected `clock`)
- `kind` — one of `orphaned-row`, `placeholder-removed`, `archive-deduplicated`,
`wrong-section-source`, `milestone-count-stale`, or `section-rewritten`
- `section` — which body section was mutated
- `before` / `after` — the dropped/changed content (truncated to 512 chars per
entry to bound log growth)
- `reason` — short structured string explaining the canonical source that won
`## Rebuild Log` is itself **preserved** (per §2). Rebuild never rewrites or
truncates prior log entries; it only appends. A separate prune step (out of
scope here) governs log retention.
**Why log everything:** dropping user-adjacent data without a trace is hostile
even when the drop is correct. The log gives the user an undo path (manual
re-add) and gives the maintainer a debugging signal when rebuild drops
something it shouldn't have. (Hyrum's Law mitigation: the drop is observable,
the audit trail is the contract.)
*Rejected:* silent drop — rejected: violates the ADR-1411 resolution-provenance
principle that mutation decisions report what they did, not fall open silently.
### 4. Idempotency is a hard guarantee
`rebuild` is **idempotent**: invoking it twice in succession on the same file
produces no change on the second invocation. This is testable and tested.
The idempotency contract has two parts:
1. **Body content idempotency:** re-running rebuild on a file rebuild just
canonicalized produces byte-identical `## Current Position` and
`## By-Phase Progress` sections.
2. **Rebuild Log idempotency:** a rebuild that mutates nothing appends no log
entry. (This is what makes the second-invocation case truly byte-identical
— without it, the second run would always append a no-op log entry and
violate idempotency.)
The log-appends-only-on-mutation rule is the load-bearing constraint. If
rebuild wrote a log entry unconditionally on every invocation, idempotency
would break.
### 5. Interaction with `sync` — non-overlapping scopes
`sync` and `rebuild` compose; they do not compete.
| Transition | Scope | Trigger | Latency |
|---|---|---|---|
| `sync` | 3 frontmatter fields (`Total Plans in Phase`, `Progress`, `Last Activity`) | Auto-triggered on every state transition | Lightweight, runs on every transition |
| `rebuild` | Body structure (`## Current Position`, `## By-Phase Progress`, archive dedup) | Manual (`gsd-tools state rebuild`) | Heavier; reads disk + ROADMAP; explicit user invocation |
Running `sync` after `rebuild` is safe: `sync`'s 3 fields are a strict subset
of what `rebuild` reconciled (in canonical form), so sync's derivation will
produce the same values rebuild just wrote. Running `rebuild` after `sync` is
also safe: rebuild re-derives body from canonical sources; sync's just-written
frontmatter is one of those sources.
`sync` stays as the auto-triggered lightweight path; `rebuild` is the
user-invoked heavy reconciliation. Neither subsumes the other.
### 6. Interaction with `auto_prune_state` — orthogonal concerns
`rebuild` does NOT prune. Pruning (removing data that is no longer relevant,
e.g. decisions older than N sessions, prior-milestone state) is a separate
concern governed by `auto_prune_state` and `pruneCore`.
The distinction:
- **Rebuild reconciles** body with current canonical sources. A `## By-Phase
Progress` row for a phase that no longer exists on disk is dropped because
it is *canonical-mismatched*, not because it is *old*.
- **Prune removes** data based on age/staleness policy. A `## Decisions`
entry from 6 months ago stays under rebuild (preserved) but may be removed
by prune based on retention policy.
The two compose: rebuild first (reconcile with canonical sources), then prune
(remove per policy). Rebuild never makes pruning decisions; prune never
re-derives structure.
## Consequences
**Positive:**
- The body-structure drift bug class is killed structurally. #1776, #1761,
and #1591 each become either directly fixable by `rebuild` or indirectly
addressable (the rebuild provides the scoped body extraction those bugs
need).
- The derivability contract is a new correctness invariant: STATE.md body
is **derivable from canonical sources at any time**, not just incrementally
updatable. This is the capstone property ADR-1769's per-field transitions
couldn't deliver alone.
- The audit log gives the maintainer a debugging signal when STATE.md editing
(manual or AI-driven) produces drift that rebuild later reconciles.
- Future drift classes (anything not in the §2 taxonomy today) can be added
by extending the taxonomy + a new `kind` in the log enum, without
re-touching the transition core's dispatch shape (Gall's Law: extend, don't
rewrite).
**Negative:**
- A new body section (`## Rebuild Log`) is added to STATE.md. Older GSD
versions reading the file ignore the section (preserved verbatim by
`readModifyWriteStateMd`'s post-sync block — the section name is not in
the field-classification table, so it falls through as "unknown, preserved").
- The derivability contract is a new shared artifact: any future STATE.md
body section must declare its taxonomy class. Adding a new re-derivable
section is a non-trivial change (rebuild must learn the derivation rule);
adding a new preserved section is mechanical.
- The first invocation of `rebuild` on a long-lived project will produce a
substantial audit log entry (the project's accumulated drift is reconciled
in one pass). This is honest — the drift existed; rebuild surfaces it —
but users may be surprised by the log size on first run. Mitigation:
`--dry-run` flag (Phase 2) previews the diff before writing.
**Neutral:**
- `rebuildCore` is a pure function over `(content, intent, deps)`, callable
inside any orchestration shape (single-file write, multi-file transaction,
dry-run preview). Same property that let ADR-1769 §3 run `completePhase`
inside `writePlanningFileSet`.
- The existing 10 transitions are unchanged. `transitionCore`'s switch grows
from 10 cases to 11; the missing-case-compile-time-error guarantee
(ADR-1769 §1) extends to the new case.
## Alternatives considered
1. **Fix each body-level bug individually (status quo).** Rejected: already
done for 10+ closed issues. Each fix is a narrow regex guard that doesn't
prevent the next variant. Does not scale; the open issues (#1776, #1761,
#1591) are evidence.
2. **`state sync` expansion — extend `syncCore` to cover body structure.**
Rejected: `sync` is intentionally lightweight and auto-triggers on every
transition. Making it re-derive body structure would make every state
transition pay the disk-scan + ROADMAP-read cost, and would couple
auto-triggered behavior to a heavier and riskier code path. The
manual/auto split (§5) is the right factoring.
3. **Regenerate STATE.md from scratch (nuke-and-rebuild).** Rejected: loses
curated human content (session notes, decisions, archives). The
derivability contract is *selective* — derived sections re-derive,
preserved sections survive — which is exactly what a nuke-and-rebuild
cannot do.
4. **A standalone `state-doctor` workflow outside the transition module.** Rejected:
same drift-from-canonical-shape risk that motivated ADR-1769's
consolidation. A workflow that bypasses the transition module re-imports
the lock/scan/preservation machinery and re-creates the bug class.
5. **Defer until ADR-1769's amendments (#1796) finish independently.**
Rejected: ADR-1769 is closed (Phase 7 closeout + #1796 amendment landed).
There is no consumer-driven sequencing constraint; the rebuild transition
composes cleanly with the existing 10.
## Phases
This epic (#1817) is implemented in three phases, each its own PR. Phase 0
closes this issue (the epic); Phases 1 and 2 close their own sub-issues.
| Phase | Scope | Closes issue | Bug coverage |
|---|---|---|---|
| 0 | ADR + CONTEXT.md update (derivability contract, preserved-vs-derived taxonomy, idempotency, sync/prune interaction) | #1817 | — |
| 1 | `rebuildCore` body + `rebuild` intent dispatch case + drift-class unit tests | #1827 | surfaces the class; #1776, #1761, #1591 become directly addressable |
| 2 | `cmdStateRebuild` CLI + `--dry-run` / `--verbose` + integration tests + `docs/commands/state.md` + changeset | #1826 | end-to-end reconciliation available to users |
Per-transition discipline (inherited from ADR-1769 §7): characterization tests
first (capture the drift signatures we want to reconcile), then implement
`rebuildCore`, then verify existing `pruneCore` / `syncCore` tests still pass,
then add idempotency tests.