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
+
+
+
Cohorts of two or more are unsatisfiable today. The freshness walk exempts only the change's own receipt path, so member B's committed receipt stales member A's — proven in all commit orderings, including both receipts in one atomic commit. The cohort gate has never been satisfiable for the multi-change cohort it exists to gate. (Git Internals + Correctness, independently)
+
The machine-split is verdict-versus-crash, not pass-versus-fail. After squash + branch delete, the author's machine (holding orphaned objects) blocks with a clean reason; a protocol clone exits 128 into release blocked: cannot inspect receipt … exit status 128 — stderr is discarded by commandOutput, so the consumer sees no path, no cause, no remedy. (DX reproduced end-to-end; Correctness found a third behavior when the branch survives)
+
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 can yield a green committed receipt for code no commit holds. Under content binding this must be closed: verify refuses a dirty tree. (Audit + Correctness + Git, independently)
+
criteria_digest drops the criterion's prose. A V-entry's claim text can be rewritten without expiring anything; only ID/Command/Expect are digested. (Audit)
+
The gate never runs in CI, and CI tags before testing.releaseCohortPreflight has one caller: the author's local loaf release. release.yml triggers on the pushed tag and runs go test ./... after the tag exists. (Release Engineering)
+
+
+
+
Unanimous riders adopted under either option
+
+
+
Digests are computed from git tree OIDs (ls-tree -z, byte-sorted, mode included, spec-versioned, domain-separated hash) — never worktree bytes, never filesystem walks. Immune to core.autocrlf, case-folding, core.quotePath.
+
Commit reachability leaves the verdict entirely.verified_commit demotes to provenance; no fallback to the walk when the object happens to exist — a fallback is the machine-split formalized. The exit status 128 path becomes unreachable code.
+
Every checker state maps to a reasoned verdict (block with named remedy), never an inspection error. Errors are reserved for genuine indeterminacy.
+
Schema v2 clean break, now. Zero receipts exist anywhere; v1 refused with a re-verify message; no dual-read path. The window closes when the first cohort receipt commits.
+
Verify refuses a dirty worktree. Plus: criteria_digest covers criterion text; record tool_version and toolchain; drop the absolute cwd (privacy leak, cross-machine diff noise); record the exclusion set and digest-spec version so changing the construction self-invalidates.
+
No configurability knob for expiry scope — a per-project knob makes "is this evidence valid" a repo-local policy answer.
+
Drift diagnostics must be machine-independent — never "N commits old" (needs reachability). Name what moved instead (paths / per-directory sub-digests).
+
Mask receipts/ repo-wide and reports/ from whatever digest gates — fixes the cohort deadlock, and committing a review board or this council board must never expire evidence.
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.
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
+
+
+
Property
Folder digest + CI assertion
Masked root-tree digest
+
Same verdict on every machine, any merge strategy
yes
yes
+
Survives squash / rebase / merge-commit unchanged content
yes
yes
+
Code change outside the folder expires the receipt
no — CI assertion compensates at cut
yes
+
Revert of the verified work expires the receipt
no (Decision 13 tension)
yes (revert that restores verified bytes un-stales — deliberate)
+
Cohort of N simultaneously verifiable
yes
yes (receipts masked repo-wide)
+
rc cut / promotion expire cohort receipts
never
never — allowlist unification, by construction
+
Re-verification cost
once per change, in its PR
sweep at rc cut + after rc bugfixes (anticipated by the rc design)
+
Receipt's green claim strength
narrow — "contract + folder", stated honestly
strong — "tracked content criteria read is unchanged"
dropped 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.
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 grepcannot inspect receipt: no matches in internal/cli
+
Full suitego 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 changeReceiptStatus → readCommittedOptional 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.
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).
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 suitego test ./... green with the three new lifecycle fixtures
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).
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 suitego 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.
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