diff --git a/docs/changes/20260728-receipt-tree-binding/change.json b/docs/changes/20260728-receipt-tree-binding/change.json new file mode 100644 index 000000000..e6cc56397 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/change.json @@ -0,0 +1,6 @@ +{ + "branch": "receipt-tree-binding", + "change": "receipt-tree-binding", + "created": "2026-07-28", + "target_release": "2.0.0" +} diff --git a/docs/changes/20260728-receipt-tree-binding/receipts/verify.json b/docs/changes/20260728-receipt-tree-binding/receipts/verify.json new file mode 100644 index 000000000..a04d7019b --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/receipts/verify.json @@ -0,0 +1,108 @@ +{ + "schema_version": 2, + "change": "receipt-tree-binding", + "verified_commit": "25440865a5619c3f6469fa799ed43b4143025a32", + "verified_root_tree": "9866fdaab48fbc736874e09fe7a7a9410c9a9688", + "verified_at": "2026-07-30T01:13:20Z", + "criteria_digest": "608ef44ab997c5ef661ffc7e1070e269d666d5c009325cf1777819896bc5f9cb", + "scope_digest": "4b780218955b7bdc70f16068422210df7ffd04c2856f5d5122ff07956bcfe91e", + "scope_sections": { + ".agents": "0cdcb499203f22e1faebbef89e211e4b6d4b253bc5486866ed28a50e15c2dd9e", + ".claude": "8dc123c00b953695edd9ccc4bb966e986bcea05607d9a125e71038a4c2ebb6be", + ".github": "42529d8eb65b5025a2a641309b5c789e771402b63fb6093468b2f04ababfabc1", + ".gitignore": "bdca2d6bd9b911e4c840fa1471c55e380fccfe92f0c5b352e6536d5f346d7249", + ".serena": "749668b92acde130ae6a4514f59b9e08bf7095c8bdd47b6b2e5929b0729ba91f", + "AGENTS.md": "5310119234b7665c6d64f2d1b5768f9eedc6f69aba35cfe083255f2d4b2c2fad", + "README.md": "9b30876ddbbf893778865069a28ea215bd3b2ce9ebee913d2cf2e60e0c958152", + "cli": "702ff0c12b611f68cd970ea1a7dfef4a38fa1be7a9c221544bd44ec9e75bac53", + "cmd": "b2666d27e62ce6fc437436bdc891f05355d29c945300b776d75e662a358b3918", + "config": "94bafe52ad02231df4b7d27e3282bac82104ad0065e3b065010d88a2777bd1e3", + "content": "d43d7d776145117867da411fb3d60e0c029cc71f4a1676bb1f6ff72fa25c814a", + "docs": "4c60f1a1b2128e474457784af773578e12cb6075e43c801bc8826258a0145a9b", + "go.mod": "5f494108c9f9ba4911bbcb82ece8168d5af253742eb632ccac5519348e34ef1c", + "go.sum": "44a5d68a99a05f3d9aff3321f4b5645d0870cc1afd36e183fbc08c07d8550d6b", + "internal": "c5166b2d1240030fa53cdb99fe94a1abf2b7228810b97a4a2efba328d65d1902", + "package-lock.json": "afd3f2eea505b8289f8b16c35364d4b892b25663b42b2a7ba967cf8c71feb44f" + }, + "exclusions": [ + "docs/changes/*/receipts/**", + "docs/changes/*/reports/**", + "package.json", + ".claude-plugin/marketplace.json", + "CHANGELOG.md", + "dist/**", + "plugins/**", + "bin/**" + ], + "digest_spec": "v1", + "tool_version": "2.0.0-alpha.15", + "toolchain": { + "go": "1.26.5", + "os": "darwin", + "arch": "arm64" + }, + "worktree_clean": true, + "target_release": "2.0.0", + "results": [ + { + "id": "V1", + "command": "go test ./internal/cli -run 'TestChangeScopeDigest' -count=1", + "exit_code": 0, + "output_digest": "85a1b961958eb468e87db88afe4742d74035d0e62ac8fd2bfbc88addbffb47d3", + "ok": true, + "expect": "exit 0.", + "expect_checks": [ + { + "kind": "exit", + "value": "0", + "ok": true + } + ] + }, + { + "id": "V2", + "command": "go test ./internal/cli -run 'TestChangeVerifySchemaV2' -count=1", + "exit_code": 0, + "output_digest": "2a9bf2cadc07e4bf17b47c5a4621e932c0aaf1b90131af89279a53d0068829d1", + "ok": true, + "expect": "exit 0.", + "expect_checks": [ + { + "kind": "exit", + "value": "0", + "ok": true + } + ] + }, + { + "id": "V3", + "command": "go test ./internal/cli -run 'TestChangeReceiptFreshness' -count=1", + "exit_code": 0, + "output_digest": "87bd5787a3f3cfc9d2f967b8dc96f4cbe218d648d2a48e3e4b23f74c5434bc4a", + "ok": true, + "expect": "exit 0.", + "expect_checks": [ + { + "kind": "exit", + "value": "0", + "ok": true + } + ] + }, + { + "id": "V4", + "command": "go test ./...", + "exit_code": 0, + "output_digest": "e1eee8d872b39c2892208c5329e49af502136e1c20fd4751fe7ce71fcf5bbfa4", + "ok": true, + "expect": "exit 0.", + "expect_checks": [ + { + "kind": "exit", + "value": "0", + "ok": true + } + ] + } + ] +} diff --git a/docs/changes/20260728-receipt-tree-binding/reports/20260728-222942-council-receipt-freshness.html b/docs/changes/20260728-receipt-tree-binding/reports/20260728-222942-council-receipt-freshness.html new file mode 100644 index 000000000..e2d9fc77c --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/reports/20260728-222942-council-receipt-freshness.html @@ -0,0 +1,215 @@ + + +Council — Receipt Freshness Binding + + +
+
receipt-tree-binding · council · receipt freshness
+

Council — What Expires a Verification Receipt?

+

convened 2026-07-28 · five members, same question, independent lenses · trigger: squash-merge unreachability of verified_commit (alpha.15 release canary) · informs the receipt-tree-binding Change, predecessor to release-promotion-model · decision recorded — masked root-tree digest

+ +
+

The question

+

Once receipt validity binds to content digests instead of commit reachability, what should expire a receipt? The gate must yield the same verdict on every machine under any merge strategy (squash, merge commit, rebase). Today's rule — walk verified_commit..HEAD, any non-receipt path stales — crashes with exit status 128 on any fresh clone after a squash merge, because the recorded commit no longer exists there.

+
+ +

Proven defects surfaced by the council reproduced, not inferred

+
+ +
+ +

Unanimous riders adopted under either option

+
+ +
+ +

The split — 3 : 2 on gating scope

+
+
+

Folder-scoped digest + drift note + CI assertion

+

Release Engineering · Audit Integrity · Portability/DX

+

Receipt binds to the change folder's content (minus receipts/, reports/) + criteria digest. Unrelated repo commits never expire it. A structured drift note carries honesty; the whole-repo currency question moves to the pipeline: block tag creation unless CI is green at the exact HEAD SHA.

+
    +
  • Pro: N verifications for N changes, zero churn; verification lives in the PR where authors want it; Gerrit's copyCondition is fifteen-year precedent for content-scoped attestations that survive unrelated landings.
  • +
  • Pro: preserves work-model Decision 17 — a healthy cohort member on main always shows verified.
  • +
  • Con: the receipt's green quietly means less: rewrite all of internal/cli/ and it stays fresh. The claim language must narrow to "criteria last ran against this contract and folder" — repo health is delegated to CI.
  • +
  • Con: re-adopts what work-model Decision 13 rejected — evidence surviving a revert of the verified work.
  • +
+
+
+

Masked root-tree digest, unified exclusions synthesis pick

+

Git Internals · Systems Correctness

+

