Epic: verification is a pure function of inputs it does not contain, and stale has exactly one owner with a recovery path
An approved epic does not approve its children — each child is its own issue before code.
Epic: verification is a pure function of inputs it does not contain, and stale has exactly one owner with a recovery path
The deliverable is the seam, not the individual fixes. The issues absorbed below are evidence that one piece of behavior has no single owner. They are the symptom list, not the work list. Repairing them where they were reported — N fixes for N issues — leaves the missing owner still missing, and the next occurrence is already being written somewhere else in the tree. An implementation that does that has not closed this epic, even with every symptom gone and CI green.
Closing this epic means three things land together: one VerificationFingerprint whose covered set is closed under the artifacts the verifying workflow itself writes, computed and re-checked through a single canonicalizer, and one owner of the stale verdict with a real regeneration route; the duplicate implementations are deleted rather than kept in sync, because two copies that agree today are the same defect as two that disagree; and enforcement is by construction — a lint asserting that no verify-lifecycle hook writes a covered artifact after the fingerprint is computed, plus a closed VerificationStatus enum both the writer (gsd-verifier) and the reader (verification.cts) import. The first two without the third un-consolidate quietly as soon as someone who has not read this issue touches the area.
A defect that cannot be expressed through the seam is a finding about the seam's design and belongs in this issue as a comment — one special case is an artifact that must be covered but is also written during verification.
This is the shape that worked in #1372 (markdown sectionizer), #2143 (table, bounded mutation, fail-loud) and #2121 (phase identifier): each closed its bug class with a seam, a migration and an anti-divergence guard, none of them by fixing the reported symptoms. Splitting delivery across several PRs is expected and welcome; splitting it so that the seam never lands is not.
Six open confirmed-bug issues are one defect: the staleness model hashes a set of files that the verification workflow itself mutates, and no workflow can regenerate the artifact the verdict prescribes. The result is a phase that genuinely passed and can never be marked complete — or, on the other side of the same branch, a phase marked complete that was never verified.
This family is expensive in a way the count understates. #4623, #4682 and #4697 were all closed against it; three more arrived. The model, not the instances, is what is wrong.
Three arms, one remedy
(a) The digest's input set is not closed — it contains files verification writes.
| Issue | What is covered that should not be | Consequence |
|---|---|---|
| #4857 | the *-VERIFICATION.md itself, listed in its own covered_files |
the digest is a function of the file containing the digest. Writing it changes the input. No value exists that passes — reproduced by restamping with verification fingerprint's own output; it re-staled on write |
| #4887 | *-SECURITY.md, appended to by the verify:post secure-phase L1 re-audit at verify-work.md:566-600 — after the digest was computed at :536-539 and before the stale check reads it at :606+ |
a green phase ends its own verification run stale. The appended row recorded no threat-state change (22/19/0, identical to the row above it). It is bookkeeping only a hasher can see |
agents/gsd-verifier.md:671 prescribes "every phase PLAN/SUMMARY (+superseded, nested plans/), mapped requirement, changed impl file" — which does not forbid the report, and the code accepts whatever list it is handed. There is no schema for what may be covered.
(b) The emitter and the checker derive their inputs independently.
| Issue | Emitter | Checker |
|---|---|---|
| #4817 | gsd-verifier omits the phase's own plans and summaries from covered_files |
allCurrentArtifactsCovered (verification.cjs:304) re-scans the live phase dir and requires every current *-PLAN.md / *-SUMMARY.md to be represented → permanent stale with provably zero drift. Regenerating reproduces the omission |
| #4894 | cmdVerificationFingerprint re-derives the root via findProjectRoot(phaseDir) |
readVerificationStatus re-derives it the same way, separately. --project-dir is validated and honored by the dispatcher (#3881) and consulted by neither. The issue's own analysis: "Both must change together, or neither" — the status side is reached through isPhaseComplete and ~9 callers |
#4817 also has the verifier writing a status outside VERIFIER_STATUSES — the writer and the reader do not share the vocabulary.
(c) stale is read by two workflows that disagree about what it means, and neither can clear it.
- #4765 —
verification.cjs:103-107returnsstatus: 'stale',next_action: "Verification is stale. Re-run verify-work before transition."execute-phase.md:377-381reads that same value as "verification EXISTS — do not redo it … continue directly atupdate_roadmap". One says re-run before transition; the other transitions. Observed on four phases in one repository, allstale, allroadmap_complete=false. The phase is marked complete without re-verification. - #4887 Defect 1 — verify-work contains no step that regenerates
*-VERIFICATION.mdor refreshescovered_digest. Its only report write is thehuman_needed → passedcanonicalization at:606-613. The canonical recovery route is therefore a closed loop: resume → no pending tests →complete_session→ zero issues → verify:post → stale → same block, same command, state unchanged by construction. #1548 fixed the wording of this message; the capability gap under it remains. - #4887 Defect 3 — with the roadmap checkbox ticked,
execute-phasecondition 3b reports "genuinely finished" and exits. Correct in refusing to advance, but it means a marked-complete stale phase has no sanctioned path back to green in any workflow. Between this and #4765,staleis mishandled on both sides ofPHASE_MARKED. - Side note from #4887, same class as #2617: the routing table ships
next_command: ''forstalewhileinit.verify-work'sphase_completionprojection fills/gsd-verify-work <N>and the spine presents/gsd-verify-work {phase}— three surfaces, two answers.
(d) The adjacent staleness detector cannot see the common change class.
#4886 — detectDrift (src/drift.cts) builds its element list from addedFiles and nothing else; modifiedFiles and deletedFiles are destructured, bound, and reach exactly one consumer — the counts object in the return value. A commit editing a hundred existing files inside directories STRUCTURE.md already lists produces elements: [], actionRequired: false. cmdVerifyCodebaseDrift compounds it from the other side: ARCHITECTURE.md, CONCERNS.md, CONVENTIONS.md, STACK.md, INTEGRATIONS.md and TESTING.md are never opened by the drift path. Six of seven generated documents are outside the detector's input entirely, and gsd-planner's load_codebase_context is unconditional, so the stale map still reaches plan generation. On a mature repo, most commits edit rather than add — exactly the class the detector cannot see.
The seam this epic delivers
- A covered-set schema, enforced at fingerprint time. The canonical list is produced by one function —
canonicalizeCoveredFiles, already shared bycmdVerificationFingerprint(:909) andcomputeCoveredDigest(:219) — which filters what may never be an input to itself. #4857 documents where this cannot go and why:computeCoveredDigest(projectRoot, coveredFiles)never receives the report path, so a path comparison there is impossible; it must be a pattern filter one function up. Keep that reasoning in the fix's comment; it is the second thing anyone will try. - A verify-lifecycle write ordering with a lint behind it. Every covered artifact is written before the fingerprint is computed. A hook registered at
verify:postthat writes a covered path is a lint failure, not a silent re-stale. - One resolved
ProjectContextthreaded through emitter and checker, so the digest is computed and re-checked under the same root. This is the verification-side consumer of the root-identity seam (#4908); the two must land compatibly. - A closed
VerificationStatusenum imported by the agent contract and the reader, withstaleowned by exactly one routing table and one regeneration command that actually regenerates. detectDrifttakes the full change set and the full document set. Added, modified and deleted all produce elements; all seven generated documents are inputs.
The ratchet
A phase that is stale must be provably recoverable. The acceptance test is mechanical and is the one the absorbed issues were found by: take a green phase, run the full verify-work cycle twice, and assert the second run ends passed — today it ends stale by construction (#4887). Pair with a property test that the fingerprint is idempotent: computing, writing, and recomputing yields the same digest. #4857's fixed point cannot survive that assertion. Every gate ships a positive control proving it can fail (#4631's ratchet) applies here too: the staleness check must have a test that drives it red on genuine drift, so the fixes cannot be delivered by weakening it.
Absorbed issues — evidence of the missing seam, NOT a work list
| Issue | Defect | Arm |
|---|---|---|
| #4857 | a *-VERIFICATION.md in its own covered_files is an unsatisfiable fixed point |
input set |
| #4887 | verify:post L1 re-audit mutates a covered file after the digest; no workflow regenerates the report | input set / routing |
| #4817 | verifier omits the phase's own plans and summaries; can write a status outside VERIFIER_STATUSES |
emitter/checker |
| #4894 | --project-dir ignored by verification.fingerprint and the staleness recompute; both re-derive from phaseDir |
emitter/checker |
| #4765 | execute-phase resume collapses stale into the "exists — do not redo" arm and marks the phase complete unverified |
routing |
| #4886 | detectDrift builds elements from addedFiles only; six of seven map documents are never read |
detector |
Closed prior art, included so they are not re-conflated: #4623 (covered set spanning shared planning docs), #4682 (stale routes to a command that never regenerates — the wording half), #4697 (staleness cascading across phases sharing covered_files). Each was a correct fix to an instance. The model produced three more.
Done when
- No artifact written during verification is an input to the digest that verification produces
- Fingerprint is idempotent: compute → write → recompute yields the same digest, asserted as a property test
- A lint fails any verify-lifecycle hook that writes a covered path after fingerprint time
- Emitter and checker derive the covered set and the project root through one seam;
--project-diris honored by both or by neither, documented either way -
VerificationStatusis a closed enum shared bygsd-verifierandverification.cts; a status outside it is a hard error -
stalehas one routing table, one command, and that command regenerates — proven by the two-cycle acceptance test endingpassed - No branch of
execute-phasetransitions a phase whose verification isstale -
detectDriftproduces elements from added, modified and deleted files, over all seven generated documents - Failing-first regression per absorbed issue
Non-goals
- Repairing the absorbed issues individually at their existing call sites without building the seam. #4623, #4682 and #4697 were each that, and this epic is what came after.
- Weakening the fail-closed staleness contract or covered-file containment. #4894 names both as out of scope per the #4815 brief, and they stay out of scope: the requirement is a digest whose inputs are correct, not a check that passes more often.
- Silently repairing stored artifacts. #4857 is explicit that a digest computed over the unfiltered set will not match a recompute over the filtered set; one restamp per existing report is expected. What the seam buys is that the restamp is stable.
- Changing
verify codebase-drift's warn-only, never-throw contract (#4886 states this and the phase workflow depends on it). The requirement is that it can see the change class it names. - Relitigating #2401 or #3678's absolute-path handling — that belongs to the root-identity epic (#4908).
Generated with Claude Code
Source: open-gsd/gsd-core