Development record
Flow-State Drift Becomes Visible Before It Compounds
⚠️ Superseded by ADR-26 (flow-next is dropped; its runtime
.flow/state store no longer exists, so there is no flow-state to drift against). Preserved as historical record per the supersession lifecycle.
Press Release
abcd ships a standing flow-state drift detector — a read-only checker that compares the
.flow/.checkpoint-spc-*.jsonruntime blocks against the<git-common-dir>/flow-state/store and exits non-zero on divergence. Wired as a local pre-commit hook (pre-commit/local only — see the corrected framing under What's In Scope: both inputs are git-untracked, so a CI gate is infeasible), the detector catches the exact desync that spc-6 (predecessor store; Phase 1 reconciliation) had to repair by hand: completed specs reporting0/N tasks done,flowctl validate --allflagging done-specs INVALID because the runtime state store was never persisted. Once drift surfaces in a pre-commit run, it gets fixed in the moment instead of accumulating until the next reconciliation pass."I committed a task closure and the pre-commit hook told me my flow-state store and my
checkpointfile disagreed about three tasks — including one I hadn't actually finished yet," said Diana, contributor. "Five minutes of investigation showed the runtime store had stale rows from a Ralph run that crashed partway through a loop pass. I fixed it, re-committed, and moved on. The alternative would have been: don't notice, accumulate three more drifts over the next month, then spend half a day reconciling everything in a sweep like the predecessor store's spc-6."
Why This Matters
spc-6 (predecessor store) fixed the flow-state desync that abcd-cli had accumulated: .git/flow-state/ was never persisted (deliverables squashed into the initial commit), flowctl validate --all defaulted every task's status to todo, and three closed specs (spc-1, spc-2 and spc-4, all predecessor store) reported 0/N because the runtime state store and the committed task JSONs disagreed silently. spc-6 reconciled by hand. spc-6's ## Boundaries / non-goals explicitly deferred the standing drift detector — a read-only checker that would catch the desync from recurring — as a new feature with its own intent.
That intent is this one.
The status quo is fragile in a specific way. Before the 0.20.21 → 1.1.1 flow-next migration, abcd had a stricter-than-flow-next flow-task-definition-drift pre-commit hook (check_task_drift.py) that covered exactly this surface. The 1.1.1 migration removed it, and flow-next 1.x ships no equivalent. So the exact desync this spec repaired by hand has no automated guard against silently recurring (per .work/issues.md 2026-05-17 line 240). Until a drift detector exists, periodic manual re-verification (flowctl validate --all + flowctl specs) is the only safety net — and "periodic" means "when someone remembers", which means "after the drift has already cost someone an afternoon".
The drift detector is project-agnostic in the same sense spc-6 (predecessor store) was: every abcd project that uses flow-next accumulates flow-state, every one of those projects can desync the runtime store from the checkpoint files, and every one of those projects benefits from the same read-only check.
What's In Scope
- A new script (provisional:
scripts/abcd/check_flow_state_drift.pyor similar — location decided at plan) that:- Reads
.flow/.checkpoint-spc-*.jsonruntime blocks (the canonical per-taskstatus,claim_note,claimed_atfields). - Reads the runtime state store at
<git-common-dir>/flow-state/tasks/*.state.json. - Reports any divergence with a structured
Finding(matching abcd's existinginternal/core/lint/lint_prompts.pyFindingshape) and exits non-zero on any finding. - Supports
--jsonfor machine consumption,--codesfor code-contract discovery, and a default human-readable text output.
- Reads
- A pre-commit hook wired in
.pre-commit-config.yaml. (Corrected at plan, spc-41, predecessor store: NOT scoped to staged.flow/paths — the hook runs on every commit, because the store changes independent of staged files and a staged-file filter would blind the detector. See spc-41 (predecessor store) R5.) - ~~A CI workflow~~ — corrected at plan (spc-41, predecessor store): pre-commit/local only, NO
CI gate. Both inputs are git-untracked (
.flow/.checkpoint-*is gitignored; the store lives under.git/flow-state/, which git never tracks), so a CI checkout has nothing to scan — a CI job would be always-green false assurance. - Documentation in
05-internals/06-lint.md(the lint code contract) and whichever05-internals/page covers flow-state structure.
What's Out Of Scope
- Auto-repair. The detector is read-only. Repair tooling
(
flowctl checkpoint restoreis the existing flow-next-side option, but it has the overwrite-defs problem that T1 of spc-6 (predecessor store) documented) is a separate intent if it turns out the hand-fix path is too cumbersome. - Coverage beyond task
status. The detector flags status divergence (which was the specific drift of spc-6, predecessor store). Per-taskclaim_noteorclaimed_atdrift, spec-levelnext_taskdrift, or dependency-graph drift are out-of-scope for v1. - flow-next-side fix. If flow-next 1.x re-introduces an equivalent hook, this intent should still ship abcd's stricter variant — abcd's drift appetite is lower than flow-next's default. Stay coordinated, but don't block on upstream.
Acceptance Criteria
- Given a fresh repo where
.git/flow-state/matches.flow/.checkpoint-*.jsonexactly, whencheck_flow_state_drift.pyruns, then exit code is 0 and no findings are emitted. - Given a repo where one task's runtime state differs from its checkpoint (e.g. checkpoint says
done, state store saystodo), when the detector runs, then exit code is non-zero and the finding names the task id, the divergent field, the checkpoint value, and the state-store value. - Given a contributor staging a change that introduces flow-state drift, when they commit, then the pre-commit hook fires and blocks the commit with the same finding output the detector emits standalone.
- ~~Given a PR whose head carries flow-state drift its base does not, when CI runs, then the drift-check workflow fails the PR check.~~ (Corrected at plan, spc-41 (predecessor store): no CI gate exists or can exist — both inputs are git-untracked, so a CI checkout carries no drift to detect. The pre-commit hook is the only automated trigger.)
- Given the detector emits a finding, when a contributor reads
05-internals/06-lint.md, then the finding's code is registered there (provisional code:FS001— exact allocation at plan).
Open Questions
- Strict mode vs. recoverable mode. Should the detector also flag the
absence of
<git-common-dir>/flow-state/(which is the spc-6 (predecessor store) starting state — the store had been wiped), or only divergence between an existing store and the checkpoints? Lean: missing-store is itself a finding (an empty store is worse than a wrong one), but a--allow-empty-storeflag exists for fresh-clone scenarios. - Pre-commit scope. Run on every commit, or only when
.flow/paths are staged? Tighter scope (only.flow/) is faster; looser scope catches drift introduced by non-.flow/work (which shouldn't happen but occasionally does). Lean tight. (Resolved at plan, spc-41 (predecessor store): LOOSE — the hook runs on every commit. The store is git-untracked and changes independent of staged files, so the tight scope would miss exactly the drift the detector exists to catch. Cost is measured and bounded instead — spc-41 (predecessor store) M5.) - Finding code allocation.
FS001for status divergence; reserveFS002–FS005for the deferred coverage extensions (claim_note, claimed_at, next_task, dependency-graph). Decide if the reservation is worth declaring up-front in06-lint.md § 1or only when those extensions ship. - Interaction with
flowctl checkpoint restore. If a contributor runsrestoreand the operation overwrites a definition field, does the detector flag that as drift on the next commit, or does it know to recognise a fresh restore? The cleanest answer is: detector flags it, contributor reads the diff and decides. But this could trap a contributor who legitimately neededrestore.
Related
- spc-6 (predecessor store; Phase 1 reconciliation) — the spec that surfaced the gap and deferred this intent. Frame this work as "the standing version of what spc-6 (predecessor store) did by hand".
.work/issues.md2026-05-17 line 240 — the canonical entry recording the gap.- spc-18 T2 (predecessor store) — the pre-commit-vs-CI parity work; this intent's hook should adopt the same scoping pattern spc-18 T2 (predecessor store) settles.
05-internals/06-lint.md— the lint-code contract; this detector registers there alongside the existing IL/MG/PQ/TM families.- flow-next 0.20.21
check_task_drift.py— the historical reference for what coverage looked like before the 1.1.1 migration removed it.
Frontmatter
| id | itd-49 |
|---|---|
| type | intent |
| lifecycle | superseded |
| kind | standalone |
| severity | nitpick |
.abcd/development/intents/superseded/itd-49-flow-state-drift-detector.md
open on GitHub ↗ · commit history ↗
Referenced by1
- adr-26A native minimal spec layer with the companion harness `ccpm` as the primary deeper backendsuperseded by
mentions9
- adr-34Intent lifecycle and phase scheduling are orthogonal axes
- dcn-26080200000000042026-08-02 — iss-39: record-lint gains `record_schema`, the first rule that reasons ACROSS the record's stores (ADRs, intents, specs, the issue ledger) rather than inside one, and the `superseded/` exemption narrows for the intent tree: both rules that read it (`intent_lifecycle`, `intent_impact_valid`) now run there too, while the spec-store rules (`spec_lifecycle`, `spec_id_unique`) still honour it as before and the cross-store `record_schema` never consulted it at all. Four invariants, one scan: a cross-reference field names a record the corpus has; a supersession is declared from BOTH sides; a filename and the id inside it agree; and every lifecycle directory is enumerated, so an undeclared bucket is a finding rather than a state no rule reads. Scope is deliberate on two axes. (1) Cross-references are checked in the machine-readable frontmatter fields (`supersedes`, `related_adrs`, `related_intents`, `builds_on`, `blocked_by`), not in bare prose: a frontmatter handle is a claim that the record exists and is a live input, whereas prose legitimately narrates ids that do not resolve ("itd-38's id was released, not reserved"; a test fixture's `itd-9999`; adr-29's account of what ADR-6 settled) — arming prose would have cost ten false positives against one true one, the same false-positive budget that scopes GL002. (2) A handle resolves either to a file or to a `supersedes:` declaration elsewhere, because the ADR lifecycle PRUNES a superseded record and keeps the trace in its successor — that is what distinguishes adr-4/adr-8/adr-14..18 (pruned, accounted for) from adr-6 (cited, never accounted for). The exemption narrowing is the root cause of the corpus defect it exposes: `intent_lifecycle` is a schema rule, but the `superseded/` bucket exempted its own records from the one rule that checks a supersession is recorded, so itd-47 and itd-49 sat there for months with a prose blockquote and no `superseded_by` field. Being historical excuses a record from how it is WRITTEN, never from being well-formed. `superseded_by` also widens from `^itd-\d+` to `^(itd|adr)-\d+`: an ADR that redecides the question an intent rested on retires that intent as surely as a successor intent does, and refusing to spell that forced those two records to say nothing at all. Corpus repairs in the same change: adr-6 is dropped from the two `related_adrs` lists and the two brief pages that cited it re-point at adr-29 (which records the two-stage redaction model and states in its own body that adr-6's decision stands — adr-29 does not supersede it, so claiming it does would have written a falsehood to clear a finding); adr-12 and adr-32 gain the bidirectional pair the ledger-location redecision always implied, adr-12's status moving to `superseded` and the index row with it; itd-47/itd-49 gain `superseded_by: adr-22`/`adr-26` with `kind_at_supersession`, and those ADRs name them back; and the pre-existing one-way links itd-32→itd-31 and itd-31→itd-48 gain their reverse halves. CHANGELOG impact `additive`, matching iss-38's disposition on the same axis: the rule ships in the lint engine `abcd docs lint` runs, and any abcd-managed repo gains it by declaring its own `record_stores`.
- iss-39record schema validation: adr-6 is cited as a live design input but does not exist and no successor records superseding it; adr-12 is not marked superseded by adr-32; itd-47 and itd-49 sit in superseded/ without supersession frontmatter; the superseded/ lint exemption is broad enough to hide content-rule violations. Detector: a mechanical record-schema check — every prose handle (adr-N, itd-N, iss-N) resolves to a file; supersession is bidirectional (superseded-by and supersedes both present); filenames match their patterns; every lifecycle directory is covered by a catch-all so no state escapes linting; the superseded/ exemption narrows to content rules only. Acceptance corpus: the four instances above.
- iss-2609290448510918The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count.
- spc-2intent-lifecycle-automation
- spc-4start-the-transcript-clock
- spc-6issue-capture
- spc-18abcd-gates-a-new-idea-before-it-becomes-a-record-entry-resea
- spc-41The bare-invocation banner: baked identity, half-block livery art, and the exported colour ladder