Receipt binds to the whole tree minus a declared, receipt-recorded exclusion set: docs/changes/*/receipts/** (repo-wide) ∪ reports/ boards ∪ the promotion designation allowlist (version files, CHANGELOG, regenerated outputs). Digest domain ≈ what the criteria actually read.

+
    +
  • Pro: the criteria are go test ./..., npm run build, loaf check — they read the repo, not the folder. Code changes and reverts expire evidence, matching Decision 13.
  • +
  • Pro: designation-legal ⟺ receipt-neutral — the same allowlist drives the promotion diff check and the digest mask, so rc cuts and the promotion commit can never expire cohort receipts. Promotion passes by construction, principled rather than carved out. Matches the Intent's own words: "rc bugfix iterations re-verify cheaply; the rc commit promoted unchanged stays verified."
  • +
  • Con: any later code landing expires cohort receipts — verification becomes a stabilization-time sweep at rc cut (one receipts-only commit, composable because receipts are masked) plus a re-sweep after rc bugfixes.
  • +
  • Con: softens Decision 17 — a verified member on main can show "evidence predates current tree" until the rc-point sweep.
  • +
+
+
+ +

Property comparison

+
+ + + + + + + + + + + + + +
PropertyFolder digest + CI assertionMasked root-tree digest
Same verdict on every machine, any merge strategyyesyes
Survives squash / rebase / merge-commit unchanged contentyesyes
Code change outside the folder expires the receiptno — CI assertion compensates at cutyes
Revert of the verified work expires the receiptno (Decision 13 tension)yes (revert that restores verified bytes un-stales — deliberate)
Cohort of N simultaneously verifiableyesyes (receipts masked repo-wide)
rc cut / promotion expire cohort receiptsnevernever — allowlist unification, by construction
Re-verification costonce per change, in its PRsweep at rc cut + after rc bugfixes (anticipated by the rc design)
Receipt's green claim strengthnarrow — "contract + folder", stated honestlystrong — "tracked content criteria read is unchanged"
Matches work-model Decision 13 (revert precedent)noyes
Matches work-model Decision 17 (main shows verified)yessoftened until rc-point sweep
Touch-then-revert detectiondropped under both — endpoint digests can't see it, and squash merges already erase it on main (Correctness: the property was branch-local fiction)
+
+ +

Member positions

+ +
+
Release Engineeringfolder + pipelinehigh confidenceThe receipt and the release suite answer different questions; stop asking one artifact to answer both.
+
+

Core argument

Folder digest gates; repo drift becomes a path-based advisory (name go.mod, not counts); the blocking whole-repo check moves to the pipeline — refuse to tag unless CI is green at the exact HEAD SHA. Gerrit's copyCondition, SLSA/in-toto content binding, and Debian britney all split per-unit evidence from archive-wide health. Option C generalizes the alpha.15 binary-hash canary to every receipt on every commit, and merge queues exist because repo-wide invalidation convoys don't scale.

+

Unique contributions

Gate has no CI caller; release.yml tags before testing and needs fetch-depth: 0; verified_at is unread yet is the one release-native signal (drift threshold keyed to the release cycle, not day counts); environment drift (toolchain) is invisible under folder scope — record {go, os, arch}; resolve the parked plan.md exemption Intent here (exclude it); defer per-change evidence_paths until a real case appears.

+
+
+ +
+
Evidence & Audit Integrityfolder + recorded qualifierhigh confidenceThe drift note is A's entire honesty budget — promote it from printed courtesy to recorded claim-qualifier, or A degrades into B.
+
+

Core argument

The receipt's unique contribution is "the declared contract was identified and executed at all" — CI never parses shape.md. C's carve-out is an unverifiable global assertion ("allowlisted paths cannot affect any criterion") and carve-outs accrete; A's gap is bounded, stated, constant. Silence (B) is the textbook attestation failure: output text unchanged while its meaning weakens.

+

Unique contributions

Dirty-worktree false-pass path (block on worktree_clean: false); criteria_digest drops criterion Text; store a literal claim sentence in the receipt; record repo_tree_digest — surfaced, never gating — so tightening policy later needs no re-verification sweep; named anti-pattern for the ADR: never let the reader recompute an absent digest from the current tree ("lenient migration" code that compares a tree against itself).

+
+
+ +
+
Portability & Consumer DXfolder, no knobhigh confidenceA doesn't produce a better error for the squash case — it deletes the failure class; there is no git question left that a merge strategy can make unanswerable.
+
+

Core argument

Zero-config-correct under every merge strategy because the digest reads HEAD:<folder>, content-addressed and identical on every clone. No expiry-scope knob: a consumer meets that setting for the first time while blocked at a release and will set it to the loosest value. If evidence outside the folder matters, the change should declare it (authored, reviewable) — the repo should not globally redefine what evidence means.

+

Unique contributions

End-to-end reproduction: both machines block, divergence is clean-reason vs exit status 128 (a --no-local clone loses the object while --is-shallow-repository still says false — the shallow guard gives false comfort); bash -lc runs criteria in a login shell (nvm/asdf/Homebrew paths locally, absent in CI) — drop the -l; the rc bypass means a consumer's first gate contact is the stable cut, the worst possible moment — add a read-only receipt-status surface; copy-pasteable block messages naming folder, cause, and remedy; fix commandOutput to capture stderr.

+
+
+ +
+
Git Internalsmasked root treehigh (mechanics)Folder scope is a silent gutting: the digest's domain does not intersect the criteria's input set — too weak where it matters, too twitchy where it doesn't.
+
+

Core argument

Not one of the work-model's nine criteria reads a byte of its change folder; under A you can rewrite internal/cli/ and stay fresh, while committing a review board expires you. shape.md Decision 13 already rejected this class in writing. The sound construction: one git ls-tree -r -z --full-tree <pinned-sha> (14 ms over 2,098 entries), mask docs/changes/*/receipts/** (+ reports/), byte-sort, keep mode, domain-separation header, SHA-256, record as evidence_digest + evidence_digest_spec.

+

Unique contributions

Proved the N≥2 cohort deadlock in all orderings; merge-mode behavior table verified empirically (squash preserves root tree when main hasn't diverged; rebase breaks reachability the same as squash; revert that restores bytes un-stales — the better semantics); never mktree (writes objects — a side effect an audit gate must not have, fails on read-only stores); no reachability fallback ever — a verdict that consults the walk when the object exists differs by machine by design; diagnostics guard with cat-file -e and degrade to shorter messages, never different verdicts.

+
+
+ +
+
Systems Correctnessmasked root + unificationhigh confidenceA and B are unsound as gating predicates; C without an exclusion set is unworkable; the exclusion set already exists — it's the designation allowlist.
+
+

Core argument

fresh(receipt, HEAD) must be a pure function of receipt fields and the HEAD tree — no refs, no reachability, no worktree, no clock. Define the digest over the tree minus (receipt surface ∪ designation allowlist): then a commit is designation-legal if and only if it is receipt-neutral, and the promotion contradiction cannot be constructed. Same class-fix shape as pinEvidenceAtHEAD: derive the boundary once, thread it everywhere.

+

Unique contributions

Full failure-mode table — every state (missing, uncommitted, unparseable, unsupported schema, object-format change, digest/criteria mismatch, failing results) maps to a reasoned block; scope_sections per-directory sub-digests give machine-independent drift naming ("content changed under internal/") with no ancestry; touch-then-revert is incoherent on squash-merged main — drop it deliberately and record why; schema_version is written and never read — v2 must be checked explicitly, not rely on zero-value accident; reason strings are load-bearing control flow today (strings.HasPrefix dispatch) — become a typed enum; composition obligation: masking regenerated outputs means the designation check must independently prove they're the deterministic rebuild.

+
+
+ +

Synthesis

+
+

Both camps converge on the same machinery and differ on one line: the digest's domain. Everything else — OID-based construction, no reachability, reasoned verdicts, schema v2 now, dirty-tree refusal, masked receipts/ + reports/, machine-independent drift, no knob — is unanimous and lands identically either way.

+

Synthesis recommendation: the masked root-tree digest with the unified exclusion set, composed with the folder camp's riders (CI-green-at-HEAD assertion before tagging, DX's message wordings, Audit's recorded claim-qualifier and repo_tree_digest field, Correctness's scope_sections for drift naming). Rationale: Decision 13 is owner-settled precedent that evidence must not survive a revert of the verified work; the criteria's input domain is the repo, and a gate whose green means less than it reads is the failure mode this project keeps paying to remove; and the allowlist unification makes the promotion model and the receipt layer one system with one invariant. The acknowledged cost — verification becomes an rc-point sweep — is the workflow the rc design already described ("rc bugfix iterations re-verify cheaply").

+

The folder option remains fully coherent if the owner weighs Decision 17's main-state doctrine and per-PR verification ergonomics above evidence strength — in that case the claim language must narrow explicitly and the CI assertion becomes load-bearing rather than advisory.

+
+ +

Decision

+
+

Decided 2026-07-28, by the owner, after board review: the masked root-tree digest with the unified exclusion set. The digest covers the whole tree minus a declared, receipt-recorded exclusion set — docs/changes/*/receipts/** (repo-wide), docs/changes/*/reports/**, and the release-metadata allowlist (version files, CHANGELOG, regenerated build outputs) — making designation-legal ⟺ receipt-neutral: rc cuts and the promotion commit can never expire cohort receipts. Composed with the unanimous riders (schema v2 clean break, OID-based construction, reachability fully retired, reasoned verdicts everywhere, dirty-worktree refusal at verify, criteria text digested, no knob, per-directory sub-digest drift naming) and the folder camp's adopted riders: the CI-green-at-HEAD assertion before tagging (lands with the promotion change), DX's block-message wordings, and Audit's recorded-never-gating repo_tree_digest. Cost model accepted explicitly: the freshness check is milliseconds everywhere; re-verification is a minutes-scale local sweep only at rc iterations, and never at promotion.

+
+ + +
diff --git a/docs/changes/20260728-receipt-tree-binding/reports/20260729-114458-review-claude.html b/docs/changes/20260728-receipt-tree-binding/reports/20260729-114458-review-claude.html new file mode 100644 index 000000000..5cf203d89 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/reports/20260729-114458-review-claude.html @@ -0,0 +1,144 @@ + + +Review Round 1 — Receipt Tree Binding + +
+
Review · Round 1
+

Receipt Tree Binding — Initial Review

+

change docs/changes/20260728-receipt-tree-binding · reviewer Claude (orchestrator) · reviewed 2026-07-29 · scope 349bbd3d..b71247c2 · dispositions folded in ede4953d

+ +
+

Round verdict: contract met, six findings, all accepted and fixed

+

The implementation delivers the shaped contract with no correctness defects: the digest construction matches ADR-024's pinned serialization exactly (injective, ls-tree-only, mode included, domain-separated), the freshness predicate is a pure function of receipt fields and the HEAD tree with no reachability consult anywhere, and the council's three defect reproductions are all encoded as regression fixtures (post-squash protocol clone, N=2 cohort coexistence, inverted touch-then-revert). Findings were mechanical-cleanup and wording tier; all six were accepted and folded in ede4953d. Round 2 re-review: approved.

+
+
V1–V4 green at HEAD; receipt re-verified after the fold
+
change check zero violations, state derives verified
+
DoD grep cannot inspect receipt: no matches in internal/cli
+
Full suite go test ./... green before and after the fold
+
+
+ +

Findings

+ +
+
F1 — The cannot inspect receipt path survived as dead codemediumaccepted
+
+
Claim
The DoD requires this path "deleted, not just avoided," but change_release_gate.go:92 kept the branch and error string. changeReceiptStatus returned a nil error on every path, so the branch and the error return were unreachable by construction, along with vestigial receiptErr plumbing in evaluateVerifiedRungAtCommit.
+
Disposition
Accepted — dead-by-construction seams contradict the DoD's deletion requirement and invite prose-as-control-flow regressions.
+
Fix
ede4953d: changeReceiptStatus returns a bare verdict; the gate branch and error string are deleted; evaluateVerifiedRungAtCommit dropped its receiptErr return and callers were simplified.
+
+
+ +
+
F2 — changeReceiptAtHEAD had zero callers and a false commentmedium-lowaccepted
+
+
Claim
The helper was introduced by this change with its logic already inlined in changeReceiptStatus; nothing called it, and the comment on loadChangeVerifyReceipt claimed gate reads flowed through it.
+
Disposition
Accepted — dead code plus a factually wrong comment.
+
Fix
ede4953d: helper deleted; comment rewritten to name changeReceiptStatusreadCommittedOptional as the committed-read path.
+
+
+ +
+
F3 — Boundary/spec mismatch misrendered as section driftlowaccepted
+
+
Claim
An exclusions or digest_spec mismatch reused the content-drift reason with a fabricated digest_spec section, so the user read "content changed under digest_spec" when no content changed. This state will occur the moment the promotion change touches the allowlist.
+
Disposition
Accepted — the typed enum exists precisely so causes name real states.
+
Fix
ede4953d: new changeReceiptBoundaryChanged reason, cause "evidence boundary changed since verification (receipt expired)", covered by a committed-receipt fixture with mutated exclusions.
+
+
+ +
+
F4 — Git-seam failures claimed the receipt was unreadablelowaccepted
+
+
Claim
A failed rev-parse, ls-tree, or committed-read yielded "receipt unreadable — re-verify" even when the receipt was fine and re-verifying could not fix a broken repo. Determinism was preserved but the cause misnamed the state.
+
Disposition
Accepted — verdict stays blocking and typed; only the cause changes. changeReceiptUnreadable is reserved for a receipt at HEAD that fails to parse.
+
Fix
ede4953d: new changeReceiptEvidenceUnavailable reason, cause "could not read evidence at HEAD (git error)", no raw error text embedded, message tests still forbid exit status in gate output.
+
+
+ +
+
N5 — Failing-results block dropped the folder and remedynitaccepted
+
+
Claim
TASK-004 says every block names folder, cause, and remedy; the failing-criteria block carried only the cause, encoded as a test exemption.
+
Disposition
Accepted — cause string preserved verbatim (documented in docs/knowledge/work-model.md), remedy appended.
+
Fix
ede4953d: block now appends "Fix the failing criteria, then run: loaf change verify <folder> and commit the receipt"; the test exemption was removed and the wording asserted.
+
+
+ +
+
N6 — Stale task citation and misnamed parameternitaccepted
+
+
Claim
A comment cited "(TASK-007)" from a previous change's numbering, and runChangeCriterionCommand's first parameter was named folder while receiving the repo root.
+
Disposition
Accepted — work-unit citations don't belong in code comments; the name misled about where criteria run.
+
Fix
ede4953d: citation removed; parameter renamed rootPath.
+
+
+ +

Provenance

+
+

Initial review conducted 2026-07-29 against commits 2f86986f..b71247c2 (TASK-001–TASK-004) on branch receipt-tree-binding, judged against shape.md's Planning Contract, Failure-mode table, and Definition of Done, plus ADR-024. All six dispositions accepted by the reviewer and folded by the implementer in ede4953d, which also re-verified the change's own receipt — the fold expiring and re-earning its evidence is the content-digest contract exercising itself. Related: council board 20260728-222942-council-receipt-freshness.html (design decisions this review verified against).

+
+
diff --git a/docs/changes/20260728-receipt-tree-binding/reports/20260729-154556-review-codex.html b/docs/changes/20260728-receipt-tree-binding/reports/20260729-154556-review-codex.html new file mode 100644 index 000000000..0e7060c23 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/reports/20260729-154556-review-codex.html @@ -0,0 +1,124 @@ + + +Review Round 2 (Codex) — Receipt Tree Binding + +
+
Review · Round 2 · External
+

Receipt Tree Binding — Codex Review

+

change docs/changes/20260728-receipt-tree-binding · reviewer Codex (gpt-5.6-sol, xhigh) · reviewed 2026-07-29 · scope main..6d6b43a3 · dispositions folded in 2cc2c333

+ +
+

Round verdict: changes requested → all three findings accepted and fixed

+

Codex reviewed the branch after round 1's fold, briefed to hunt lifecycle and test blind spots the first round might have missed. It returned changes-requested with three findings — one High, one Medium, one Low — all confirmed by inspection and accepted without modification. The High and Medium findings share a root: the dirty-tree check was a single pre-run snapshot that either missed mutation (during criteria) or over-fired (on committed cohort receipts). The governing fix decision: dirty checks exempt exactly the receipt and report masks, never the release-metadata allowlist, and worktree_clean became a load-bearing receipt field enforced by the freshness predicate. All fixes folded in 2cc2c333; round 3 re-review approved.

+
+
Full suite go test ./... green with the three new lifecycle fixtures
+
Receipt re-verified; worktree_clean: true; change derives verified
+
Design proof receipt survived a post-verify amend — verified_commit no longer exists, verdict unchanged
+
+
+ +

Findings

+ +
+
C1 — Criteria could mutate tracked content while the receipt recorded worktree_clean: truehighaccepted
+
+
Claim
Verify checked cleanliness once pre-run, computed the scope digest, then ran arbitrary writable Bash commands and hardcoded WorktreeClean: true. A criterion mutating a tracked, digest-excluded path (e.g. dist/) yielded a gate-accepted receipt attesting an execution against bytes absent from the recorded commit — the exact defect Decision 5 exists to prevent.
+
Disposition
Accepted — the realistic trigger is not malice but a criterion like npm run build regenerating outputs.
+
Fix
2cc2c333: post-run divergence check (same mask exemptions as pre-run); receipt still written (write-on-failure) with derived worktree_clean; verify exits non-zero naming the diverged paths; new typed reason changeReceiptDirtyExecution makes the predicate reject worktree_clean: false receipts. Pinned by the tracked-dist-mutation-voids-and-blocks-gate fixture.
+
+
+ +
+
C2 — Re-verify sweep self-blocked on previously committed cohort receiptsmediumaccepted
+
+
Claim
Re-verifying member A rewrote its tracked receipt, so member B's pre-run dirty check refused with "working tree differs from HEAD" — the contracted verify-all-then-one-sweep-commit workflow (shape.md:61) was impossible once receipts were committed. Round 1's cohort fixture only exercised first-time untracked receipts, which -uno hid.
+
Disposition
Accepted — receipts and boards are declared outside the evidence domain, so their dirt cannot invalidate an attestation; the exemption is scoped to those masks only.
+
Fix
2cc2c333: dirty checks filter porcelain entries through matchEvidenceGlob against the receipt + report masks (rename and quoted-path records handled); allowlist paths deliberately stay checked. Pinned by re-verify-succeeds-with-committed-receipt-after-drift and cohort-reverify-sweep-with-committed-receipts fixtures.
+
+
+ +
+
C3 — Seam-failure block prescribed re-verify through the same broken seamlowaccepted
+
+
Claim
The changeReceiptEvidenceUnavailable block carried the generic re-verify remedy, but verify performs the same git reads and would fail before writing a receipt; a test asserted the non-remedy.
+
Disposition
Accepted as wording — the verdict semantics were already correct (fail closed, typed).
+
Fix
2cc2c333: the block now ends "Verification cannot proceed until git reads succeed — inspect the repository (git fsck) or re-clone"; the test asserts the new remedy.
+
+
+ +
+
R1 — Residual: changeTrackedWorktreeDirty left with zero callersnitdeferred
+
+
Claim
Round-3 re-review: the fold rewired verify to changeTrackedWorktreeDivergedPaths and left the boolean wrapper dead — the same class round 1's F1/F2 removed.
+
Disposition
Deferred to the ship round's pre-merge cleanup — deleting it touches internal/ and would expire the receipt again for an eight-line wrapper; fold it into the next substantive commit instead.
+
Fix
Pending — remove the wrapper when the branch next re-verifies anyway.
+
+
+ +

Provenance

+
+

External review run 2026-07-29 by Codex (gpt-5.6-sol, xhigh reasoning) via the Codex companion runtime, thread 019fad93-0b97-7972-a0a8-e0acbf62d952; the first run wedged mid-suite and was resumed to completion. Findings relayed verbatim, confirmed by orchestrator inspection, and dispositioned the same day; fixes folded by the implementer in 2cc2c333, which re-verified the change's own receipt. Related boards: 20260729-114458-review-claude.html (round 1), 20260728-222942-council-receipt-freshness.html (design provenance).

+
+
diff --git a/docs/changes/20260728-receipt-tree-binding/reports/20260730-021341-review-codex.html b/docs/changes/20260728-receipt-tree-binding/reports/20260730-021341-review-codex.html new file mode 100644 index 000000000..61daacbd6 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/reports/20260730-021341-review-codex.html @@ -0,0 +1,129 @@ + + +Review Round 3 (Codex, fresh thread) — Receipt Tree Binding + +
+
Review · Round 3 · External · Fresh Thread
+

Receipt Tree Binding — Fresh Codex Review

+

change docs/changes/20260728-receipt-tree-binding · reviewer Codex (gpt-5.6-sol, xhigh, new thread) · reviewed 2026-07-29 · scope main..1fe2eea5 · dispositions folded in 1c0e82bf

+ +
+

Round verdict: changes requested → all four findings accepted and fixed

+

A deliberately fresh Codex thread — independent of the round-2 reviewer's context — re-read the whole branch plus both prior boards and found four issues none of the earlier rounds caught, concentrated in the newest code (the dirty-check plumbing added in round 2's fold) and in docs supersession completeness. All four were accepted with fix decisions journaled before briefing; fixes folded in 1c0e82bf, which also closed the round-2 residual R1. Round-5 re-review: approved.

+
+
Full suite go test ./... green with three new fixtures (submodule, HEAD-move, arrow filename)
+
Receipt re-verified; change derives verified at HEAD
+
R1 closed dead changeTrackedWorktreeDirty wrapper deleted in the same fold
+
+
+ +

Findings

+ +
+
C4 — Submodule ignore config bypassed both cleanliness checksmediumaccepted
+
+
Claim
git status --porcelain=v1 -uno honors submodule.<name>.ignore=all from repo config, so a consumer repository's configuration could hide a dirty or HEAD-moved submodule from the pre- and post-run checks while criteria executed against those uncommitted bytes. Loaf cannot dictate consumer config, so the check must be configuration-independent.
+
Disposition
Accepted — policy pinned as --ignore-submodules=untracked: submodule HEAD moves and tracked-content modifications refuse; untracked-only submodule content does not, mirroring the -uno superproject rule.
+
Fix
1c0e82bf: the flag is passed explicitly on the status invocation with the policy documented at the call site; pinned by TestChangeVerifySubmoduleIgnoreConfigCannotHideDirt (ignore=all set, dirty submodule refused, clean submodule verifies).
+
+
+ +
+
C5 — HEAD advancing during criteria bypassed the post-run mutation checkmediumaccepted
+
+
Claim
The post-run check compared the worktree to the current HEAD, so a criterion that committed (or any concurrent commit during a long verify) read as clean and the receipt recorded worktree_clean: true while attesting an execution nothing at the captured commit produced — for digest-excluded paths the gate then accepted it, contradicting ADR-024's fail-closed promise.
+
Disposition
Accepted — fixed via a post-run HEAD equality check reusing the existing void path; no schema field added. worktree_clean's claim is redefined in place: execution integrity — tracked tree unchanged and HEAD unmoved for the whole run.
+
Fix
1c0e82bf: post-run rev-parse HEAD compared to the captured commit; movement voids the receipt (worktree_clean: false, non-zero exit naming old → new); pinned by TestChangeVerifyHEADMoveDuringCriteriaVoidsReceipt.
+
+
+ +
+
C6 — Porcelain hand-parsing misclassified quoted filenames containing " -> "lowaccepted
+
+
Claim
The parser split every record at the first " -> " before considering C-quoting, so an ordinary modified file whose name contains the arrow (emitted by git as one quoted pathname) was split into two garbage paths and lost its report-mask exemption — falsely refusing a sweep over a report-only dirty tree.
+
Disposition
Accepted — switched to the NUL format git recommends for machine parsing, the same byte-exactness discipline scopeDigest already gets from ls-tree -z.
+
Fix
1c0e82bf: status runs with -z; porcelainTrackedPathsZ parses NUL records with the rename/copy second field handled structurally; unquotePorcelainPath and the arrow split are deleted; pinned by TestChangeVerifyPathologicalArrowReportFilenameStaysMaskExempt.
+
+
+ +
+
C7 — ADR-023's supersession was materially incompletelowaccepted
+
+
Claim
Only the freshness paragraph was marked superseded while ADR-023 still specified the v1 schema (including cwd) and the "any post-verify commit imposes a re-verify" cost claim, both contradicting schema v2; the successor note required by shape.md's Durable Outputs was also missing from ADR-024.
+
Disposition
Accepted — annotate in place, never rewrite: ADRs are decision records.
+
Fix
1c0e82bf: both stale ADR-023 paragraphs carry inline supersession annotations with the historical text retained for provenance; ADR-024 gains the successor note ("the receipt attests criteria-against-declared-scope; tree-is-green at cut belongs to CI") and its supersession list names the schema and cost claims explicitly.
+
+
+ +
+
R1 (carried from round 2) — dead changeTrackedWorktreeDirty wrappernitclosed
+
+
Resolution
Deleted in 1c0e82bf as planned — deferred from round 2 to ride along with the next internal/ commit, which this fold was.
+
+
+ +

Provenance

+
+

Fresh-thread review run 2026-07-29 by Codex (gpt-5.6-sol, xhigh) via the companion runtime, Codex session 019fae81-b450-7e81-b5fd-0df6fe08722f; the first attempt died to a companion worker fault mid-read and was resumed to completion (second worker death that day — runtime flakiness, not task-related). Findings relayed verbatim, confirmed by orchestrator inspection, fix decisions journaled as decision(receipt-tree-binding) 2026-07-30, folded by the implementer in 1c0e82bf with the change's receipt re-verified. Related boards: 20260729-114458-review-claude.html (round 1), 20260729-154556-review-codex.html (round 2), council board 20260728-222942-council-receipt-freshness.html.

+
+
diff --git a/docs/changes/20260728-receipt-tree-binding/shape.md b/docs/changes/20260728-receipt-tree-binding/shape.md new file mode 100644 index 000000000..9dc0278fd --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/shape.md @@ -0,0 +1,137 @@ + + +# Receipt Tree Binding — Content-Digest Freshness, Merge-Strategy-Agnostic + +## Problem + +Receipt freshness is anchored to commit identity, and merge strategies are licensed to destroy commit identity. `receipts/verify.json` records `verified_commit`, and the gate's freshness check walks `verified_commit..HEAD` (`change_verify.go:493-525`); after a squash or rebase merge plus branch deletion, that commit is unreachable on every machine except the author's, so the same repository content yields a reasoned block on one machine and a `release blocked: cannot inspect receipt … exit status 128` crash on any fresh clone (stderr discarded by `commandOutput`). The council reproduced this end-to-end, and found two more defects the commit walk hides: + +1. **Cohorts of two or more are unsatisfiable.** The walk exempts only the change's own receipt path, so member B's committed receipt stales member A's — proven in every commit ordering, including both receipts in one atomic commit. The cohort gate has never been satisfiable for the multi-change cohort it exists to gate. +2. **Verify attests worktree results under a HEAD label.** Criteria run against the working tree while the receipt records `git rev-parse HEAD` — an uncommitted fix produces a green committed receipt for code no commit holds. +3. **`criteria_digest` drops the criterion's prose**, so a V-entry's claim text can be rewritten without expiring anything; `schema_version` is written and never read; the receipt commits an absolute local path (`cwd`); and reason strings are load-bearing control flow (`strings.HasPrefix` dispatch in `formatChangeReceiptBlock`). + +It bites the first cohort member that verifies on a branch — the sweep carrier, en route to 2.0.0 — and Loaf installs into arbitrary consumer repos whose merge policy it cannot dictate, so the gate must be merge-strategy-agnostic. + +## Hypothesis + +Binding receipt validity to content digests — the whole tree minus a declared, receipt-recorded exclusion set — makes the gate's verdict a pure function of receipt fields and HEAD content: same HEAD, same verdict, on any clone, under squash, merge commit, or rebase. The exclusion set (receipts repo-wide, report boards, and the release-metadata allowlist) makes **designation-legal equivalent to receipt-neutral**, so rc cuts and the promotion commit can never expire cohort receipts — the promotion model's by-construction guarantee — while any real code change anywhere expires evidence, preserving the work model's Decision 13 precedent that evidence must not survive a revert of the verified work. + +## Scope + +**In** + +- Canonical scope digest: `git ls-tree -r -z --full-tree ` entries minus the exclusion set, byte-sorted, mode included, domain-separation header, SHA-256, spec-versioned; the exclusion set defined as one exported boundary (receipts + reports masks ∪ release-metadata allowlist) for the promotion change to consume. +- Receipt `schema_version: 2`: `scope_digest`, `scope_sections` (per-top-level-directory sub-digests for drift naming), `exclusions` verbatim, `digest_spec`, `verified_root_tree` and `repo_tree_digest`-style provenance (recorded, never gating), `tool_version`, toolchain, `worktree_clean`; `verified_commit` demoted to provenance with a non-gating comment; `cwd` dropped; `criteria_digest` extended to cover criterion text. +- Verify hardening: refuse a dirty tracked worktree; drop the `-l` from `bash -lc` (login-shell environment divergence). +- Freshness read path rewrite: the commit walk deleted; a pure predicate over (receipt fields, HEAD tree); every checker state maps to a reasoned block verdict via a typed reason enum — unparseable, unsupported schema, digest mismatch (naming drifted sections), criteria mismatch, failing results, uncommitted, missing; the unreachable-commit inspection error becomes unreachable code. +- Regression fixtures: post-squash verification (verify on branch, squash-merge, delete branch, protocol-clone simulation → verified), the N≥2 cohort composability fixture, and the inverse of the touch-then-revert fixture asserting the new contract (byte-identical restore un-stales, deliberately). +- Message surfaces: `commandOutput` captures stderr into returned errors; block messages name the folder, the cause, and the copy-pasteable remedy. + +**Out** (deferred, not rejected) + +- The CI-green-at-HEAD assertion before tagging, rc-cut gating, and the designation diff check — the promotion-model change (`docs/changes/20260728-release-promotion-model/`) consumes this change's exclusion-set boundary for them. +- Per-change declared `evidence_paths` — coherent additive-only extension; ship only when a real case argues for it (council: medium-low, deliberately). +- Command dedup across an rc-sweep (`go test ./...` re-runs per member) — an optimization for cohorts big enough to feel it. +- A read-only receipt-status surface in `loaf change check` output — fold into the promotion change's skill work where the ceremony is defined. + +**Cut** (explicitly rejected) + +- Any reachability fallback when the verified commit happens to exist — a verdict that differs by object-store state is the defect formalized (council unanimous). +- Folder-scoped gating — the criteria read the repo, not the folder; rejected 3:2 with the owner settling on masked root (board: `reports/20260728-222942-council-receipt-freshness.html`). +- A configurability knob for expiry scope. +- `git mktree` for digest construction — writes objects into the store; an audit gate must not have side effects. +- Recomputing an absent digest from the current tree to "upgrade" a v1 receipt — an attestation whose subject is manufactured by its reader is not evidence (named anti-pattern for the ADR). +- "N commits old" drift metrics — they need reachability; drift is named by sections, machine-independently. + +## Observable Workflow + +``` +# verify writes a v2 receipt bound to content, refusing dirty trees +loaf change verify docs/changes/20260727-spec-conversion-and-guidance-sweep +# error: working tree differs from HEAD; commit before verifying + +# after squash merge + branch deletion, on a fresh clone: +loaf release --dry-run --bump release +# change "spec-conversion-and-guidance-sweep": verified (V1–V7 green) ← same verdict as the author's machine + +# a later code commit expires evidence with a named reason: +# release blocked: change "…" targets 2.0.0 but content changed since verification +# (content changed under internal/, content/). Run: loaf change verify docs/changes/…, then commit the receipt + +# two cohort members' receipts coexist — committed in one sweep commit, neither stales the other +``` + +## Rabbit Holes and No-Gos + +- **Do not grow the digest into semver-style cleverness or partial-tree negotiation.** One construction, one spec version, spec change expires everything — the same discipline `criteria_digest` already has. +- **Do not consult the worktree in the gate path.** The one surviving worktree read (`changeReceiptExistsInWorkingTree`) refines a reason between two blocking verdicts, never a verdict; reasons become a typed enum precisely so prose can't become control flow again. +- **Do not solve promotion here.** This change exports the exclusion-set boundary; the promotion change wires it into rc gating and the designation check. +- **Do not let the exclusion set accrete silently.** It is recorded verbatim in every receipt and any edit to it expires all receipts — correct, because the domain of the claim changed. + +## Decisions + +Provenance: five-member advisory council 2026-07-28, decision by the owner after board review (`reports/20260728-222942-council-receipt-freshness.html`); defect reproductions by the council against the real gate code; interview decisions logged as journal `decision(shape)`/`decision(council)` entries the same day. + +1. **The gating digest is the masked root tree.** Whole tree minus the declared exclusion set; folder-scoped gating rejected because the criteria's input domain is the repository (`go test ./...`, `npm run build`, `loaf check`) and Decision 13 of the work model already rejected evidence that survives a revert of the verified work. +2. **The exclusion set is one exported boundary: receipts (repo-wide) ∪ report boards ∪ the release-metadata allowlist.** Designation-legal ⟺ receipt-neutral — rc cuts and promotion cannot expire cohort receipts by construction; the promotion change consumes the same constant for its designation diff check. If the allowlist ever grows to admit paths criteria genuinely read, the sets diverge with the digest exclusion strictly smaller. +3. **Reachability leaves the verdict entirely; no fallback.** `verified_commit` is provenance. Diagnostics may consult extra objects when present but degrade to shorter messages — never different verdicts, never errors. +4. **Touch-then-revert detection is dropped deliberately.** Endpoint digests cannot see it, squash merges already erase it on main, and the receipt's claim is about content, not history; the fixture inverts to assert the new contract. +5. **Verify refuses a dirty tracked worktree.** A receipt from a dirty tree attests an execution against bytes no commit holds; three council members found this independently. +6. **Schema v2 is a clean break, shipped in the next alpha.** Zero receipts exist anywhere; v1 refused with a named re-verify remedy; no dual-read path; `schema_version` is read and enforced from now on. +7. **Drift is named by content, not counted by commits.** Per-top-level-directory sub-digests let every machine say "content changed under `internal/`" with no ancestry; wall-clock and cycle-relative signals stay advisory material for the promotion ceremony. +8. **Digest construction details are load-bearing and pinned in the ADR**: `-z` (quotePath immunity), byte-sort (traversal-order independence), mode included (executable-bit changes behavior), tree-path matching byte-exact and case-sensitive, entries from `ls-tree` never the filesystem (autocrlf/case-folding immunity), domain-separated hash, no `mktree`. + +## Planning Contract + +### Digest construction + +`scopeDigest(T, X)`: over every entry of `git ls-tree -r -z --full-tree ` whose path matches no glob in `X`, emit `path \0 mode \0 oid \n`, byte-sort ascending, prefix `loaf/change-evidence-digest\nv1\n`, SHA-256. `X = {docs/changes/*/receipts/**, docs/changes/*/reports/**} ∪ releaseMetadataAllowlist` where the allowlist names version files (`package.json`, `.claude-plugin/marketplace.json`), `CHANGELOG.md`, and the regenerated output roots (`dist/**`, `plugins/**`, `bin/**`). The boundary lives in one exported place; the composition obligation is stated in the ADR: masking regenerated outputs means the promotion designation check must independently prove they are the deterministic rebuild of source (the existing drift check does). + +### Freshness predicate + +`fresh(receipt, HEAD) ≡ schema_version supported ∧ exclusions = current ∧ digest_spec = current ∧ criteria_digest matches shape.md@HEAD (text included) ∧ scope_digest = scopeDigest(HEAD, X) ∧ results cover the criteria ID set ∧ all ok`. Every input derives from receipt fields and the pinned HEAD tree — no refs, no reachability, no worktree, no clock. `scope_sections` mismatches refine the block message, never the verdict. + +### Failure-mode table + +Missing → block `missing receipt`; present-in-worktree-only → block `receipt not committed at HEAD` (worktree stat is a hint layer refining prose only); unparseable → block `receipt unreadable — re-verify` (today an inspection error); unsupported schema → block naming the version and remedy; criteria mismatch → block `criteria changed (receipt expired)`; scope mismatch → block naming drifted sections; result-set mismatch or any `ok: false` → block naming IDs. Reasons are a typed enum; rendering derives from the type; no consumer parses prose. + +### Sequencing + +Digest + boundary first (TASK-001), then the write path (TASK-002) which needs the digest, then the read path (TASK-003) which needs both, then messages/fixture sweeps (TASK-004) which exercise everything. This change lands before the sweep carrier runs its first `loaf change verify` on a branch — the whole point — and before the promotion change's gate work, which imports the boundary. + +## Implementation Units + +- **TASK-001 — Scope digest and the evidence boundary.** The canonical digest construction, the exported exclusion-set constant, determinism tests (quotePath, sort, mode, case-sensitivity), and the sub-digest sections. +- **TASK-002 — Receipt schema v2 and the verify write path.** New fields, criteria-text digesting, dirty-worktree refusal, login-shell fix, `cwd` dropped, tool/toolchain recorded. +- **TASK-003 — Freshness predicate and typed verdicts.** Commit walk deleted, pure predicate, reason enum, every state a reasoned block; post-squash, cohort-composability, and inverted touch-then-revert fixtures. +- **TASK-004 — Messages and error capture.** `commandOutput` stderr capture, block wordings with copy-pasteable remedies, section-named drift in gate output. + +## Verification Contract + +- **V1.** The digest is deterministic and correctly masked: identical trees digest identically across quotePath/case/mode variations; excluded paths never participate; spec or exclusion change expires. Command: `go test ./internal/cli -run 'TestChangeScopeDigest' -count=1`. Expect: exit 0. +- **V2.** Verify writes v2 receipts: new fields present, criteria text digested, dirty tree refused, no absolute paths in the artifact. Command: `go test ./internal/cli -run 'TestChangeVerifySchemaV2' -count=1`. Expect: exit 0. +- **V3.** Freshness is machine-independent and reasoned: post-squash protocol-clone fixture verifies green; N≥2 cohort receipts coexist; every failure state yields a typed block, never an inspection error; v1 receipts refused with the remedy. Command: `go test ./internal/cli -run 'TestChangeReceiptFreshness' -count=1`. Expect: exit 0. +- **V4.** The full suite is green. Command: `go test ./...`. Expect: exit 0. + + + +- **H1.** A gate transcript over the fixtures reads as reasoned verdicts with named remedies — no raw git errors surface anywhere. +- **H2.** The council board in `reports/` accurately records the decision and its provenance; the ADR pins the digest serialization exactly. + +## Definition of Done + +- All V-entries green at HEAD via `loaf change verify` with the receipt committed — this change's own receipt is the first v2 receipt and the first post-squash survivor, its own proof. +- `loaf change check` reports zero violations and the change derives executable. +- The `cannot inspect receipt` error path is deleted, not just avoided; `grep` finds no reachability consult in the freshness path. + +## Durable Outputs + +- ADR: receipt validity binds to content — the masked root-tree digest, the exported evidence boundary, the exact serialization, the dropped touch-then-revert property and why, the no-fallback rule, and the named anti-pattern (never recompute an absent digest from the current tree). +- ADR-023 successor note: the receipt attests criteria-against-declared-scope; tree-is-green at cut belongs to CI (the promotion change lands that assertion). +- `docs/knowledge/work-model.md`: freshness section updated from commit-walk to content-digest semantics, including the rc-sweep verification rhythm. + +## Open Questions + + + +- [KU] Exact glob grammar for the exclusion constant (component-anchored vs prefix match) → TASK-001's tactical choice; pinned in the ADR with the serialization. diff --git a/docs/changes/20260728-receipt-tree-binding/tasks/TASK-001-scope-digest-and-evidence-boundary.md b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-001-scope-digest-and-evidence-boundary.md new file mode 100644 index 000000000..614666911 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-001-scope-digest-and-evidence-boundary.md @@ -0,0 +1,55 @@ +--- +change: receipt-tree-binding +id: TASK-001 +title: Scope digest and the evidence boundary +blocks: + - TASK-002 + - TASK-003 +--- + +# TASK-001 — Scope digest and the evidence boundary + +## Objective + +The canonical content digest exists and is deterministic on every machine: `scopeDigest(tree, exclusions)` built from `git ls-tree -r -z --full-tree ` entries (`path\0mode\0oid`), byte-sorted, excluded paths dropped by byte-exact tree-path match, domain header `loaf/change-evidence-digest\nv1\n`, SHA-256. The exclusion set ships as one exported boundary constant — `docs/changes/*/receipts/**`, `docs/changes/*/reports/**`, and the release-metadata allowlist (version files, `CHANGELOG.md`, `dist/**`, `plugins/**`, `bin/**`) — that the promotion change will import for its designation check. Per-top-level-directory sub-digests (`scope_sections`) come from the same serialization. + +## Scope boundaries + +**In:** New digest code beside `internal/cli/change_verify.go` (or a sibling file), the exported boundary constant, sub-digest sections, unit tests. + +**Out:** Receipt schema fields and the verify write path (TASK-002); the freshness predicate (TASK-003); any gate or message changes (TASK-003/004); `git mktree` in any form (rejected — writes objects). + +## Context pointers + +- Contract: `shape.md` — Planning Contract → Digest construction; Decisions 1, 2, 8. +- Council board: `reports/20260728-222942-council-receipt-freshness.html` — Git Internals card for the exact plumbing rationale; Correctness card for the predicate the digest feeds. +- Pinning precedent: `internal/cli/change_state.go:76-115` (`pinEvidenceAtHEAD`). + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-001 — scope digest and evidence boundary" +# Read internal/cli/change_verify.go and change_state.go pinning before writing. +``` + +## Steps + +- [x] Implement `scopeDigest` per the pinned serialization; entries from `ls-tree` only, never the filesystem. +- [x] Export the evidence-boundary constant with the three mask groups; settle the glob grammar (component-anchored vs prefix) and record the choice here for the ADR. +- [x] Emit `scope_sections`: per-top-level-directory sub-digests from the same filtered, sorted entry stream. +- [x] Tests under `TestChangeScopeDigest`: identical trees digest identically regardless of `core.quotePath`; sort independence from traversal order; mode change (100644→100755) changes the digest; excluded paths never participate; a receipts-only or reports-only commit leaves the digest unchanged; case-sensitive mask matching. + +## Verification + +- `go test ./internal/cli -run 'TestChangeScopeDigest' -count=1` green. +- The boundary constant is importable without cycle from the release-gate package paths the promotion change will touch. + + +## Glob grammar (pinned for ADR) + +Component-anchored, byte-exact, case-sensitive matching over forward-slash tree paths from `git ls-tree`: +- literal segments match exactly +- `*` matches exactly one path segment +- trailing `**` matches zero or more remaining segments + +Chosen over prefix-match so `dist/**` cannot swallow `distributor/…`, and over filesystem globs so matching stays quotePath/autocrlf immune. diff --git a/docs/changes/20260728-receipt-tree-binding/tasks/TASK-002-receipt-schema-v2-and-verify-write-path.md b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-002-receipt-schema-v2-and-verify-write-path.md new file mode 100644 index 000000000..af98690dc --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-002-receipt-schema-v2-and-verify-write-path.md @@ -0,0 +1,47 @@ +--- +change: receipt-tree-binding +id: TASK-002 +title: Receipt schema v2 and the verify write path +blocked-by: + - TASK-001 +blocks: + - TASK-003 +--- + +# TASK-002 — Receipt schema v2 and the verify write path + +## Objective + +`loaf change verify` writes `schema_version: 2` receipts bound to content: `scope_digest`, `scope_sections`, `exclusions` (verbatim), `digest_spec`, `verified_root_tree` (provenance, never gating), `tool_version`, toolchain (`go`, `os`, `arch`), `worktree_clean`; `verified_commit` kept as provenance with a non-gating struct comment; `cwd` dropped; `criteria_digest` extended to cover each criterion's text. Verify refuses to run when the tracked worktree differs from HEAD, and criteria run under `bash -c` (no login shell). + +## Scope boundaries + +**In:** `internal/cli/change_verify.go` write path (`runChangeVerify`, receipt struct, `changeCriteriaDigest`, `runChangeCriterionCommand`), tests. + +**Out:** The freshness read path (TASK-003 — do not touch `changeReceiptStatus` here); digest internals (TASK-001 — consume them); gate messages (TASK-004). + +## Context pointers + +- Contract: `shape.md` — Scope → In; Decisions 5, 6; Planning Contract → Digest construction. +- Dirty-tree false-pass and criteria-text gap: council board, Audit Integrity card. +- Login-shell divergence: council board, Portability/DX card. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-002 — receipt schema v2 and verify write path" +# Read internal/cli/change_verify.go:35-170 and 390-400 before editing. +``` + +## Steps + +- [x] Extend the receipt struct and writer with the v2 fields; drop `cwd`; comment `verified_commit` as provenance-only. +- [x] Extend `changeCriteriaDigest` to include criterion text; bump nothing else about its discipline. +- [x] Refuse a dirty tracked worktree before running any criterion, with the exact message `working tree differs from HEAD; commit before verifying`. +- [x] Change `bash -lc` to `bash -c` in `runChangeCriterionCommand`. +- [x] Tests under `TestChangeVerifySchemaV2`: all v2 fields present and correct; no absolute path anywhere in the artifact; dirty tree refused (tracked edits, staged edits; untracked files do not refuse); criteria-text edit changes the digest. + +## Verification + +- `go test ./internal/cli -run 'TestChangeVerifySchemaV2' -count=1` green. +- A receipt written on this branch round-trips through TASK-003's reader once both land. diff --git a/docs/changes/20260728-receipt-tree-binding/tasks/TASK-003-freshness-predicate-and-typed-verdicts.md b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-003-freshness-predicate-and-typed-verdicts.md new file mode 100644 index 000000000..861f85837 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-003-freshness-predicate-and-typed-verdicts.md @@ -0,0 +1,47 @@ +--- +change: receipt-tree-binding +id: TASK-003 +title: Freshness predicate and typed verdicts +blocked-by: + - TASK-001 + - TASK-002 +blocks: + - TASK-004 +--- + +# TASK-003 — Freshness predicate and typed verdicts + +## Objective + +The commit walk is deleted and freshness becomes a pure function of receipt fields and the pinned HEAD tree: supported schema ∧ current exclusions ∧ current digest spec ∧ criteria digest matches `shape.md@HEAD` ∧ scope digest matches ∧ results cover the criteria ID set ∧ all passing. Every checker state maps to a reasoned block through a typed reason enum (missing, uncommitted, unreadable, unsupported schema, criteria mismatch, content drift naming sections, results gap, failing results); the `cannot inspect receipt` inspection error becomes unreachable code; no reachability is ever consulted for the verdict — diagnostics guard optional object reads with `git cat-file -e` and degrade to shorter prose, never different verdicts. + +## Scope boundaries + +**In:** `internal/cli/change_verify.go` read path (`changeReceiptStatus`, `changeReceiptAtHEAD`, `formatChangeReceiptBlock`), the reason enum, gate call sites in `change_release_gate.go` that consume the status, fixtures. + +**Out:** Digest construction (TASK-001); write path (TASK-002); message wording polish and `commandOutput` (TASK-004) — land mechanics first, wording second. + +## Context pointers + +- Contract: `shape.md` — Planning Contract → Freshness predicate + Failure-mode table; Decisions 3, 4, 6, 7. +- Council board: Correctness card (predicate and failure table), Git Internals card (no-fallback rule, merge-mode table). +- Current walk: `change_verify.go:493-525`; prose dispatch: `formatChangeReceiptBlock`. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-003 — freshness predicate and typed verdicts" +# Read internal/cli/change_verify.go:430-560 and change_release_gate.go:60-113 before editing. +``` + +## Steps + +- [x] Replace `changeReceiptStatus` internals with the pure predicate; delete the `git log`/`diff-tree` walk and the reachability error branch. +- [x] Introduce the typed reason enum; derive all rendering from it; `changeReceiptExistsInWorkingTree` demoted to a prose hint that can never change a verdict. +- [x] Refuse v1/unknown-schema receipts with the named remedy; enforce `schema_version` reading explicitly. +- [x] Fixtures: post-squash protocol-clone (verify on branch, squash-merge, delete branch, clone via `file://` transport, gate verifies green); N≥2 cohort receipts committed in one sweep commit both stay fresh; touch-then-revert inverse (byte-identical restore is fresh, with a comment recording Decision 4); every enum state produces its block and never an error. + +## Verification + +- `go test ./internal/cli -run 'TestChangeReceiptFreshness' -count=1` green. +- `go test ./...` green — the retired walk's old fixtures updated, none silently skipped. diff --git a/docs/changes/20260728-receipt-tree-binding/tasks/TASK-004-messages-and-error-capture.md b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-004-messages-and-error-capture.md new file mode 100644 index 000000000..5a8c42c09 --- /dev/null +++ b/docs/changes/20260728-receipt-tree-binding/tasks/TASK-004-messages-and-error-capture.md @@ -0,0 +1,44 @@ +--- +change: receipt-tree-binding +id: TASK-004 +title: Messages and error capture +blocked-by: + - TASK-003 +--- + +# TASK-004 — Messages and error capture + +## Objective + +Every receipt-related surface names the folder, the cause, and a copy-pasteable remedy: `commandOutput` captures stderr into returned errors (killing the bare `exit status N` class repo-wide), gate blocks read per the DX wordings (stale content naming drifted sections, criteria changed, unsupported schema — never "invalid" or "corrupt"), and drift refinement uses `scope_sections` ("content changed under `internal/`, `content/`") with no ancestry anywhere. + +## Scope boundaries + +**In:** `internal/cli/check.go` `commandOutput`, block/reason rendering from TASK-003's enum, gate output in `change_release_gate.go`, message tests. + +**Out:** Predicate mechanics (TASK-003); anything in the release/promotion surface (the promotion change owns rc gating and ceremony output); skill prose. + +## Context pointers + +- Contract: `shape.md` — Observable Workflow; Failure-mode table. +- Council board: Portability/DX card — exact wordings for stale, criteria-changed, and old-schema blocks; the `exit status 128` support-ticket anatomy. +- `commandOutput`: `internal/cli/check.go:1391-1396`. + +## Acquisition + +```bash +loaf journal log "skill(implement): TASK-004 — messages and error capture" +# Read internal/cli/check.go:1380-1400 and the TASK-003 enum rendering before editing. +``` + +## Steps + +- [x] `commandOutput` includes captured stderr in returned errors; audit its call sites for double-printing. +- [x] Render the DX wordings from the reason enum; every block names folder, cause, and the `loaf change verify ` + commit remedy. +- [x] Drift blocks name the drifted top-level sections from `scope_sections`. +- [x] Message tests: no raw `exit status` string reaches gate output in any fixture state; wordings match the contract. + +## Verification + +- `go test ./internal/cli -run 'TestChangeReceiptFreshness|TestReleaseCohort' -count=1` green with message assertions. +- H1 transcript over the fixtures reads as reasoned verdicts end to end. diff --git a/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md b/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md index f0a5eedcc..49b1bae62 100644 --- a/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md +++ b/docs/decisions/ADR-023-execution-provenance-and-cohort-receipts.md @@ -4,7 +4,7 @@ title: "Execution provenance and cohort receipts — git as the witness, verify status: Accepted date: 2026-07-28 supersedes: null -superseded_by: null +superseded_by: ADR-024 # freshness + receipt schema claims annotated below; see ADR-024 --- # ADR-023: Execution provenance and cohort receipts @@ -17,11 +17,11 @@ ADR-022's release cohorts need an evidence model: what proves a change was execu **Two provenance grades, git-derived.** Path grade (display): a change is executing when a commit in HEAD ancestry modifies its `tasks/` files and at least one path outside `docs/changes/` entirely, so cross-change task edits and folder-local scaffolding never count. Flip grade (gating): the commit's diff must carry a true `- [ ]`→`- [x]` transition — removed-unchecked and added-checked lines in the same hunk with the same normalized label, outside code fences with fence state tracked. Reverse flips, added boxes in either state, whitespace and title edits, and fenced-block flips are non-events, each with a negative fixture. The accepted failure direction is false negatives: work that does not register blocks stable until a one-commit remedy; a false positive would open a release on nothing. -**The receipt is a committed cache of verify.** `loaf change verify` parses executable criteria from `shape.md`'s Verification Contract (V-entries declaring `Command:` and `Expect:`, inline or sub-bullet form), runs exactly those commands from the repository root, and writes `receipts/verify.json`: schema version, change slug, verified commit, criteria digest, target release, cwd, and per-criterion command, exit code, output digest, and ok. H-tier prose criteria are review material, never gate input. +**The receipt is a committed cache of verify.** `loaf change verify` parses executable criteria from `shape.md`'s Verification Contract (V-entries declaring `Command:` and `Expect:`, inline or sub-bullet form), runs exactly those commands from the repository root, and writes `receipts/verify.json`. *(Superseded by ADR-024 schema v2: the v1 field list below — including absolute `cwd` — is historical. Schema v2 records `scope_digest` / `scope_sections` / `exclusions` / `digest_spec` / `verified_root_tree` / `tool_version` / `toolchain` / `worktree_clean`; `verified_commit` is provenance only; `cwd` is dropped; `criteria_digest` covers criterion text.)* Historical v1 field list retained for provenance: schema version, change slug, verified commit, criteria digest, target release, cwd, and per-criterion command, exit code, output digest, and ok. H-tier prose criteria are review material, never gate input. **Success is required, not just execution.** A receipt recording any `ok: false` criterion blocks with `receipt records failing criteria ()`. Verify still writes the receipt on failure — the evidence stays on disk — and exits non-zero; the gate is the arbiter. -**Freshness.** Any later commit touching a path other than the receipt file itself stales the receipt; the receipt's own commit never does (the bootstrap case). Criteria edits expire the digest regardless of freshness; `plan.md` edits leave the digest intact and surface as the re-verify demand, never as expiry. Staleness is inspected commit-by-commit, so a touch-then-revert pair still forces re-verification. +**Freshness (superseded by ADR-024).** The commit-walk freshness rule below is historical. Receipt validity now binds to a masked root-tree content digest — see ADR-024. Retained for provenance of the original design: any later commit touching a path other than the receipt file itself staled the receipt; the receipt's own commit never did; touch-then-revert still forced re-verification. **The gate is a pure reader; verify is the only runner.** A stale or failing receipt blocks with a plain reason naming the mechanical remedy (`run: loaf change verify , then commit the receipt`) rather than re-running criteria at preflight: running declared commands from preflight would make `--dry-run` a lie and would leave its freshest result uncommitted. Evidence is always committed before it is read. @@ -31,6 +31,6 @@ ADR-022's release cohorts need an evidence model: what proves a change was execu Release preflight is deterministic from the repository alone — clone, run, same verdict — and the verification evidence is a reviewable PR artifact rather than a machine-local fact. The B1 incident is under contract (V5 requires failing receipts to block naming the failed IDs) with `RejectsFailingReceipt` and `V5PlanMdEditStalesNotExpires` among the fixtures. -Costs accepted: any post-verify commit imposes a re-verify before stable finalization — `INTENT-20260727-exempt-plan-md-from-receipt-freshness-re-run` stays open as a UX refinement, weighed against every freshness exemption being a future argument. The installed-smoke capability receipts pin the committed binary hash, so every code commit demands a rebuild and re-record; the canary fired four times during this change's implementation, and `INTENT-20260719-decouple-installed-smoke-evidence-from-binary-rebuilds` tracks the standing fix. Delegation discipline matters to the evidence trail: task packets must be committed unchecked before execution, or the delivering diff carries added-checked lines and no transition. +Costs accepted: *(Superseded by ADR-024: content-digest freshness, not "any later commit." Only a change to masked-in content — or to the exclusion boundary / digest_spec / criteria — expires a receipt; receipt-only and report-board commits, and designation-legal release-metadata edits, do not.)* Historical cost retained for provenance: any post-verify commit imposes a re-verify before stable finalization — `INTENT-20260727-exempt-plan-md-from-receipt-freshness-re-run` stays open as a UX refinement, weighed against every freshness exemption being a future argument. The installed-smoke capability receipts pin the committed binary hash, so every code commit demands a rebuild and re-record; the canary fired four times during this change's implementation, and `INTENT-20260719-decouple-installed-smoke-evidence-from-binary-rebuilds` tracks the standing fix. Delegation discipline matters to the evidence trail: task packets must be committed unchecked before execution, or the delivering diff carries added-checked lines and no transition. Provenance: `docs/changes/20260726-change-work-model/` shape.md Decisions 2, 13–15 and the Provenance precision contract; review boards rounds 1–2 (B1 reproduction transcript on round 1, block-with-reason adjudication R2-2 on round 2); commits `799019f4`, `6fd0464a`, `9584a307`; journal `finding(release-gate)` and `decision(review)` 2026-07-27→28. diff --git a/docs/decisions/ADR-024-receipt-content-digest-freshness.md b/docs/decisions/ADR-024-receipt-content-digest-freshness.md new file mode 100644 index 000000000..70dbabe43 --- /dev/null +++ b/docs/decisions/ADR-024-receipt-content-digest-freshness.md @@ -0,0 +1,44 @@ +--- +id: ADR-024 +title: "Receipt validity binds to a masked root-tree content digest" +status: Accepted +date: 2026-07-29 +supersedes: null +superseded_by: null +related: + - ADR-023 +--- + +# ADR-024: Receipt validity binds to a masked root-tree content digest + +## Context + +ADR-023's receipt freshness walked `verified_commit..HEAD` and exempted only the change's own receipt path. Merge strategies that destroy commit identity (squash, rebase) make that walk machine-dependent: the author's object store may still hold the commit while a protocol clone exits 128 into `cannot inspect receipt`. The walk also made N≥2 cohorts unsatisfiable — member B's receipt stales member A — and left touch-then-revert as a load-bearing property that squash merges already erase on main. + +Council 2026-07-28 (`docs/changes/20260728-receipt-tree-binding/reports/20260728-222942-council-receipt-freshness.html`) settled on binding validity to content. + +## Decision + +**Gating digest.** `scopeDigest(T, X)` over `git ls-tree -r -z --full-tree ` entries whose path matches no glob in `X`: emit `path\0mode\0oid\n`, byte-sort ascending, prefix `loaf/change-evidence-digest\nv1\n`, SHA-256. Entries come from ls-tree only — never the filesystem. Mode is included. Matching is byte-exact and case-sensitive. + +**Exclusion set (one exported boundary).** `X = ChangeEvidenceExclusions()` = `docs/changes/*/receipts/**` ∪ `docs/changes/*/reports/**` ∪ `ReleaseMetadataAllowlist` (version files, `CHANGELOG.md`, `dist/**`, `plugins/**`, `bin/**`). Designation-legal ≡ receipt-neutral: the promotion Change imports the same constant. Masking regenerated outputs obliges the designation check to prove they are the deterministic rebuild of source. + +**Glob grammar.** Component-anchored: `*` is one segment; trailing `**` is zero-or-more remaining segments. Not prefix match (`dist/**` must not match `distributor/…`). + +**Freshness predicate.** A pure function of receipt fields and the pinned HEAD tree — supported schema ∧ current exclusions ∧ current digest_spec ∧ criteria digest (text included) ∧ scope digest ∧ results cover criteria IDs ∧ all ok. Reachability never participates in the verdict. `verified_commit` is provenance only. `scope_sections` refine drift messages, never the verdict. + +**Schema v2 clean break.** No dual-read of v1; unsupported schema blocks with a re-verify remedy. Never recompute an absent digest from the current tree to "upgrade" a receipt — an attestation whose subject is manufactured by its reader is not evidence. + +**Touch-then-revert detection dropped.** Endpoint digests cannot see it; squash already erases it; the claim is about content, not history. A byte-identical restore un-stales deliberately. + +**Verify refuses a dirty tracked worktree.** A receipt must not attest execution against bytes no commit holds. Dirty checks (pre- and post-criteria) exempt exactly the receipt and report masks — never the release-metadata allowlist — so a criterion that mutates tracked `dist/`/`bin/`/version files fails closed. Post-run divergence still writes the receipt (write-on-failure) with `worktree_clean: false`; the freshness predicate rejects that field as a typed void-execution verdict. + +## Consequences + +Same HEAD yields the same verdict on every clone under any merge strategy. Cohort receipts coexist because receipts are masked. The promotion model can cut rc / promote without expiring evidence by construction. Cost accepted: any real code landing expires cohort receipts until an rc-point re-verify sweep. + +**ADR-023 successor note.** The receipt attests criteria-against-declared-scope; tree-is-green at cut belongs to CI (the promotion change lands that assertion). + +ADR-023's freshness section (commit-walk staleness, touch-then-revert), v1 receipt schema (including `cwd`), and "any later commit stales" cost claim are superseded by this ADR; provenance grades, verify-as-only-runner, and success-required rules remain. + +Provenance: `docs/changes/20260728-receipt-tree-binding/` shape.md Decisions 1–8; council board 2026-07-28. diff --git a/docs/knowledge/work-model.md b/docs/knowledge/work-model.md index a0d806cf9..46b2a73d6 100644 --- a/docs/knowledge/work-model.md +++ b/docs/knowledge/work-model.md @@ -28,7 +28,7 @@ last_reviewed: '2026-07-28' - The Pipeline - Projections: Served, Never Committed -How bounded work moves through Loaf: what a Change is, which documents carry which role, how tasks become committed evidence, and how releases read that evidence. Rationale lives in [ADR-022](../decisions/ADR-022-change-anatomy-and-release-cohorts.md) (anatomy, cohorts, commitments) and [ADR-023](../decisions/ADR-023-execution-provenance-and-cohort-receipts.md) (provenance, receipts, the gate); this document is the operating view. +How bounded work moves through Loaf: what a Change is, which documents carry which role, how tasks become committed evidence, and how releases read that evidence. Rationale lives in [ADR-022](../decisions/ADR-022-change-anatomy-and-release-cohorts.md) (anatomy, cohorts, commitments) and [ADR-023](../decisions/ADR-023-execution-provenance-and-cohort-receipts.md) (provenance, receipts, the gate) and [ADR-024](../decisions/ADR-024-receipt-content-digest-freshness.md) (content-digest freshness); this document is the operating view. ## The Unit: a Change @@ -81,7 +81,7 @@ captured → shaped → executable → executing → complete Declaring `target_release: X.Y.Z` in `change.json` (canonical form — no `v`, no leading zeros, no prerelease) opts the change into the strong gate. The cohort — every change sharing that target — is the arc, derived rather than declared. Cutting stable `X.Y.Z` requires the whole cohort executed at flip grade and receipt-verified; one shaped-only member blocks the version. Prerelease candidates always flow. `--bump release` always gates; `--post-merge` keys on the prepared version at HEAD — a prepared prerelease publishes through the valve and tags exactly what the version files carry, while a prepared stable gates that version's cohort before tagging it. -The working loop for a cohort member: land the tasks → `loaf change verify ` → commit `receipts/verify.json` → release. Block messages name their remedy — `structurally invalid (…)` and `not executable (contract gaps: …)` (fix what `loaf change check` reports; a brief-only member reads as `contract gaps: shape.md (missing)`), `not executed` (land a real flip commit), `receipt records failing criteria (V1)` (fix, re-verify, recommit), `receipt is not current` (re-verify after later commits; an uncommitted receipt reads `not committed at HEAD`, and the receipt's own commit never stales it), `legacy member — convert first` (sanctioned atomic conversion, boxes unchecked). +The working loop for a cohort member: land the tasks → `loaf change verify ` → commit `receipts/verify.json` → release. Block messages name their remedy — `structurally invalid (…)` and `not executable (contract gaps: …)` (fix what `loaf change check` reports; a brief-only member reads as `contract gaps: shape.md (missing)`), `not executed` (land a real flip commit), `receipt records failing criteria (V1)` (fix, re-verify, recommit), `content changed since verification` / `criteria changed (receipt expired)` (re-verify after content or criteria drift; an uncommitted receipt reads `not committed at HEAD`; receipts bind to a masked root-tree content digest — ADR-024 — so squash/rebase and cohort peers never machine-split the verdict), `legacy member — convert first` (sanctioned atomic conversion, boxes unchecked). Slipping a change to a later release is a retarget: a reviewable `change.json` diff, surfaced at check and preflight, never blocked. The roadmap is this projection — cohorts and their derived states — not a planner. diff --git a/internal/cli/change_evidence.go b/internal/cli/change_evidence.go new file mode 100644 index 000000000..fa8237108 --- /dev/null +++ b/internal/cli/change_evidence.go @@ -0,0 +1,224 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" +) + +// ChangeEvidenceDigestSpec is the domain-separation / serialization version +// recorded on every v2 receipt as digest_spec. Bumping it expires all receipts. +const ChangeEvidenceDigestSpec = "v1" + +const changeEvidenceDigestDomain = "loaf/change-evidence-digest\nv1\n" + +// ChangeEvidenceReceiptMasks exclude every change's receipt surface from the +// scope digest so cohort members' receipts never stale each other. +var ChangeEvidenceReceiptMasks = []string{ + "docs/changes/*/receipts/**", +} + +// ChangeEvidenceReportMasks exclude authored report boards from the scope digest. +var ChangeEvidenceReportMasks = []string{ + "docs/changes/*/reports/**", +} + +// ReleaseMetadataAllowlist names paths the release/promotion ceremony may rewrite +// without changing receipt-bound content. The promotion Change imports this +// constant for its designation diff check so designation-legal ≡ receipt-neutral. +// +// Composition obligation: masking regenerated outputs (dist/**, plugins/**, bin/**) +// means the promotion designation check must independently prove they are the +// deterministic rebuild of source. +var ReleaseMetadataAllowlist = []string{ + "package.json", + ".claude-plugin/marketplace.json", + "CHANGELOG.md", + "dist/**", + "plugins/**", + "bin/**", +} + +// ChangeEvidenceExclusions is the full exclusion set for scopeDigest: +// receipts ∪ reports ∪ ReleaseMetadataAllowlist. Exported so the promotion +// Change can import the same boundary without redefining it. +func ChangeEvidenceExclusions() []string { + out := make([]string, 0, len(ChangeEvidenceReceiptMasks)+len(ChangeEvidenceReportMasks)+len(ReleaseMetadataAllowlist)) + out = append(out, ChangeEvidenceReceiptMasks...) + out = append(out, ChangeEvidenceReportMasks...) + out = append(out, ReleaseMetadataAllowlist...) + return out +} + +// changeTreeEntry is one ls-tree blob/commit entry used for digest construction. +type changeTreeEntry struct { + Mode string + OID string + Path string +} + +// changeScopeDigestResult holds the masked root digest and per-top-level-directory +// sub-digests derived from the same filtered, sorted entry stream. +type changeScopeDigestResult struct { + Digest string + Sections map[string]string +} + +// scopeDigest computes the content digest for treeish under exclusions. +// Serialization (pinned): over every git ls-tree -r -z --full-tree entry whose +// path matches no glob in exclusions, emit path\0mode\0oid\n, byte-sort +// ascending, prefix the domain header, SHA-256 hex. +// +// Glob grammar (component-anchored, byte-exact, case-sensitive): +// - literal segments match exactly +// - * matches exactly one path segment +// - ** as a trailing segment matches zero or more remaining segments +// +// Paths come from ls-tree only — never the filesystem — so quotePath, autocrlf, +// and case-folding cannot change the digest. +func scopeDigest(rootPath, treeish string, exclusions []string, outputCommand changeGitOutput) (changeScopeDigestResult, error) { + if outputCommand == nil { + outputCommand = commandOutput + } + raw, err := outputCommand(rootPath, "git", "ls-tree", "-r", "-z", "--full-tree", treeish) + if err != nil { + return changeScopeDigestResult{}, fmt.Errorf("ls-tree %s: %w", treeish, err) + } + entries, err := parseLSTreeNUL(raw) + if err != nil { + return changeScopeDigestResult{}, err + } + filtered := make([]changeTreeEntry, 0, len(entries)) + for _, e := range entries { + if evidencePathExcluded(e.Path, exclusions) { + continue + } + filtered = append(filtered, e) + } + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Path < filtered[j].Path + }) + digest := hashEvidenceEntries(filtered) + sections := map[string]string{} + bySection := map[string][]changeTreeEntry{} + for _, e := range filtered { + section := evidenceTopLevelSection(e.Path) + bySection[section] = append(bySection[section], e) + } + for section, sectionEntries := range bySection { + sections[section] = hashEvidenceEntries(sectionEntries) + } + return changeScopeDigestResult{Digest: digest, Sections: sections}, nil +} + +func hashEvidenceEntries(entries []changeTreeEntry) string { + var b strings.Builder + b.WriteString(changeEvidenceDigestDomain) + for _, e := range entries { + b.WriteString(e.Path) + b.WriteByte(0) + b.WriteString(e.Mode) + b.WriteByte(0) + b.WriteString(e.OID) + b.WriteByte('\n') + } + sum := sha256.Sum256([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +func evidenceTopLevelSection(path string) string { + if i := strings.IndexByte(path, '/'); i >= 0 { + return path[:i] + } + return path +} + +// evidencePathExcluded reports whether path matches any exclusion glob. +func evidencePathExcluded(path string, exclusions []string) bool { + for _, pattern := range exclusions { + if matchEvidenceGlob(path, pattern) { + return true + } + } + return false +} + +// matchEvidenceGlob matches a git tree path against a component-anchored glob. +// Matching is byte-exact and case-sensitive; * is one segment; trailing ** is +// zero-or-more remaining segments. +func matchEvidenceGlob(path, pattern string) bool { + pathParts := splitPathSegments(path) + patternParts := splitPathSegments(pattern) + return matchEvidenceParts(pathParts, patternParts) +} + +func splitPathSegments(path string) []string { + if path == "" { + return nil + } + return strings.Split(path, "/") +} + +func matchEvidenceParts(pathParts, patternParts []string) bool { + pi, pti := 0, 0 + for pti < len(patternParts) { + pat := patternParts[pti] + if pat == "**" { + if pti == len(patternParts)-1 { + return true + } + // Non-trailing ** is not used by the exclusion set; treat as "match + // any prefix then resume" for completeness. + rest := patternParts[pti+1:] + for skip := 0; skip <= len(pathParts)-pi; skip++ { + if matchEvidenceParts(pathParts[pi+skip:], rest) { + return true + } + } + return false + } + if pi >= len(pathParts) { + return false + } + if pat != "*" && pat != pathParts[pi] { + return false + } + pi++ + pti++ + } + return pi == len(pathParts) +} + +// parseLSTreeNUL parses `git ls-tree -z` output into entries. +// Each record is: SP SP TAB NUL +func parseLSTreeNUL(raw string) ([]changeTreeEntry, error) { + if raw == "" { + return nil, nil + } + records := strings.Split(raw, "\x00") + entries := make([]changeTreeEntry, 0, len(records)) + for _, rec := range records { + if rec == "" { + continue + } + tab := strings.IndexByte(rec, '\t') + if tab < 0 { + return nil, fmt.Errorf("ls-tree record missing tab: %q", rec) + } + meta := rec[:tab] + path := rec[tab+1:] + parts := strings.SplitN(meta, " ", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("ls-tree record malformed meta: %q", rec) + } + mode, typ, oid := parts[0], parts[1], parts[2] + if typ != "blob" && typ != "commit" { + // -r lists blobs (and submodule commits); skip unexpected types. + continue + } + entries = append(entries, changeTreeEntry{Mode: mode, OID: oid, Path: path}) + } + return entries, nil +} diff --git a/internal/cli/change_evidence_test.go b/internal/cli/change_evidence_test.go new file mode 100644 index 000000000..8db9ac61f --- /dev/null +++ b/internal/cli/change_evidence_test.go @@ -0,0 +1,251 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestChangeScopeDigest(t *testing.T) { + t.Run("identical-trees-digest-identically-regardless-of-quotePath", func(t *testing.T) { + repo := initCLIGitRepo(t) + writeEvidenceFixtureTree(t, repo) + commitAllChangeTest(t, repo, "chore: seed evidence tree") + + gitCLI(t, repo, "config", "core.quotePath", "true") + a, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("digest quotePath=true: %v", err) + } + gitCLI(t, repo, "config", "core.quotePath", "false") + b, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("digest quotePath=false: %v", err) + } + if a.Digest == "" || a.Digest != b.Digest { + t.Fatalf("digest mismatch under quotePath: %s vs %s", a.Digest, b.Digest) + } + if len(a.Sections) == 0 { + t.Fatal("expected scope_sections") + } + }) + + t.Run("sort-independent-of-traversal-order", func(t *testing.T) { + // Two trees with the same entries must digest identically even if we + // feed unsorted ls-tree output through the parser path — scopeDigest + // byte-sorts before hashing. + entries := []changeTreeEntry{ + {Mode: "100644", OID: "aaa", Path: "z.txt"}, + {Mode: "100644", OID: "bbb", Path: "a.txt"}, + {Mode: "100755", OID: "ccc", Path: "m/bin"}, + } + reversed := []changeTreeEntry{entries[2], entries[1], entries[0]} + sortedCopy := append([]changeTreeEntry(nil), entries...) + // Mimic scopeDigest's sort. + sortEvidenceEntries(sortedCopy) + sortEvidenceEntries(reversed) + if hashEvidenceEntries(sortedCopy) != hashEvidenceEntries(reversed) { + t.Fatal("byte-sort must make digest traversal-order independent") + } + }) + + t.Run("mode-change-changes-digest", func(t *testing.T) { + repo := initCLIGitRepo(t) + path := filepath.Join(repo, "script.sh") + if err := os.WriteFile(path, []byte("#!/bin/sh\necho hi\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + commitAllChangeTest(t, repo, "chore: add script non-executable") + before, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("digest before: %v", err) + } + gitCLI(t, repo, "update-index", "--chmod=+x", "script.sh") + gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "chore: mark executable") + after, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("digest after: %v", err) + } + if before.Digest == after.Digest { + t.Fatal("100644→100755 must change the digest") + } + }) + + t.Run("excluded-paths-never-participate", func(t *testing.T) { + repo := initCLIGitRepo(t) + writeEvidenceFixtureTree(t, repo) + commitAllChangeTest(t, repo, "chore: seed") + + baseline, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("baseline: %v", err) + } + + // Receipts-only commit. + receipt := filepath.Join(repo, "docs", "changes", "20260728-demo", "receipts", "verify.json") + if err := os.MkdirAll(filepath.Dir(receipt), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(receipt, []byte(`{"schema_version":2}`+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile receipt: %v", err) + } + commitAllChangeTest(t, repo, "chore: receipts only") + afterReceipt, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("after receipt: %v", err) + } + if afterReceipt.Digest != baseline.Digest { + t.Fatalf("receipts-only commit must leave digest unchanged: %s → %s", baseline.Digest, afterReceipt.Digest) + } + + // Reports-only commit. + report := filepath.Join(repo, "docs", "changes", "20260728-demo", "reports", "board.html") + if err := os.MkdirAll(filepath.Dir(report), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(report, []byte("\n"), 0o644); err != nil { + t.Fatalf("WriteFile report: %v", err) + } + commitAllChangeTest(t, repo, "chore: reports only") + afterReport, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("after report: %v", err) + } + if afterReport.Digest != baseline.Digest { + t.Fatalf("reports-only commit must leave digest unchanged: %s → %s", baseline.Digest, afterReport.Digest) + } + + // Allowlist paths. + if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"version":"9.9.9"}`+"\n"), 0o644); err != nil { + t.Fatalf("WriteFile package.json: %v", err) + } + if err := os.MkdirAll(filepath.Join(repo, "dist"), 0o755); err != nil { + t.Fatalf("mkdir dist: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, "dist", "out.js"), []byte("x\n"), 0o644); err != nil { + t.Fatalf("WriteFile dist: %v", err) + } + commitAllChangeTest(t, repo, "chore: release metadata only") + afterMeta, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("after meta: %v", err) + } + if afterMeta.Digest != baseline.Digest { + t.Fatalf("allowlist-only commit must leave digest unchanged") + } + + // A real code path must change it. + if err := os.WriteFile(filepath.Join(repo, "internal", "cli", "x.go"), []byte("package cli\n"), 0o644); err != nil { + t.Fatalf("WriteFile code: %v", err) + } + commitAllChangeTest(t, repo, "feat: real code") + afterCode, err := scopeDigest(repo, "HEAD", ChangeEvidenceExclusions(), nil) + if err != nil { + t.Fatalf("after code: %v", err) + } + if afterCode.Digest == baseline.Digest { + t.Fatal("non-excluded path must change digest") + } + if afterCode.Sections["internal"] == "" || afterCode.Sections["internal"] == baseline.Sections["internal"] { + t.Fatalf("internal section must drift: %#v vs %#v", baseline.Sections, afterCode.Sections) + } + }) + + t.Run("case-sensitive-mask-matching", func(t *testing.T) { + if matchEvidenceGlob("Docs/changes/x/receipts/verify.json", "docs/changes/*/receipts/**") { + t.Fatal("mask must be case-sensitive") + } + if !matchEvidenceGlob("docs/changes/x/receipts/verify.json", "docs/changes/*/receipts/**") { + t.Fatal("expected match for exact-case receipts path") + } + if matchEvidenceGlob("Package.json", "package.json") { + t.Fatal("package.json mask must be case-sensitive") + } + if !matchEvidenceGlob("dist/foo/bar.js", "dist/**") { + t.Fatal("dist/** must match nested paths") + } + if matchEvidenceGlob("distributor/x", "dist/**") { + t.Fatal("dist/** must not prefix-match unrelated paths") + } + }) + + t.Run("exclusions-exported-boundary", func(t *testing.T) { + got := ChangeEvidenceExclusions() + wantParts := []string{ + "docs/changes/*/receipts/**", + "docs/changes/*/reports/**", + "package.json", + ".claude-plugin/marketplace.json", + "CHANGELOG.md", + "dist/**", + "plugins/**", + "bin/**", + } + if len(got) != len(wantParts) { + t.Fatalf("exclusions = %#v, want %#v", got, wantParts) + } + for i, want := range wantParts { + if got[i] != want { + t.Fatalf("exclusions[%d] = %q, want %q", i, got[i], want) + } + } + if ChangeEvidenceDigestSpec != "v1" { + t.Fatalf("digest spec = %q, want v1", ChangeEvidenceDigestSpec) + } + if len(ReleaseMetadataAllowlist) == 0 { + t.Fatal("ReleaseMetadataAllowlist must be exported for promotion Change") + } + }) +} + +func writeEvidenceFixtureTree(t *testing.T, repo string) { + t.Helper() + files := map[string]string{ + "internal/cli/main.go": "package cli\n", + "content/skills/x.md": "# skill\n", + "weird name.txt": "space\n", + } + for rel, body := range files { + path := filepath.Join(repo, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("WriteFile %s: %v", rel, err) + } + } +} + +func sortEvidenceEntries(entries []changeTreeEntry) { + // Local helper mirroring scopeDigest's byte-sort without importing sort in + // every call site of the test — keep deterministic for the fixture. + for i := 0; i < len(entries); i++ { + for j := i + 1; j < len(entries); j++ { + if entries[j].Path < entries[i].Path { + entries[i], entries[j] = entries[j], entries[i] + } + } + } +} + +func TestMatchEvidenceGlob(t *testing.T) { + cases := []struct { + path, pattern string + want bool + }{ + {"docs/changes/foo/receipts/verify.json", "docs/changes/*/receipts/**", true}, + {"docs/changes/foo/receipts", "docs/changes/*/receipts/**", true}, + {"docs/changes/foo/shape.md", "docs/changes/*/receipts/**", false}, + {"docs/changes/foo/bar/receipts/x", "docs/changes/*/receipts/**", false}, + {"bin/loaf", "bin/**", true}, + {"bin", "bin/**", true}, + {"CHANGELOG.md", "CHANGELOG.md", true}, + {"docs/CHANGELOG.md", "CHANGELOG.md", false}, + } + for _, tc := range cases { + got := matchEvidenceGlob(tc.path, tc.pattern) + if got != tc.want { + t.Fatalf("match(%q, %q) = %v, want %v", tc.path, tc.pattern, got, tc.want) + } + } +} diff --git a/internal/cli/change_provenance.go b/internal/cli/change_provenance.go index 1c8a4b341..0bfafc59e 100644 --- a/internal/cli/change_provenance.go +++ b/internal/cli/change_provenance.go @@ -266,13 +266,18 @@ func formatChangeExecutionBlock(slug, target string, layout string, status chang return "" } -// formatChangeReceiptBlock renders a cohort receipt failure. Failing criteria -// are stated plainly; freshness failures keep the "not current" framing and -// name the mechanical remedy — preflight never runs criteria. -func formatChangeReceiptBlock(slug, target, reason, folder string) string { - if strings.HasPrefix(reason, "receipt records failing criteria") { - return fmt.Sprintf("change %q targets %s but %s", slug, target, reason) +// formatChangeReceiptBlock renders a cohort receipt failure from a typed +// verdict. Every block names the folder, the cause, and a copy-pasteable remedy +// — preflight never runs criteria. +func formatChangeReceiptBlock(slug, target string, verdict changeReceiptVerdict, folder string) string { + folder = filepath.ToSlash(folder) + cause := verdict.Cause() + if verdict.Reason == changeReceiptFailingResults { + return fmt.Sprintf("change %q targets %s but %s. Fix the failing criteria, then run: loaf change verify %s and commit the receipt", slug, target, cause, folder) } - return fmt.Sprintf("change %q targets %s but receipt is not current (%s); run: loaf change verify %s, then commit the receipt", - slug, target, reason, folder) + if verdict.Reason == changeReceiptEvidenceUnavailable { + return fmt.Sprintf("change %q targets %s but %s. Verification cannot proceed until git reads succeed — inspect the repository (git fsck) or re-clone", slug, target, cause) + } + remedy := fmt.Sprintf("Run: loaf change verify %s, then commit the receipt", folder) + return fmt.Sprintf("change %q targets %s but %s. %s", slug, target, cause, remedy) } diff --git a/internal/cli/change_receipt_freshness_test.go b/internal/cli/change_receipt_freshness_test.go new file mode 100644 index 000000000..14e1a0dec --- /dev/null +++ b/internal/cli/change_receipt_freshness_test.go @@ -0,0 +1,359 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestChangeReceiptFreshness(t *testing.T) { + t.Run("post-squash-protocol-clone-stays-verified", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + // Unchecked task lands on main first so the squash commit's diff carries a real flip. + dir := writeNewLayoutChange(t, repo, "20260727-squash", "squash", "1.0.0", "") + task := filepath.Join(dir, "tasks", "TASK-001-work.md") + if err := os.WriteFile(task, []byte("---\nchange: squash\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile unchecked: %v", err) + } + commitAllChangeTest(t, repo, "docs: shape squash on main") + + gitCLI(t, repo, "checkout", "-b", "feature-squash") + if err := os.WriteFile(filepath.Join(repo, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("WriteFile main.go: %v", err) + } + if err := os.WriteFile(task, []byte("---\nchange: squash\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile flip: %v", err) + } + commitAllChangeTest(t, repo, "feat: execute squash") + folderRel := filepath.Join("docs", "changes", "20260727-squash") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify on branch: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit receipt on feature branch") + + gitCLI(t, repo, "checkout", "main") + gitCLI(t, repo, "merge", "--squash", "feature-squash") + gitCLI(t, repo, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "squash: land feature") + gitCLI(t, repo, "branch", "-D", "feature-squash") + + if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { + t.Fatalf("author machine after squash should verify: %v", err) + } + + clone := t.TempDir() + cmd := exec.Command("git", "clone", "--", "file://"+repo, clone) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("protocol clone: %v\n%s", err, out) + } + if err := releaseCohortPreflight(clone, "1.0.0", nil); err != nil { + t.Fatalf("protocol clone must yield same verified verdict: %v", err) + } + }) + + t.Run("cohort-of-two-receipts-coexist", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + dirA := writeNewLayoutChange(t, repo, "20260727-cohort-a", "cohort-a", "1.0.0", "") + dirB := writeNewLayoutChange(t, repo, "20260727-cohort-b", "cohort-b", "1.0.0", "") + flipExecuteChange(t, repo, dirA, "cohort-a") + + taskB := filepath.Join(dirB, "tasks", "TASK-001-work.md") + if err := os.MkdirAll(filepath.Dir(taskB), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(taskB, []byte("---\nchange: cohort-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile unchecked: %v", err) + } + commitAllChangeTest(t, repo, "docs: shape cohort-b") + if err := os.WriteFile(filepath.Join(repo, "main_b.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("WriteFile main_b.go: %v", err) + } + if err := os.WriteFile(taskB, []byte("---\nchange: cohort-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile flip: %v", err) + } + commitAllChangeTest(t, repo, "feat: execute cohort-b") + + folderA := filepath.Join("docs", "changes", "20260727-cohort-a") + folderB := filepath.Join("docs", "changes", "20260727-cohort-b") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { + t.Fatalf("verify A: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { + t.Fatalf("verify B: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit both cohort receipts") + if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { + t.Fatalf("N=2 cohort receipts must coexist: %v", err) + } + }) + + t.Run("touch-then-revert-inverse-stays-fresh", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + dir := writeNewLayoutChange(t, repo, "20260727-revert", "revert", "1.0.0", "") + flipExecuteChange(t, repo, dir, "revert") + folderRel := filepath.Join("docs", "changes", "20260727-revert") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit receipt") + + path := filepath.Join(repo, "touch.txt") + if err := os.WriteFile(path, []byte("x\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + commitAllChangeTest(t, repo, "chore: touch") + if err := os.Remove(path); err != nil { + t.Fatalf("Remove: %v", err) + } + commitAllChangeTest(t, repo, "chore: restore bytes") + // ADR-024 Decision 4: byte-identical restore un-stales deliberately. + if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { + t.Fatalf("byte-identical restore must stay fresh: %v", err) + } + }) + + t.Run("every-reason-is-a-typed-block-never-inspection-error", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0\n- **V2.** Also. Command: `true`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260727-reasons", "reasons", "1.0.0", body) + flipExecuteChange(t, repo, dir, "reasons") + folderRel := filepath.Join("docs", "changes", "20260727-reasons") + node, err := assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + if err != nil { + t.Fatalf("assemble: %v", err) + } + + assertBlock := func(t *testing.T, verdict changeReceiptVerdict, want changeReceiptReason, substr string) { + t.Helper() + if verdict.OK || verdict.Reason != want { + t.Fatalf("verdict=%#v, want reason %v", verdict, want) + } + msg := formatChangeReceiptBlock("reasons", "1.0.0", verdict, folderRel) + if strings.Contains(msg, "cannot inspect") || strings.Contains(msg, "exit status") { + t.Fatalf("inspection error leaked: %s", msg) + } + if !strings.Contains(msg, substr) { + t.Fatalf("msg=%q, want substr %q", msg, substr) + } + if want == changeReceiptEvidenceUnavailable { + if !strings.Contains(msg, "git fsck") || !strings.Contains(msg, "re-clone") { + t.Fatalf("msg=%q, want seam-recovery remedy", msg) + } + if strings.Contains(msg, "loaf change verify") { + t.Fatalf("msg=%q must not prescribe re-verify through the same broken seam", msg) + } + } else if !strings.Contains(msg, "loaf change verify") { + t.Fatalf("msg=%q, want remedy", msg) + } + lower := strings.ToLower(msg) + if strings.Contains(lower, "invalid") || strings.Contains(lower, "corrupt") { + t.Fatalf("DX wording must not say invalid/corrupt: %s", msg) + } + } + + verdict := changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptMissing, "missing receipt") + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify: %v", err) + } + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptUncommitted, "not committed") + + commitAllChangeTest(t, repo, "chore: commit receipt") + node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("fresh: %#v", verdict) + } + + v1 := changeVerifyReceipt{SchemaVersion: 1, Change: "reasons", CriteriaDigest: "x", Results: []changeVerifyCriterionResult{{ID: "V1", OK: true}, {ID: "V2", OK: true}}} + writeCommittedReceipt(t, repo, dir, v1) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptUnsupportedSchema, "unsupported receipt schema_version 1") + + if err := os.WriteFile(filepath.Join(dir, "receipts", "verify.json"), []byte("{not-json"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + commitAllChangeTest(t, repo, "chore: unreadable receipt") + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptUnreadable, "unreadable") + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("re-verify: %v", err) + } + commitAllChangeTest(t, repo, "chore: restore receipt") + expired := shapeWithVerification("- **V1.** Smoke changed. Command: `true`. Expect: exit 0\n- **V2.** Also. Command: `true`. Expect: exit 0") + if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(expired), 0o644); err != nil { + t.Fatalf("WriteFile shape: %v", err) + } + commitAllChangeTest(t, repo, "docs: change criteria text") + node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptCriteriaMismatch, "criteria changed") + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify after criteria: %v", err) + } + commitAllChangeTest(t, repo, "chore: re-verify") + driftPath := filepath.Join(repo, "internal", "cli", "drift.go") + if err := os.MkdirAll(filepath.Dir(driftPath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(driftPath, []byte("package cli\n"), 0o644); err != nil { + t.Fatalf("WriteFile drift: %v", err) + } + commitAllChangeTest(t, repo, "feat: drift content") + node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptContentDrift, "content changed under") + if !strings.Contains(verdict.Cause(), "`internal`") { + t.Fatalf("cause should name internal section: %s", verdict.Cause()) + } + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify clean: %v", err) + } + commitAllChangeTest(t, repo, "chore: fresh again") + boundary := mustReadVerifyReceipt(t, dir) + if len(boundary.Exclusions) == 0 { + t.Fatal("expected exclusions on fresh receipt") + } + boundary.Exclusions = append(append([]string{}, boundary.Exclusions...), "docs/changes/*/extra/**") + writeCommittedReceipt(t, repo, dir, boundary) + node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptBoundaryChanged, "evidence boundary changed since verification (receipt expired)") + + brokenGit := func(cwd, name string, args ...string) (string, error) { + return "", fmt.Errorf("exit status 128: fatal: simulated git seam failure") + } + verdict = changeReceiptStatus(repo, folderRel, node, brokenGit) + assertBlock(t, verdict, changeReceiptEvidenceUnavailable, "could not read evidence at HEAD (git error)") + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify after boundary: %v", err) + } + commitAllChangeTest(t, repo, "chore: restore after boundary") + good := mustReadVerifyReceipt(t, dir) + good.Results = good.Results[:1] + writeCommittedReceipt(t, repo, dir, good) + node, _ = assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptResultsGap, "missing criteria (V2)") + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify: %v", err) + } + commitAllChangeTest(t, repo, "chore: full results") + failing := mustReadVerifyReceipt(t, dir) + failing.Results[0].OK = false + writeCommittedReceipt(t, repo, dir, failing) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + assertBlock(t, verdict, changeReceiptFailingResults, "failing criteria (V1)") + msg := formatChangeReceiptBlock("reasons", "1.0.0", verdict, folderRel) + if !strings.Contains(msg, "Fix the failing criteria, then run: loaf change verify") { + t.Fatalf("failing block missing named remedy: %s", msg) + } + }) + + t.Run("re-verify-succeeds-with-committed-receipt-after-drift", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + dir := writeNewLayoutChange(t, repo, "20260727-reverify", "reverify", "1.0.0", "") + flipExecuteChange(t, repo, dir, "reverify") + folderRel := filepath.Join("docs", "changes", "20260727-reverify") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("initial verify: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit receipt") + + if err := os.WriteFile(filepath.Join(repo, "drift.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("WriteFile drift: %v", err) + } + commitAllChangeTest(t, repo, "feat: content drift") + + // Re-verify must succeed without an intermediate commit of the receipt — + // the dirty check exempts the receipt mask so a tracked receipts/verify.json + // rewrite does not self-block. + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("re-verify with committed receipt after drift: %v", err) + } + receipt := mustReadVerifyReceipt(t, dir) + if !receipt.WorktreeClean { + t.Fatal("re-verify receipt must record worktree_clean true") + } + }) + + t.Run("cohort-reverify-sweep-with-committed-receipts", func(t *testing.T) { + repo := seedCohortGateRepo(t, "1.0.0-alpha.1") + dirA := writeNewLayoutChange(t, repo, "20260727-sweep-a", "sweep-a", "1.0.0", "") + dirB := writeNewLayoutChange(t, repo, "20260727-sweep-b", "sweep-b", "1.0.0", "") + flipExecuteChange(t, repo, dirA, "sweep-a") + + taskB := filepath.Join(dirB, "tasks", "TASK-001-work.md") + if err := os.MkdirAll(filepath.Dir(taskB), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(taskB, []byte("---\nchange: sweep-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [ ] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile unchecked: %v", err) + } + commitAllChangeTest(t, repo, "docs: shape sweep-b") + if err := os.WriteFile(filepath.Join(repo, "main_sweep.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("WriteFile main_sweep.go: %v", err) + } + if err := os.WriteFile(taskB, []byte("---\nchange: sweep-b\nid: TASK-001\ntitle: Work\n---\n\n# Work\n\n## Steps\n\n- [x] Do it\n"), 0o644); err != nil { + t.Fatalf("WriteFile flip: %v", err) + } + commitAllChangeTest(t, repo, "feat: execute sweep-b") + + folderA := filepath.Join("docs", "changes", "20260727-sweep-a") + folderB := filepath.Join("docs", "changes", "20260727-sweep-b") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { + t.Fatalf("verify A: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { + t.Fatalf("verify B: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit both cohort receipts") + if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { + t.Fatalf("initial cohort green: %v", err) + } + + if err := os.WriteFile(filepath.Join(repo, "sweep_drift.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatalf("WriteFile drift: %v", err) + } + commitAllChangeTest(t, repo, "feat: drift expires receipts") + + // True sweep: re-verify A then B back-to-back with no commits between. + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderA}); err != nil { + t.Fatalf("re-verify A: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderB}); err != nil { + t.Fatalf("re-verify B with A's uncommitted receipt dirty: %v", err) + } + commitAllChangeTest(t, repo, "chore: sweep-commit both receipts") + if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { + t.Fatalf("sweep cohort must be green: %v", err) + } + }) +} + +func writeCommittedReceipt(t *testing.T, repo, dir string, receipt changeVerifyReceipt) { + t.Helper() + data, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + path := filepath.Join(dir, "receipts", "verify.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + commitAllChangeTest(t, repo, "chore: write receipt fixture") +} diff --git a/internal/cli/change_receipt_status.go b/internal/cli/change_receipt_status.go new file mode 100644 index 000000000..a5c70fc5c --- /dev/null +++ b/internal/cli/change_receipt_status.go @@ -0,0 +1,179 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// changeReceiptReason is the typed freshness verdict. Rendering derives from the +// type; consumers must not parse prose (ADR-024). +type changeReceiptReason int + +const ( + changeReceiptOK changeReceiptReason = iota + changeReceiptMissing + changeReceiptUncommitted + changeReceiptUnreadable + changeReceiptUnsupportedSchema + changeReceiptCriteriaMismatch + changeReceiptContentDrift + changeReceiptBoundaryChanged + changeReceiptResultsGap + changeReceiptFailingResults + changeReceiptDirtyExecution + changeReceiptEvidenceUnavailable +) + +// changeReceiptVerdict is the pure freshness outcome for one change folder. +type changeReceiptVerdict struct { + OK bool + Reason changeReceiptReason + DriftedSections []string + FailedIDs []string + MissingIDs []string + SchemaVersion int +} + +// Cause returns the stable cause fragment used in tests and block messages. +func (v changeReceiptVerdict) Cause() string { + switch v.Reason { + case changeReceiptOK: + return "" + case changeReceiptMissing: + return "missing receipt" + case changeReceiptUncommitted: + return "receipt not committed at HEAD" + case changeReceiptUnreadable: + return "receipt unreadable — re-verify" + case changeReceiptUnsupportedSchema: + return fmt.Sprintf("unsupported receipt schema_version %d", v.SchemaVersion) + case changeReceiptCriteriaMismatch: + return "criteria changed (receipt expired)" + case changeReceiptContentDrift: + if len(v.DriftedSections) == 0 { + return "content changed since verification" + } + quoted := make([]string, len(v.DriftedSections)) + for i, s := range v.DriftedSections { + quoted[i] = "`" + s + "`" + } + return fmt.Sprintf("content changed since verification (content changed under %s)", strings.Join(quoted, ", ")) + case changeReceiptBoundaryChanged: + return "evidence boundary changed since verification (receipt expired)" + case changeReceiptResultsGap: + return fmt.Sprintf("receipt results missing criteria (%s)", strings.Join(v.MissingIDs, ", ")) + case changeReceiptFailingResults: + return fmt.Sprintf("receipt records failing criteria (%s)", strings.Join(v.FailedIDs, ", ")) + case changeReceiptDirtyExecution: + return "verify ran against a worktree that diverged from HEAD (receipt void)" + case changeReceiptEvidenceUnavailable: + return "could not read evidence at HEAD (git error)" + default: + return "receipt not current" + } +} + +// changeReceiptStatus reports whether a committed receipt attests successful +// verification of the pinned HEAD tree. The verdict is a pure function of +// receipt fields and HEAD content — no refs, no reachability, no worktree for +// the verdict itself. changeReceiptExistsInWorkingTree only refines the missing +// vs uncommitted cause when HEAD has no receipt. Git-seam failures are typed +// blocking verdicts, never errors. +func changeReceiptStatus(rootPath, folderRel string, node changeNode, outputCommand changeGitOutput) changeReceiptVerdict { + if outputCommand == nil { + outputCommand = commandOutput + } + receiptRel := changeReceiptRelPath(folderRel) + content, found, err := readCommittedOptional(rootPath, "HEAD", receiptRel, outputCommand) + if err != nil { + return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} + } + if !found { + if changeReceiptExistsInWorkingTree(rootPath, folderRel) { + return changeReceiptVerdict{Reason: changeReceiptUncommitted} + } + return changeReceiptVerdict{Reason: changeReceiptMissing} + } + var receipt changeVerifyReceipt + if err := json.Unmarshal([]byte(content), &receipt); err != nil { + return changeReceiptVerdict{Reason: changeReceiptUnreadable} + } + if receipt.SchemaVersion != 2 { + return changeReceiptVerdict{Reason: changeReceiptUnsupportedSchema, SchemaVersion: receipt.SchemaVersion} + } + if !receipt.WorktreeClean { + return changeReceiptVerdict{Reason: changeReceiptDirtyExecution} + } + currentExclusions := ChangeEvidenceExclusions() + if !slices.Equal(receipt.Exclusions, currentExclusions) || receipt.DigestSpec != ChangeEvidenceDigestSpec { + return changeReceiptVerdict{Reason: changeReceiptBoundaryChanged} + } + criteria := parseChangeExecutableCriteria(node.Content) + if changeCriteriaDigest(criteria) != receipt.CriteriaDigest { + return changeReceiptVerdict{Reason: changeReceiptCriteriaMismatch} + } + if failed := receiptFailingCriterionIDs(receipt); len(failed) > 0 { + return changeReceiptVerdict{Reason: changeReceiptFailingResults, FailedIDs: failed} + } + if missing := receiptMissingCriterionIDs(receipt, criteria); len(missing) > 0 { + return changeReceiptVerdict{Reason: changeReceiptResultsGap, MissingIDs: missing} + } + head, err := outputCommand(rootPath, "git", "rev-parse", "HEAD") + if err != nil { + return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} + } + head = strings.TrimSpace(head) + scope, err := scopeDigest(rootPath, head, currentExclusions, outputCommand) + if err != nil { + return changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable} + } + if scope.Digest != receipt.ScopeDigest { + return changeReceiptVerdict{ + Reason: changeReceiptContentDrift, + DriftedSections: driftedScopeSections(receipt.ScopeSections, scope.Sections), + } + } + return changeReceiptVerdict{OK: true, Reason: changeReceiptOK} +} + +func receiptMissingCriterionIDs(receipt changeVerifyReceipt, criteria []changeCriterion) []string { + have := map[string]bool{} + for _, r := range receipt.Results { + have[r.ID] = true + } + var missing []string + for _, c := range criteria { + if !have[c.ID] { + missing = append(missing, c.ID) + } + } + return missing +} + +func driftedScopeSections(recorded, current map[string]string) []string { + keys := map[string]struct{}{} + for k := range recorded { + keys[k] = struct{}{} + } + for k := range current { + keys[k] = struct{}{} + } + var drifted []string + for k := range keys { + if recorded[k] != current[k] { + drifted = append(drifted, k) + } + } + slices.Sort(drifted) + return drifted +} + +func changeReceiptExistsInWorkingTree(rootPath, folderRel string) bool { + folderAbs := filepath.Join(rootPath, filepath.FromSlash(folderRel)) + _, err := os.Stat(filepath.Join(folderAbs, filepath.FromSlash(changeVerifyReceiptFile))) + return err == nil +} diff --git a/internal/cli/change_release_gate.go b/internal/cli/change_release_gate.go index aa1d56f42..15bb67265 100644 --- a/internal/cli/change_release_gate.go +++ b/internal/cli/change_release_gate.go @@ -85,12 +85,9 @@ func releaseCohortPreflightWithOutput(rootPath, candidate string, outputCommand blocked = append(blocked, msg) continue } - ok, reason, receiptErr := changeReceiptStatus(rootPath, node.Folder, node, outputCommand) - if receiptErr != nil { - return fmt.Errorf("release blocked: cannot inspect receipt for %q: %w", node.Slug, receiptErr) - } - if !ok { - blocked = append(blocked, formatChangeReceiptBlock(node.Slug, candidate, reason, node.Folder)) + verdict := changeReceiptStatus(rootPath, node.Folder, node, outputCommand) + if !verdict.OK { + blocked = append(blocked, formatChangeReceiptBlock(node.Slug, candidate, verdict, node.Folder)) } } diff --git a/internal/cli/change_release_gate_test.go b/internal/cli/change_release_gate_test.go index 6e51f99b4..c08c15575 100644 --- a/internal/cli/change_release_gate_test.go +++ b/internal/cli/change_release_gate_test.go @@ -477,7 +477,8 @@ func TestReleaseCohortGateReceiptFreshnessBootstrap(t *testing.T) { t.Fatalf("receipt-only commit should not stale: %v", err) } - // Touch then revert a non-receipt path — tree diff is empty, commit-by-commit is not. + // Decision 4 / ADR-024: touch-then-revert is deliberately undetectable — + // byte-identical restore leaves the receipt fresh. other := filepath.Join(repo, "other.txt") if err := os.WriteFile(other, []byte("touch\n"), 0o644); err != nil { t.Fatalf("WriteFile other: %v", err) @@ -488,28 +489,18 @@ func TestReleaseCohortGateReceiptFreshnessBootstrap(t *testing.T) { } commitAllChangeTest(t, repo, "chore: revert other") - gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "later non-receipt path requires criteria re-run") { - t.Fatalf("touch-then-revert should stale: %v", gateErr) - } - - // Re-verify after the stale commits. - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { - t.Fatalf("re-verify: %v", err) - } - commitAllChangeTest(t, repo, "chore: re-verify after stale") if err := releaseCohortPreflight(repo, "1.0.0", nil); err != nil { - t.Fatalf("fresh receipt should pass: %v", err) + t.Fatalf("touch-then-revert must stay fresh under content digest: %v", err) } - // Any other later non-receipt path stales. + // A lasting content change stales with a typed drift reason. if err := os.WriteFile(filepath.Join(repo, "stale.txt"), []byte("x\n"), 0o644); err != nil { t.Fatalf("WriteFile stale: %v", err) } - commitAllChangeTest(t, repo, "chore: later non-receipt path") - gateErr = releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "later non-receipt path requires criteria re-run") { - t.Fatalf("later path should stale: %v", gateErr) + commitAllChangeTest(t, repo, "chore: lasting content change") + gateErr := releaseCohortPreflight(repo, "1.0.0", nil) + if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { + t.Fatalf("lasting content change should drift: %v", gateErr) } } @@ -714,7 +705,7 @@ func TestReleaseCohortGateV5CriteriaEditExpiresReceipt(t *testing.T) { commitAllChangeTest(t, repo, "docs: edit shape criteria") gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "criteria digest mismatch (receipt expired)") { + if gateErr == nil || !strings.Contains(gateErr.Error(), "criteria changed (receipt expired)") { t.Fatalf("criteria edit should expire receipt: %v", gateErr) } } @@ -738,7 +729,7 @@ func TestReleaseCohortGateV5FreshnessRerunAndReceiptOwnCommit(t *testing.T) { } commitAllChangeTest(t, repo, "chore: later non-receipt path") gateErr := releaseCohortPreflight(repo, "1.0.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "later non-receipt path requires criteria re-run") { + if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { t.Fatalf("non-receipt path should force re-run: %v", gateErr) } } @@ -786,13 +777,13 @@ func TestReleaseCohortGateV5PlanMdEditStalesNotExpires(t *testing.T) { t.Fatal("plan.md-only commit should stale the receipt") } msg := gateErr.Error() - if !strings.Contains(msg, "later non-receipt path requires criteria re-run") { - t.Fatalf("want re-verify demand, got: %v", gateErr) + if !strings.Contains(msg, "content changed since verification") { + t.Fatalf("want content-drift demand, got: %v", gateErr) } if strings.Contains(msg, "receipt expired") { t.Fatalf("must not report expiry for plan.md edit: %v", gateErr) } - remedy := "run: loaf change verify " + filepath.ToSlash(folderRel) + ", then commit the receipt" + remedy := "Run: loaf change verify " + filepath.ToSlash(folderRel) + ", then commit the receipt" if !strings.Contains(msg, remedy) { t.Fatalf("want mechanical remedy %q, got: %v", remedy, gateErr) } @@ -817,8 +808,8 @@ func TestReleaseCohortGateV5RetargetAfterVerifyRequiresRerun(t *testing.T) { // Blind trust would accept the pre-retarget receipt; freshness must force re-run. gateErr := releaseCohortPreflight(repo, "2.1.0", nil) - if gateErr == nil || !strings.Contains(gateErr.Error(), "later non-receipt path requires criteria re-run") { - t.Fatalf("retarget should trigger re-run path: %v", gateErr) + if gateErr == nil || !strings.Contains(gateErr.Error(), "content changed since verification") { + t.Fatalf("retarget should trigger content drift: %v", gateErr) } // Not permanent invalidation: re-verify opens the new cohort. diff --git a/internal/cli/change_state.go b/internal/cli/change_state.go index 3eed533d0..c4aabe476 100644 --- a/internal/cli/change_state.go +++ b/internal/cli/change_state.go @@ -52,14 +52,14 @@ func deriveChangeStateDetailed(rootPath string, node changeNode, outputCommand c if pinErr != nil { warnings = append(warnings, "evidence pin failed: "+pinErr.Error()) } else { - ok, receiptErr, clean, evalErr := evaluateVerifiedRungAtCommit(rootPath, node, evidenceGit) + ok, clean, evalErr, receiptWarn := evaluateVerifiedRungAtCommit(rootPath, node, evidenceGit) if evalErr != "" { warnings = append(warnings, "structural evaluation failed: "+evalErr) } - if receiptErr != nil { - warnings = append(warnings, "receipt evaluation failed: "+receiptErr.Error()) + if receiptWarn != "" { + warnings = append(warnings, receiptWarn) } - if receiptErr == nil && ok && clean { + if ok && clean { return "verified", warnings } } @@ -117,29 +117,33 @@ func rewriteHEADToken(arg, sha string) string { // evaluateVerifiedRungAtCommit loads the committed node once and feeds that same // node (folder + content) into both the structural composite and the receipt // check — never the working-tree node the ladder received. -func evaluateVerifiedRungAtCommit(rootPath string, node changeNode, outputCommand changeGitOutput) (ok bool, receiptErr error, clean bool, evalErr string) { +func evaluateVerifiedRungAtCommit(rootPath string, node changeNode, outputCommand changeGitOutput) (ok bool, clean bool, evalErr string, receiptWarn string) { if outputCommand == nil { outputCommand = changeEvidenceGitOutput } nodes, err := loadChangeNodesAtHEADWithOutput(rootPath, outputCommand) if err != nil { - return false, nil, false, err.Error() + return false, false, err.Error(), "" } headNode, found := changeNodeForFolder(nodes, node.Folder) if !found { headNode, found = changeNodeForSlug(nodes, node.Slug) } if !found { - return false, nil, false, fmt.Sprintf("change %q missing from committed HEAD", node.Slug) + return false, false, fmt.Sprintf("change %q missing from committed HEAD", node.Slug), "" } folderAbs := filepath.Join(rootPath, filepath.FromSlash(headNode.Folder)) report, reportErr := composeChangeCheckReport(evaluateChangeNode(headNode, ""), rootPath, folderAbs, headNode, nodes, outputCommand, false, changeTaskContentHEAD) if reportErr != nil { - return false, nil, false, reportErr.Error() + return false, false, reportErr.Error(), "" } clean = len(report.Violations) == 0 && report.Executable - ok, _, receiptErr = changeReceiptStatus(rootPath, headNode.Folder, headNode, outputCommand) - return ok, receiptErr, clean, "" + verdict := changeReceiptStatus(rootPath, headNode.Folder, headNode, outputCommand) + ok = verdict.OK + if !verdict.OK && verdict.Reason != changeReceiptMissing && verdict.Reason != changeReceiptOK { + receiptWarn = "receipt evaluation failed: " + verdict.Cause() + } + return ok, clean, "", receiptWarn } // changeStructurallyCleanForState reports whether the gate's structural @@ -151,7 +155,7 @@ func changeStructurallyCleanForState(rootPath string, node changeNode, outputCom if outputCommand == nil { outputCommand = changeEvidenceGitOutput } - _, _, clean, evalErr := evaluateVerifiedRungAtCommit(rootPath, node, outputCommand) + _, clean, evalErr, _ := evaluateVerifiedRungAtCommit(rootPath, node, outputCommand) return clean, evalErr } diff --git a/internal/cli/change_state_test.go b/internal/cli/change_state_test.go index a8b273a80..00a557337 100644 --- a/internal/cli/change_state_test.go +++ b/internal/cli/change_state_test.go @@ -539,9 +539,9 @@ func TestChangeStateVerifiedRungIgnoresDirtyCriteriaAndRename(t *testing.T) { if pinErr != nil { t.Fatalf("pin: %v", pinErr) } - ok, receiptErr, clean, evalErr := evaluateVerifiedRungAtCommit(repo, wtNode, evidenceGit) - if evalErr != "" || receiptErr != nil || !ok || !clean { - t.Fatalf("renamed+dirty WT node: ok=%v clean=%v receiptErr=%v evalErr=%q, want verified rung", ok, clean, receiptErr, evalErr) + ok, clean, evalErr, _ := evaluateVerifiedRungAtCommit(repo, wtNode, evidenceGit) + if evalErr != "" || !ok || !clean { + t.Fatalf("renamed+dirty WT node: ok=%v clean=%v evalErr=%q, want verified rung", ok, clean, evalErr) } } diff --git a/internal/cli/change_verify.go b/internal/cli/change_verify.go index 78e3d7b2e..f48475087 100644 --- a/internal/cli/change_verify.go +++ b/internal/cli/change_verify.go @@ -10,6 +10,8 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" + "slices" "strconv" "strings" "time" @@ -33,19 +35,36 @@ type changeCriterion struct { } type changeVerifyReceipt struct { - SchemaVersion int `json:"schema_version"` - Change string `json:"change"` - VerifiedCommit string `json:"verified_commit"` - VerifiedAt string `json:"verified_at"` - CriteriaDigest string `json:"criteria_digest"` - Cwd string `json:"cwd"` - TargetRelease string `json:"target_release,omitempty"` - Results []changeVerifyCriterionResult `json:"results"` + SchemaVersion int `json:"schema_version"` + Change string `json:"change"` + // VerifiedCommit is provenance only — never consulted for the freshness verdict (ADR-024). + VerifiedCommit string `json:"verified_commit"` + VerifiedRootTree string `json:"verified_root_tree"` + VerifiedAt string `json:"verified_at"` + CriteriaDigest string `json:"criteria_digest"` + ScopeDigest string `json:"scope_digest"` + ScopeSections map[string]string `json:"scope_sections"` + Exclusions []string `json:"exclusions"` + DigestSpec string `json:"digest_spec"` + ToolVersion string `json:"tool_version"` + Toolchain changeVerifyToolchain `json:"toolchain"` + // WorktreeClean records execution integrity: the tracked tree was unchanged + // and HEAD was unmoved for the whole verify run. False voids the receipt + // (ADR-024 dirty-execution rejection); there is no separate schema field + // for HEAD movement. + WorktreeClean bool `json:"worktree_clean"` + TargetRelease string `json:"target_release,omitempty"` + Results []changeVerifyCriterionResult `json:"results"` } -// changeVerifyCriterionResult records one criterion's evidence. Expect fields are -// additive on schema_version 1: older readers ignore them, and no receipt exists -// outside fixtures. +// changeVerifyToolchain records the verify host environment for audit, never gating. +type changeVerifyToolchain struct { + Go string `json:"go"` + OS string `json:"os"` + Arch string `json:"arch"` +} + +// changeVerifyCriterionResult records one criterion's evidence. type changeVerifyCriterionResult struct { ID string `json:"id"` Command string `json:"command"` @@ -97,11 +116,28 @@ func (r Runner) runChangeVerify(args []string, out io.Writer, rootPath string) e if len(criteria) == 0 { return fmt.Errorf("no executable criteria found in shape.md (need V-entries with Command: `...`)") } + dirtyPaths, err := changeTrackedWorktreeDivergedPaths(rootPath) + if err != nil { + return fmt.Errorf("inspect worktree: %w", err) + } + if len(dirtyPaths) > 0 { + return fmt.Errorf("working tree differs from HEAD; commit before verifying") + } head, err := commandOutput(rootPath, "git", "rev-parse", "HEAD") if err != nil { return fmt.Errorf("resolve HEAD: %w", err) } head = strings.TrimSpace(head) + rootTree, err := commandOutput(rootPath, "git", "rev-parse", "HEAD^{tree}") + if err != nil { + return fmt.Errorf("resolve HEAD tree: %w", err) + } + rootTree = strings.TrimSpace(rootTree) + exclusions := ChangeEvidenceExclusions() + scope, err := scopeDigest(rootPath, head, exclusions, nil) + if err != nil { + return fmt.Errorf("compute scope digest: %w", err) + } results := make([]changeVerifyCriterionResult, 0, len(criteria)) failed := false for _, criterion := range criteria { @@ -135,18 +171,42 @@ func (r Runner) runChangeVerify(args []string, out io.Writer, rootPath string) e ansiYellow("warn"), criterion.ID, clause) } } + // Post-run dirty check: criteria may mutate tracked files. Receipt/report + // masks are exempt (same as pre-run); allowlist paths are not. + postDirty, err := changeTrackedWorktreeDivergedPaths(rootPath) + if err != nil { + return fmt.Errorf("inspect worktree after criteria: %w", err) + } + postHead, err := commandOutput(rootPath, "git", "rev-parse", "HEAD") + if err != nil { + return fmt.Errorf("resolve HEAD after criteria: %w", err) + } + postHead = strings.TrimSpace(postHead) + headMoved := postHead != head + worktreeClean := len(postDirty) == 0 && !headMoved receipt := changeVerifyReceipt{ - SchemaVersion: 1, - Change: node.Slug, - VerifiedCommit: head, - VerifiedAt: time.Now().UTC().Format(time.RFC3339), - CriteriaDigest: changeCriteriaDigest(criteria), - Cwd: rootPath, - TargetRelease: node.TargetRelease, - Results: results, - } - // Write-on-failure: persist evidence even when criteria fail; the cohort - // gate rejects receipts with any results[].ok == false (TASK-007). + SchemaVersion: 2, + Change: node.Slug, + VerifiedCommit: head, + VerifiedRootTree: rootTree, + VerifiedAt: time.Now().UTC().Format(time.RFC3339), + CriteriaDigest: changeCriteriaDigest(criteria), + ScopeDigest: scope.Digest, + ScopeSections: scope.Sections, + Exclusions: exclusions, + DigestSpec: ChangeEvidenceDigestSpec, + ToolVersion: packageVersion(rootPath), + Toolchain: changeVerifyToolchain{ + Go: strings.TrimPrefix(runtime.Version(), "go"), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }, + WorktreeClean: worktreeClean, + TargetRelease: node.TargetRelease, + Results: results, + } + // Write-on-failure: persist evidence even when criteria fail or the + // worktree diverged mid-run; the cohort gate rejects both. receiptPath := filepath.Join(folder, filepath.FromSlash(changeVerifyReceiptFile)) if err := os.MkdirAll(filepath.Dir(receiptPath), 0o755); err != nil { return fmt.Errorf("create receipts/: %w", err) @@ -161,7 +221,14 @@ func (r Runner) runChangeVerify(args []string, out io.Writer, rootPath string) e } fmt.Fprintf(out, "\nWrote receipt: %s\n", relFromRoot(rootPath, receiptPath)) fmt.Fprintf(out, "criteria_digest: %s\n", receipt.CriteriaDigest) - fmt.Fprintf(out, "verified_commit: %s\n", shortSHA(receipt.VerifiedCommit)) + fmt.Fprintf(out, "scope_digest: %s\n", receipt.ScopeDigest) + fmt.Fprintf(out, "verified_commit: %s (provenance)\n", shortSHA(receipt.VerifiedCommit)) + if !worktreeClean { + if headMoved { + return fmt.Errorf("HEAD moved during verification (%s → %s); receipt is void — re-verify", shortSHA(head), shortSHA(postHead)) + } + return fmt.Errorf("criteria mutated the tracked worktree (%s); receipt is void — restore or commit, then re-verify", strings.Join(postDirty, ", ")) + } if failed { return ExitError{Code: 1} } @@ -170,7 +237,7 @@ func (r Runner) runChangeVerify(args []string, out io.Writer, rootPath string) e func writeChangeVerifyHelp(out io.Writer) { writeUsageHelp(out, "loaf change verify [folder]", - "Run executable criteria declared in shape.md and write receipts/verify.json (criteria digest, verified commit, per-criterion evidence). New-layout-only.", + "Run executable criteria declared in shape.md and write receipts/verify.json (schema v2 content digest, criteria digest, per-criterion evidence). New-layout-only. Refuses a dirty tracked worktree.", "[folder] Change folder path; resolves from the current branch when omitted") } @@ -390,14 +457,14 @@ func changeExpectFailureNote(runErr error, exitCode int, checks []changeVerifyEx func changeCriteriaDigest(criteria []changeCriterion) string { var b strings.Builder for _, c := range criteria { - fmt.Fprintf(&b, "%s\n%s\n%s\n", c.ID, c.Command, c.Expect) + fmt.Fprintf(&b, "%s\n%s\n%s\n%s\n", c.ID, c.Text, c.Command, c.Expect) } return sha256HexBytes([]byte(b.String())) } -func runChangeCriterionCommand(folder, command string) (int, string, error) { - cmd := exec.Command("bash", "-lc", command) - cmd.Dir = folder +func runChangeCriterionCommand(rootPath, command string) (int, string, error) { + cmd := exec.Command("bash", "-c", command) + cmd.Dir = rootPath output, err := cmd.CombinedOutput() if err == nil { return 0, string(output), nil @@ -408,6 +475,92 @@ func runChangeCriterionCommand(folder, command string) (int, string, error) { return 1, string(output), err } +// changeTrackedWorktreeDivergedPaths lists tracked/staged paths that differ from +// HEAD and are not receipt/report-mask exempt. Paths are slash-normalized and sorted. +// Untracked files do not count — verify may write the receipt into an untracked +// receipts/ path. The release-metadata allowlist is NOT exempt: digest-excluded +// paths like dist/** stay dirty-checked because criteria may mutate them. +// +// Status uses --ignore-submodules=untracked so a consumer's submodule..ignore +// config cannot hide a dirty or HEAD-moved submodule; untracked-only content inside +// a submodule still does not refuse (mirrors -uno for the superproject). +func changeTrackedWorktreeDivergedPaths(rootPath string) ([]string, error) { + out, err := commandOutput(rootPath, "git", "status", "--porcelain=v1", "-z", "-uno", "--ignore-submodules=untracked") + if err != nil { + return nil, err + } + seen := map[string]bool{} + var paths []string + for _, path := range porcelainTrackedPathsZ(out) { + if path == "" || changeDirtyCheckExempt(path) || seen[path] { + continue + } + seen[path] = true + paths = append(paths, path) + } + slices.Sort(paths) + return paths, nil +} + +// changeDirtyCheckExempt is true only for receipt and report mask paths — never +// the release-metadata allowlist. +func changeDirtyCheckExempt(path string) bool { + for _, pattern := range ChangeEvidenceReceiptMasks { + if matchEvidenceGlob(path, pattern) { + return true + } + } + for _, pattern := range ChangeEvidenceReportMasks { + if matchEvidenceGlob(path, pattern) { + return true + } + } + return false +} + +// porcelainTrackedPathsZ extracts path(s) from git status --porcelain=v1 -z output. +// Records are NUL-terminated; paths are never quoted. Rename/copy records (X status +// R or C) carry a second NUL-terminated original-path field after the primary path. +func porcelainTrackedPathsZ(raw string) []string { + if raw == "" { + return nil + } + var paths []string + data := raw + for len(data) > 0 { + nul := strings.IndexByte(data, 0) + if nul < 0 { + break + } + record := data[:nul] + data = data[nul+1:] + if len(record) < 3 || record[2] != ' ' { + continue + } + xy := record[:2] + path := filepath.ToSlash(record[3:]) + if path != "" { + paths = append(paths, path) + } + if xy[0] != 'R' && xy[0] != 'C' { + continue + } + if len(data) == 0 { + break + } + nul2 := strings.IndexByte(data, 0) + if nul2 < 0 { + break + } + orig := filepath.ToSlash(data[:nul2]) + data = data[nul2+1:] + if orig != "" { + paths = append(paths, orig) + } + } + return paths +} + func sha256HexBytes(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) @@ -415,8 +568,8 @@ func sha256HexBytes(data []byte) string { // loadChangeVerifyReceipt reads the receipt from the working tree. This is // verify's own surface — it writes that file — and never the gate's: gate-context -// reads go through changeReceiptAtHEAD so evidence is always committed before it -// is read (ADR-023). +// reads go through changeReceiptStatus, which reads the committed receipt at HEAD +// via readCommittedOptional (ADR-023 / ADR-024). func loadChangeVerifyReceipt(folderAbs string) (changeVerifyReceipt, error) { data, err := os.ReadFile(filepath.Join(folderAbs, filepath.FromSlash(changeVerifyReceiptFile))) if err != nil { @@ -433,98 +586,6 @@ func changeReceiptRelPath(folderRel string) string { return filepath.ToSlash(filepath.Join(folderRel, changeVerifyReceiptFile)) } -// changeReceiptAtHEAD loads the receipt as committed at HEAD. found=false means -// the HEAD tree carries no receipt; the working tree is never consulted for -// content, so a receipt that exists on one machine only cannot satisfy the gate. -func changeReceiptAtHEAD(rootPath, folderRel string, outputCommand changeGitOutput) (changeVerifyReceipt, bool, error) { - if outputCommand == nil { - outputCommand = commandOutput - } - receiptRel := changeReceiptRelPath(folderRel) - content, found, err := readCommittedOptional(rootPath, "HEAD", receiptRel, outputCommand) - if err != nil { - return changeVerifyReceipt{}, false, err - } - if !found { - return changeVerifyReceipt{}, false, nil - } - var receipt changeVerifyReceipt - if err := json.Unmarshal([]byte(content), &receipt); err != nil { - return changeVerifyReceipt{}, false, fmt.Errorf("parse committed receipt %s: %w", receiptRel, err) - } - return receipt, true, nil -} - -func changeReceiptExistsInWorkingTree(rootPath, folderRel string) bool { - folderAbs := filepath.Join(rootPath, filepath.FromSlash(folderRel)) - _, err := os.Stat(filepath.Join(folderAbs, filepath.FromSlash(changeVerifyReceiptFile))) - return err == nil -} - -// changeReceiptStatus reports whether a receipt attests successful verification -// that still covers HEAD. The receipt is read from committed HEAD, never from the -// working tree: an uncommitted receipt is evidence on one machine only and blocks -// with its own reason. Failing criteria block even when the receipt is fresh; the -// receipt's own commit never stales it; any later commit that touches a -// non-receipt path stales the receipt with a re-verify demand (Decision 13). -// Preflight never executes criteria — only loaf change verify does. -func changeReceiptStatus(rootPath, folderRel string, node changeNode, outputCommand changeGitOutput) (ok bool, reason string, err error) { - if outputCommand == nil { - outputCommand = commandOutput - } - receipt, found, err := changeReceiptAtHEAD(rootPath, folderRel, outputCommand) - if err != nil { - return false, "", err - } - if !found { - if changeReceiptExistsInWorkingTree(rootPath, folderRel) { - return false, "receipt not committed at HEAD", nil - } - return false, "missing receipt", nil - } - criteria := parseChangeExecutableCriteria(node.Content) - digest := changeCriteriaDigest(criteria) - if digest != receipt.CriteriaDigest { - return false, "criteria digest mismatch (receipt expired)", nil - } - if failed := receiptFailingCriterionIDs(receipt); len(failed) > 0 { - return false, fmt.Sprintf("receipt records failing criteria (%s)", strings.Join(failed, ", ")), nil - } - head, err := outputCommand(rootPath, "git", "rev-parse", "HEAD") - if err != nil { - return false, "", err - } - head = strings.TrimSpace(head) - if head == receipt.VerifiedCommit { - return true, "", nil - } - // Commit-by-commit: a touch-then-revert pair still stales, unlike a - // verified..HEAD tree diff that would cancel out. - logOut, err := outputCommand(rootPath, "git", "log", "--format=%H", receipt.VerifiedCommit+"..HEAD") - if err != nil { - return false, "", err - } - receiptRel := changeReceiptRelPath(folderRel) - for _, commit := range strings.Split(strings.TrimSpace(logOut), "\n") { - commit = strings.TrimSpace(commit) - if commit == "" { - continue - } - pathsOut, err := outputCommand(rootPath, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit) - if err != nil { - return false, "", err - } - for _, p := range strings.Split(pathsOut, "\n") { - p = filepath.ToSlash(strings.TrimSpace(p)) - if p == "" || p == receiptRel { - continue - } - return false, "later non-receipt path requires criteria re-run", nil - } - } - return true, "", nil -} - func receiptFailingCriterionIDs(receipt changeVerifyReceipt) []string { var failed []string for _, result := range receipt.Results { diff --git a/internal/cli/change_verify_test.go b/internal/cli/change_verify_test.go index d2f0ea7ce..b8e54f421 100644 --- a/internal/cli/change_verify_test.go +++ b/internal/cli/change_verify_test.go @@ -26,8 +26,11 @@ func TestChangeVerifyWritesReceipt(t *testing.T) { if !strings.Contains(string(data), `"criteria_digest"`) || !strings.Contains(string(data), `"verified_commit"`) { t.Fatalf("receipt = %s", data) } - if !strings.Contains(string(data), `"cwd": "`+filepath.ToSlash(repo)) && !strings.Contains(string(data), `"cwd": "`+repo) { - t.Fatalf("receipt missing repo-root cwd: %s", data) + if !strings.Contains(string(data), `"schema_version": 2`) { + t.Fatalf("receipt missing schema_version 2: %s", data) + } + if strings.Contains(string(data), `"cwd"`) { + t.Fatalf("receipt must not record cwd: %s", data) } if !strings.Contains(stdout.String(), "Wrote receipt:") { t.Fatalf("stdout = %q", stdout.String()) @@ -115,7 +118,7 @@ func TestChangeVerifyIgnoresHTier(t *testing.T) { } } -func TestChangeVerifyRunsFromRepoRootAndRecordsCwd(t *testing.T) { +func TestChangeVerifyRunsFromRepoRoot(t *testing.T) { repo := initCLIGitRepo(t) marker := filepath.Join(repo, "root-marker.txt") if err := os.WriteFile(marker, []byte("ok\n"), 0o644); err != nil { @@ -136,12 +139,12 @@ func TestChangeVerifyRunsFromRepoRootAndRecordsCwd(t *testing.T) { if err := json.Unmarshal(data, &receipt); err != nil { t.Fatalf("unmarshal: %v", err) } - if receipt.Cwd != repo { - t.Fatalf("cwd = %q, want repo root %q", receipt.Cwd, repo) - } if len(receipt.Results) != 1 || !receipt.Results[0].OK { t.Fatalf("results = %#v, want V1 ok at repo root", receipt.Results) } + if receipt.SchemaVersion != 2 || receipt.ScopeDigest == "" || receipt.WorktreeClean != true { + t.Fatalf("receipt = %#v, want schema v2 with scope digest and clean worktree", receipt) + } } func TestChangeVerifyParsesFreshScaffoldCriterion(t *testing.T) { @@ -189,9 +192,9 @@ func TestChangeVerifyV5ReceiptDigestExpiryViaChangeReceiptStatus(t *testing.T) { if err != nil { t.Fatalf("assemble: %v", err) } - ok, reason, statusErr := changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || !ok { - t.Fatalf("fresh receipt: ok=%v reason=%q err=%v", ok, reason, statusErr) + verdict := changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("fresh receipt: ok=%v reason=%q", verdict.OK, verdict.Cause()) } expired := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0 changed") @@ -204,12 +207,9 @@ func TestChangeVerifyV5ReceiptDigestExpiryViaChangeReceiptStatus(t *testing.T) { if err != nil { t.Fatalf("assemble after edit: %v", err) } - ok, reason, statusErr = changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil { - t.Fatalf("status err: %v", statusErr) - } - if ok || reason != "criteria digest mismatch (receipt expired)" { - t.Fatalf("ok=%v reason=%q, want expired digest", ok, reason) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if verdict.OK || verdict.Cause() != "criteria changed (receipt expired)" { + t.Fatalf("ok=%v reason=%q, want expired criteria", verdict.OK, verdict.Cause()) } } @@ -504,6 +504,7 @@ func TestChangeVerifyEnforcesExpectAndRecordsAtoms(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "shape.md"), []byte(missBody), 0o644); err != nil { t.Fatalf("WriteFile shape: %v", err) } + commitAllChangeTest(t, repo, "docs: tighten contains expectation") stdout.Reset() if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err == nil { t.Fatalf("exit-zero command with unmet contains must fail\n%s", stdout.String()) @@ -582,9 +583,9 @@ func TestChangeReceiptStatusReadsCommittedHEADNotWorkingTree(t *testing.T) { } // Never verified: no receipt anywhere. - ok, reason, statusErr := changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || ok || reason != "missing receipt" { - t.Fatalf("never verified: ok=%v reason=%q err=%v, want missing receipt", ok, reason, statusErr) + verdict := changeReceiptStatus(repo, folderRel, node, nil) + if verdict.OK || verdict.Cause() != "missing receipt" { + t.Fatalf("never verified: ok=%v reason=%q, want missing receipt", verdict.OK, verdict.Cause()) } // Verified at HEAD but nobody committed the receipt: blocks distinctly. @@ -596,19 +597,19 @@ func TestChangeReceiptStatusReadsCommittedHEADNotWorkingTree(t *testing.T) { if err != nil { t.Fatalf("ReadFile receipt: %v", err) } - ok, reason, statusErr = changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || ok || reason != "receipt not committed at HEAD" { - t.Fatalf("uncommitted receipt: ok=%v reason=%q err=%v, want not-committed block", ok, reason, statusErr) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if verdict.OK || verdict.Cause() != "receipt not committed at HEAD" { + t.Fatalf("uncommitted receipt: ok=%v reason=%q, want not-committed block", verdict.OK, verdict.Cause()) } - if msg := formatChangeReceiptBlock("head-receipt", "1.0.0", reason, filepath.ToSlash(folderRel)); !strings.Contains(msg, "commit the receipt") { + if msg := formatChangeReceiptBlock("head-receipt", "1.0.0", verdict, filepath.ToSlash(folderRel)); !strings.Contains(msg, "commit the receipt") { t.Fatalf("block message = %q, want commit-the-receipt remedy", msg) } // The same receipt, committed, proceeds. commitAllChangeTest(t, repo, "chore: commit verify receipt") - ok, reason, statusErr = changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || !ok { - t.Fatalf("committed receipt: ok=%v reason=%q err=%v, want pass", ok, reason, statusErr) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("committed receipt: ok=%v reason=%q, want pass", verdict.OK, verdict.Cause()) } // Dirty working tree is irrelevant: a locally failing receipt cannot close the gate. @@ -626,18 +627,18 @@ func TestChangeReceiptStatusReadsCommittedHEADNotWorkingTree(t *testing.T) { if err := os.WriteFile(receiptPath, append(mangledData, '\n'), 0o644); err != nil { t.Fatalf("WriteFile mangled receipt: %v", err) } - ok, reason, statusErr = changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || !ok { - t.Fatalf("working-tree edit must not affect the gate: ok=%v reason=%q err=%v", ok, reason, statusErr) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("working-tree edit must not affect the gate: ok=%v reason=%q", verdict.OK, verdict.Cause()) } // Nor can deleting it locally. if err := os.Remove(receiptPath); err != nil { t.Fatalf("Remove receipt: %v", err) } - ok, reason, statusErr = changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || !ok { - t.Fatalf("working-tree delete must not affect the gate: ok=%v reason=%q err=%v", ok, reason, statusErr) + verdict = changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("working-tree delete must not affect the gate: ok=%v reason=%q", verdict.OK, verdict.Cause()) } } @@ -656,8 +657,313 @@ func TestChangeVerifyV5ReceiptOwnCommitExemption(t *testing.T) { if err != nil { t.Fatalf("assemble: %v", err) } - ok, reason, statusErr := changeReceiptStatus(repo, folderRel, node, nil) - if statusErr != nil || !ok { - t.Fatalf("receipt-only commit must not stale: ok=%v reason=%q err=%v", ok, reason, statusErr) + verdict := changeReceiptStatus(repo, folderRel, node, nil) + if !verdict.OK { + t.Fatalf("receipt-only commit must not stale: ok=%v reason=%q", verdict.OK, verdict.Cause()) + } +} + +func TestChangeVerifySchemaV2(t *testing.T) { + t.Run("writes-v2-fields-without-absolute-paths", func(t *testing.T) { + repo := initCLIGitRepo(t) + body := shapeWithVerification("- **V1.** Smoke prose. Command: `true`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260728-schema-v2", "schema-v2", "2.0.0", body) + commitAllChangeTest(t, repo, "docs: shape schema-v2") + folderRel := filepath.Join("docs", "changes", "20260728-schema-v2") + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify: %v\n%s", err, stdout.String()) + } + receipt := mustReadVerifyReceipt(t, dir) + if receipt.SchemaVersion != 2 { + t.Fatalf("schema = %d, want 2", receipt.SchemaVersion) + } + if receipt.ScopeDigest == "" || receipt.DigestSpec != ChangeEvidenceDigestSpec || len(receipt.Exclusions) == 0 { + t.Fatalf("digest fields incomplete: %#v", receipt) + } + if receipt.VerifiedRootTree == "" || receipt.VerifiedCommit == "" { + t.Fatalf("provenance missing: %#v", receipt) + } + if !receipt.WorktreeClean { + t.Fatal("worktree_clean must be true after clean verify") + } + if receipt.Toolchain.Go == "" || receipt.Toolchain.OS == "" || receipt.Toolchain.Arch == "" { + t.Fatalf("toolchain incomplete: %#v", receipt.Toolchain) + } + if len(receipt.ScopeSections) == 0 { + t.Fatal("scope_sections required") + } + raw := mustRead(t, filepath.Join(dir, "receipts", "verify.json")) + if bytes.Contains(raw, []byte(repo)) || bytes.Contains(raw, []byte(filepath.ToSlash(repo))) { + t.Fatalf("receipt must not embed absolute paths: %s", raw) + } + if bytes.Contains(raw, []byte(`"cwd"`)) { + t.Fatalf("cwd must be dropped: %s", raw) + } + wantExclusions := ChangeEvidenceExclusions() + if len(receipt.Exclusions) != len(wantExclusions) { + t.Fatalf("exclusions = %#v, want %#v", receipt.Exclusions, wantExclusions) + } + for i := range wantExclusions { + if receipt.Exclusions[i] != wantExclusions[i] { + t.Fatalf("exclusions[%d] = %q, want %q", i, receipt.Exclusions[i], wantExclusions[i]) + } + } + }) + + t.Run("refuses-dirty-tracked-and-staged-but-not-untracked", func(t *testing.T) { + repo := initCLIGitRepo(t) + body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260728-dirty", "dirty", "", body) + commitAllChangeTest(t, repo, "docs: shape dirty") + folderRel := filepath.Join("docs", "changes", "20260728-dirty") + shapePath := filepath.Join(dir, "shape.md") + original := mustRead(t, shapePath) + + // Tracked unstaged edit. + if err := os.WriteFile(shapePath, append(original, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) + if err == nil || !strings.Contains(err.Error(), "working tree differs from HEAD; commit before verifying") { + t.Fatalf("tracked dirty err = %v, want dirty refusal", err) + } + + // Restore working tree, then stage an edit. + if err := os.WriteFile(shapePath, original, 0o644); err != nil { + t.Fatalf("restore: %v", err) + } + if err := os.WriteFile(shapePath, append(original, []byte("\n")...), 0o644); err != nil { + t.Fatalf("WriteFile staged: %v", err) + } + gitCLI(t, repo, "add", filepath.ToSlash(filepath.Join("docs", "changes", "20260728-dirty", "shape.md"))) + err = (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) + if err == nil || !strings.Contains(err.Error(), "working tree differs from HEAD; commit before verifying") { + t.Fatalf("staged dirty err = %v, want dirty refusal", err) + } + + // Clean tracked/staged state; untracked files must not refuse. + gitCLI(t, repo, "reset", "--hard", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "untracked-only.txt"), []byte("x\n"), 0o644); err != nil { + t.Fatalf("WriteFile untracked: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("untracked must not refuse verify: %v", err) + } + }) + + t.Run("criteria-text-edit-changes-digest", func(t *testing.T) { + before := changeCriteriaDigest([]changeCriterion{{ + ID: "V1", Text: "Smoke prose.", Command: "true", Expect: "exit 0", + }}) + after := changeCriteriaDigest([]changeCriterion{{ + ID: "V1", Text: "Smoke prose changed.", Command: "true", Expect: "exit 0", + }}) + if before == after { + t.Fatal("criterion text must participate in criteria_digest") + } + sameCommand := changeCriteriaDigest([]changeCriterion{{ + ID: "V1", Text: "Smoke prose.", Command: "true", Expect: "exit 0", + }}) + if before != sameCommand { + t.Fatal("identical criteria must digest identically") + } + }) +} + +func TestChangeVerifyCriteriaMutationVoidsReceipt(t *testing.T) { + t.Run("tracked-dist-mutation-voids-and-blocks-gate", func(t *testing.T) { + repo := initCLIGitRepo(t) + writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") + distPath := filepath.Join(repo, "dist", "artifact.js") + if err := os.MkdirAll(filepath.Dir(distPath), 0o755); err != nil { + t.Fatalf("mkdir dist: %v", err) + } + if err := os.WriteFile(distPath, []byte("original\n"), 0o644); err != nil { + t.Fatalf("WriteFile dist: %v", err) + } + commitAllChangeTest(t, repo, "chore: track dist artifact") + + body := shapeWithVerification("- **V1.** Mutate digest-excluded tracked path. Command: `printf mutated > dist/artifact.js`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260727-mutate-dist", "mutate-dist", "1.0.0", body) + flipExecuteChange(t, repo, dir, "mutate-dist") + folderRel := filepath.Join("docs", "changes", "20260727-mutate-dist") + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) + if err == nil || !strings.Contains(err.Error(), "criteria mutated the tracked worktree") { + t.Fatalf("err = %v, want mid-verify mutation void\n%s", err, stdout.String()) + } + if !strings.Contains(err.Error(), "dist/artifact.js") { + t.Fatalf("err = %v, want diverged path named", err) + } + receipt := mustReadVerifyReceipt(t, dir) + if receipt.WorktreeClean { + t.Fatal("receipt must record worktree_clean false") + } + + // Restore dist so only the void receipt is committed; the gate reads receipt fields. + if err := os.WriteFile(distPath, []byte("original\n"), 0o644); err != nil { + t.Fatalf("restore dist: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit void receipt") + + node, err := assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + if err != nil { + t.Fatalf("assemble: %v", err) + } + verdict := changeReceiptStatus(repo, folderRel, node, nil) + if verdict.OK || verdict.Reason != changeReceiptDirtyExecution { + t.Fatalf("verdict=%#v, want dirty-execution block", verdict) + } + if verdict.Cause() != "verify ran against a worktree that diverged from HEAD (receipt void)" { + t.Fatalf("cause = %q", verdict.Cause()) + } + msg := formatChangeReceiptBlock("mutate-dist", "1.0.0", verdict, folderRel) + if !strings.Contains(msg, "loaf change verify") || strings.Contains(strings.ToLower(msg), "invalid") || strings.Contains(strings.ToLower(msg), "corrupt") { + t.Fatalf("block = %q", msg) + } + gateErr := releaseCohortPreflight(repo, "1.0.0", nil) + if gateErr == nil || !strings.Contains(gateErr.Error(), "diverged from HEAD (receipt void)") { + t.Fatalf("gate err = %v, want dirty-execution block", gateErr) + } + }) + + t.Run("untracked-scratch-does-not-void", func(t *testing.T) { + repo := initCLIGitRepo(t) + body := shapeWithVerification("- **V1.** Write untracked scratch. Command: `printf scratch > untracked-scratch.txt`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260727-scratch", "scratch", "", body) + commitAllChangeTest(t, repo, "docs: shape scratch") + folderRel := filepath.Join("docs", "changes", "20260727-scratch") + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("untracked scratch must not void verify: %v\n%s", err, stdout.String()) + } + receipt := mustReadVerifyReceipt(t, dir) + if !receipt.WorktreeClean { + t.Fatal("untracked scratch must leave worktree_clean true") + } + }) + + t.Run("committed-receipt-edit-does-not-refuse-pre-run", func(t *testing.T) { + repo := initCLIGitRepo(t) + body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260727-receipt-exempt", "receipt-exempt", "", body) + commitAllChangeTest(t, repo, "docs: shape receipt-exempt") + folderRel := filepath.Join("docs", "changes", "20260727-receipt-exempt") + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("verify: %v", err) + } + commitAllChangeTest(t, repo, "chore: commit receipt") + // Dirty only the receipt (mask-exempt) — pre-run must allow verify. + receiptPath := filepath.Join(dir, "receipts", "verify.json") + raw := mustRead(t, receiptPath) + if err := os.WriteFile(receiptPath, append(raw, []byte("\n")...), 0o644); err != nil { + t.Fatalf("dirty receipt: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("mask-exempt receipt dirt must not refuse: %v", err) + } + }) +} + +func TestChangeVerifySubmoduleIgnoreConfigCannotHideDirt(t *testing.T) { + sub := t.TempDir() + gitCLI(t, sub, "init", "-b", "main") + if err := os.WriteFile(filepath.Join(sub, "tracked.txt"), []byte("clean\n"), 0o644); err != nil { + t.Fatalf("WriteFile submodule tracked: %v", err) + } + gitCLI(t, sub, "add", "tracked.txt") + gitCLI(t, sub, "-c", "user.name=Loaf Test", "-c", "user.email=loaf@example.test", "-c", "commit.gpgsign=false", "commit", "-m", "sub initial") + + repo := initCLIGitRepo(t) + gitCLI(t, repo, "-c", "protocol.file.allow=always", "submodule", "add", sub, "vendor/mod") + commitAllChangeTest(t, repo, "chore: add submodule") + gitCLI(t, repo, "config", "submodule.vendor/mod.ignore", "all") + + body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") + writeNewLayoutChange(t, repo, "20260730-submodule-ignore", "submodule-ignore", "", body) + commitAllChangeTest(t, repo, "docs: shape submodule-ignore") + folderRel := filepath.Join("docs", "changes", "20260730-submodule-ignore") + + if err := os.WriteFile(filepath.Join(repo, "vendor", "mod", "tracked.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatalf("dirty submodule: %v", err) + } + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) + if err == nil || !strings.Contains(err.Error(), "working tree differs from HEAD; commit before verifying") { + t.Fatalf("dirty submodule under ignore=all err = %v, want pre-run refusal", err) + } + + if err := os.WriteFile(filepath.Join(repo, "vendor", "mod", "tracked.txt"), []byte("clean\n"), 0o644); err != nil { + t.Fatalf("restore submodule: %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("clean submodule must allow verify: %v", err) + } +} + +func TestChangeVerifyHEADMoveDuringCriteriaVoidsReceipt(t *testing.T) { + repo := initCLIGitRepo(t) + writeReleaseVersionFiles(t, repo, "1.0.0-alpha.1") + cmd := `printf 'changed\n' > README.md && git add README.md && git -c user.name='Loaf Test' -c user.email=loaf@example.test -c commit.gpgsign=false commit -m mid-verify` + body := shapeWithVerification("- **V1.** Commit mid-verify. Command: `" + cmd + "`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260727-head-move", "head-move", "1.0.0", body) + flipExecuteChange(t, repo, dir, "head-move") + folderRel := filepath.Join("docs", "changes", "20260727-head-move") + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}) + if err == nil || !strings.Contains(err.Error(), "HEAD moved during verification") { + t.Fatalf("err = %v, want HEAD-moved void\n%s", err, stdout.String()) + } + if !strings.Contains(err.Error(), "receipt is void — re-verify") { + t.Fatalf("err = %v, want re-verify remedy", err) + } + receipt := mustReadVerifyReceipt(t, dir) + if receipt.WorktreeClean { + t.Fatal("receipt must record worktree_clean false after HEAD move") + } + + commitAllChangeTest(t, repo, "chore: commit void receipt") + node, err := assembleChangeNodeFromFolder(repo, filepath.Join(repo, folderRel)) + if err != nil { + t.Fatalf("assemble: %v", err) + } + verdict := changeReceiptStatus(repo, folderRel, node, nil) + if verdict.OK || verdict.Reason != changeReceiptDirtyExecution { + t.Fatalf("verdict=%#v, want dirty-execution block", verdict) + } + gateErr := releaseCohortPreflight(repo, "1.0.0", nil) + if gateErr == nil || !strings.Contains(gateErr.Error(), "diverged from HEAD (receipt void)") { + t.Fatalf("gate err = %v, want dirty-execution block", gateErr) + } +} + +func TestChangeVerifyPathologicalArrowReportFilenameStaysMaskExempt(t *testing.T) { + repo := initCLIGitRepo(t) + body := shapeWithVerification("- **V1.** Smoke. Command: `true`. Expect: exit 0") + dir := writeNewLayoutChange(t, repo, "20260730-arrow-report", "arrow-report", "", body) + reportDir := filepath.Join(dir, "reports") + if err := os.MkdirAll(reportDir, 0o755); err != nil { + t.Fatalf("mkdir reports: %v", err) + } + reportName := "board -> review.md" + reportPath := filepath.Join(reportDir, reportName) + if err := os.WriteFile(reportPath, []byte("board v1\n"), 0o644); err != nil { + t.Fatalf("WriteFile report: %v", err) + } + commitAllChangeTest(t, repo, "docs: shape arrow-report with pathological report name") + folderRel := filepath.Join("docs", "changes", "20260730-arrow-report") + + if err := os.WriteFile(reportPath, []byte("board v2 dirty\n"), 0o644); err != nil { + t.Fatalf("dirty report: %v", err) + } + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: repo}).Run([]string{"change", "verify", folderRel}); err != nil { + t.Fatalf("mask-exempt report with ' -> ' in name must not refuse: %v\n%s", err, stdout.String()) + } + receipt := mustReadVerifyReceipt(t, dir) + if !receipt.WorktreeClean { + t.Fatal("report-only dirt must leave worktree_clean true") } } diff --git a/internal/cli/check.go b/internal/cli/check.go index 7cd64c71a..b230f9a1c 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -1,6 +1,7 @@ package cli import ( + "bytes" "context" "encoding/json" "fmt" @@ -1391,8 +1392,21 @@ func changelogVersionSectionHasEntries(changelog string, version string) bool { func commandOutput(cwd string, name string, args ...string) (string, error) { cmd := exec.Command(name, args...) cmd.Dir = cwd - output, err := cmd.Output() - return string(output), err + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + out := stdout.String() + if err == nil { + return out, nil + } + stderrText := strings.TrimSpace(stderr.String()) + if stderrText == "" { + return out, err + } + // Preserve the original error (e.g. *exec.ExitError) while surfacing stderr + // so callers never see a bare "exit status N" without cause. + return out, fmt.Errorf("%w\n%s", err, stderrText) } func shouldRunSecurityScanners(context checkHookContext) bool { diff --git a/internal/cli/command_output_test.go b/internal/cli/command_output_test.go new file mode 100644 index 000000000..10d1d204e --- /dev/null +++ b/internal/cli/command_output_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "errors" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestCommandOutputCapturesStderr(t *testing.T) { + dir := t.TempDir() + _, err := commandOutput(dir, "git", "rev-parse", "not-a-ref") + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "exit status") { + t.Fatalf("want wrapped exit status, got %q", msg) + } + // git writes the cause to stderr — must appear in the returned error. + if !strings.Contains(strings.ToLower(msg), "unknown revision") && !strings.Contains(strings.ToLower(msg), "bad revision") && !strings.Contains(msg, "fatal:") { + t.Fatalf("stderr cause missing from error: %q", msg) + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("want errors.As ExitError, got %T %v", err, err) + } +} + +func TestChangeReceiptBlockMessagesNameFolderCauseRemedy(t *testing.T) { + folder := filepath.Join("docs", "changes", "20260727-demo") + cases := []struct { + name string + verdict changeReceiptVerdict + want []string + forbid []string + }{ + { + name: "drift", + verdict: changeReceiptVerdict{Reason: changeReceiptContentDrift, DriftedSections: []string{"internal", "content"}}, + want: []string{`change "demo"`, "1.0.0", "content changed under `internal`, `content`", "Run: loaf change verify", folder, "commit the receipt"}, + forbid: []string{"exit status", "cannot inspect", "invalid", "corrupt", "later non-receipt"}, + }, + { + name: "criteria", + verdict: changeReceiptVerdict{Reason: changeReceiptCriteriaMismatch}, + want: []string{"criteria changed (receipt expired)", "Run: loaf change verify", folder}, + forbid: []string{"exit status", "invalid", "corrupt"}, + }, + { + name: "schema", + verdict: changeReceiptVerdict{Reason: changeReceiptUnsupportedSchema, SchemaVersion: 1}, + want: []string{"unsupported receipt schema_version 1", "Run: loaf change verify", folder}, + forbid: []string{"invalid", "corrupt", "exit status"}, + }, + { + name: "failing", + verdict: changeReceiptVerdict{Reason: changeReceiptFailingResults, FailedIDs: []string{"V1", "V3"}}, + want: []string{"receipt records failing criteria (V1, V3)", "Fix the failing criteria, then run: loaf change verify", folder, "and commit the receipt"}, + forbid: []string{"exit status", "cannot inspect", "invalid", "corrupt"}, + }, + { + name: "boundary", + verdict: changeReceiptVerdict{Reason: changeReceiptBoundaryChanged}, + want: []string{"evidence boundary changed since verification (receipt expired)", "Run: loaf change verify", folder}, + forbid: []string{"exit status", "invalid", "corrupt", "cannot inspect"}, + }, + { + name: "evidence-unavailable", + verdict: changeReceiptVerdict{Reason: changeReceiptEvidenceUnavailable}, + want: []string{"could not read evidence at HEAD (git error)", "Verification cannot proceed until git reads succeed", "git fsck", "re-clone"}, + forbid: []string{"exit status", "cannot inspect", "Run: loaf change verify"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := formatChangeReceiptBlock("demo", "1.0.0", tc.verdict, folder) + for _, w := range tc.want { + if !strings.Contains(msg, w) { + t.Fatalf("msg=%q missing %q", msg, w) + } + } + lower := strings.ToLower(msg) + for _, f := range tc.forbid { + if strings.Contains(lower, strings.ToLower(f)) { + t.Fatalf("msg=%q must not contain %q", msg, f) + } + } + }) + } +}