What happened
verify_receipt() and verify_evidence() in src/vouch/receipts.py both
guard on quote is None, not on an empty string:
# verify_receipt
start, end, quote = evidence.byte_start, evidence.byte_end, evidence.quote
if start is None or end is None or quote is None:
return ReceiptResult(ReceiptStatus.NO_RECEIPT, "no byte-offset span")
if start > end or end > len(source_bytes):
return ReceiptResult(ReceiptStatus.FORGED, ...)
try:
span = source_bytes[start:end].decode("utf-8")
except UnicodeDecodeError:
return ReceiptResult(ReceiptStatus.FORGED, "span does not decode as utf-8")
if span != quote:
return ReceiptResult(ReceiptStatus.FORGED, "span does not match quote")
return ReceiptResult(ReceiptStatus.VERIFIED)
For quote="" and any zero-length span (byte_start == byte_end, e.g.
0, 0), source_bytes[0:0] decodes to "", which trivially string-equals
the empty quote → VERIFIED. This holds regardless of source content or
length, even against an empty source.
The module's own docstring states the entire premise this breaks: "This is
what lets the review gate become arithmetic instead of a person: a
citation whose receipt verifies can be auto-approved... The quoted span is
in the source at those offsets or it is not." An empty receipt proves
nothing, yet is scored identically to a real one.
verify_evidence() carries the identical gap (evidence.quote is None)
ahead of its own source-read, so a missing source plus an empty quote is
misreported as FORGED ("source not in kb") rather than the more
fundamentally correct NO_RECEIPT ("nothing to compare in the first
place").
Evidence.quote has no min-length constraint at the model layer
(quote: str | None = None in models.py, no Field length bound), so
nothing upstream of verify_receipt closes this off.
What you expected
An empty quote should be treated the same as no receipt at all
(NO_RECEIPT), exactly as locate_span() already does on the mint
side (if not needle: return None) — the verify side never got the
matching guard.
Reproduction
Unit level:
from vouch.models import Evidence
from vouch.receipts import verify_receipt
source = b"the quick brown fox jumps over the lazy dog"
ev = Evidence(id="e1", source_id="s1", locator="b0-0", quote="", byte_start=0, byte_end=0)
print(verify_receipt(ev, source).status) # 'verified' — should be 'no_receipt'
Full end-to-end, no human in the loop (the capture.py-documented
starter-config default review.auto_approve_on_receipt: true):
import tempfile, pathlib
from vouch.storage import KBStore
from vouch.models import Evidence
from vouch import proposals
d = pathlib.Path(tempfile.mkdtemp())
store = KBStore.init(d)
store.config_path.write_text(
store.config_path.read_text(encoding="utf-8") + "\nreview:\n auto_approve_on_receipt: true\n",
encoding="utf-8",
)
src = store.put_source(b"the quick brown fox jumps over the lazy dog", title="t")
ev = store.put_evidence(Evidence(id="forged-empty", source_id=src.id, locator="b0-0",
quote="", byte_start=0, byte_end=0))
pr = proposals.propose_claim(store, text="the moon is made of cheese and nobody can stop me",
evidence=[ev.id], proposed_by="attacker-agent")
print(list(proposals.auto_approve_receipts(store, actor="vouch-auto")))
for c in store.list_claims():
print(c.id, c.status, c.text)
Output: the claim lands with status working — durable, approved, zero
real evidentiary backing, and no human ever reviewed it.
store.put_evidence() only checks the cited source exists; it never
re-verifies the receipt. bundle.import_as_proposals /
sync._sync_apply_as_proposals write incoming Evidence.model_validate(...)
objects straight to put_evidence after schema validation only, so a
hand-crafted bundle or a malicious federation peer can plant a forged
empty-quote Evidence and cite it from an inbound claim — this isn't only
reachable through the normal propose path (which already can't trigger it,
since proposals.py builds evidence via receipt_for_quote →
locate_span's existing guard).
Environment
- vouch version:
test branch @ current HEAD
- Python version: 3.11+
- OS: any
- Host: any — reachable via bundle import, sync, or any code path that can
register an Evidence directly
.vouch/ state
Not required to reproduce — the repro above uses a fresh temp KB.
Anything else
This exact bug and fix were previously identified and correctly fixed in
#513 (CodeRabbit-approved, all substantive checks green) — but that PR was
closed unmerged on 2026-07-29 purely for going stale against a fast-moving
test branch (CHANGELOG.md conflict), not for anything wrong with the
change itself; the maintainer's closing comment explicitly invited a
rebase or a fresh PR: "this isn't a judgement on the change itself... happy
to look again." Notably, #513's CI also hit the fork-PR coderabbit-gate
403 permission bug (#511/#512) along the way, which likely contributed to
the review friction that let it go stale. Re-verified independently against
current test HEAD — the fix never landed, so the bug remains live.
Suggested fix: change both guards from quote is None (verify_receipt)
and evidence.quote is None (verify_evidence) to not quote, so an
empty string is treated identically to None. Add regression tests
mirroring the existing test_locate_span_returns_none_for_empty_quote
for both verify_receipt/verify_evidence directly and for
evaluate_claim_receipts end-to-end, so the mint-time and verify-time
guards stay in lockstep.
What happened
verify_receipt()andverify_evidence()insrc/vouch/receipts.pybothguard on
quote is None, not on an empty string:For
quote=""and any zero-length span (byte_start == byte_end, e.g.0, 0),source_bytes[0:0]decodes to"", which trivially string-equalsthe empty quote →
VERIFIED. This holds regardless of source content orlength, even against an empty source.
The module's own docstring states the entire premise this breaks: "This is
what lets the review gate become arithmetic instead of a person: a
citation whose receipt verifies can be auto-approved... The quoted span is
in the source at those offsets or it is not." An empty receipt proves
nothing, yet is scored identically to a real one.
verify_evidence()carries the identical gap (evidence.quote is None)ahead of its own source-read, so a missing source plus an empty quote is
misreported as
FORGED("source not in kb") rather than the morefundamentally correct
NO_RECEIPT("nothing to compare in the firstplace").
Evidence.quotehas no min-length constraint at the model layer(
quote: str | None = Noneinmodels.py, noFieldlength bound), sonothing upstream of
verify_receiptcloses this off.What you expected
An empty
quoteshould be treated the same as no receipt at all(
NO_RECEIPT), exactly aslocate_span()already does on the mintside (
if not needle: return None) — the verify side never got thematching guard.
Reproduction
Unit level:
Full end-to-end, no human in the loop (the
capture.py-documentedstarter-config default
review.auto_approve_on_receipt: true):Output: the claim lands with status
working— durable, approved, zeroreal evidentiary backing, and no human ever reviewed it.
store.put_evidence()only checks the cited source exists; it neverre-verifies the receipt.
bundle.import_as_proposals/sync._sync_apply_as_proposalswrite incomingEvidence.model_validate(...)objects straight to
put_evidenceafter schema validation only, so ahand-crafted bundle or a malicious federation peer can plant a forged
empty-quote Evidence and cite it from an inbound claim — this isn't only
reachable through the normal propose path (which already can't trigger it,
since
proposals.pybuilds evidence viareceipt_for_quote→locate_span's existing guard).Environment
testbranch @ current HEADregister an
Evidencedirectly.vouch/stateNot required to reproduce — the repro above uses a fresh temp KB.
Anything else
This exact bug and fix were previously identified and correctly fixed in
#513 (CodeRabbit-approved, all substantive checks green) — but that PR was
closed unmerged on 2026-07-29 purely for going stale against a fast-moving
testbranch (CHANGELOG.mdconflict), not for anything wrong with thechange itself; the maintainer's closing comment explicitly invited a
rebase or a fresh PR: "this isn't a judgement on the change itself... happy
to look again." Notably, #513's CI also hit the fork-PR
coderabbit-gate403 permission bug (#511/#512) along the way, which likely contributed to
the review friction that let it go stale. Re-verified independently against
current
testHEAD — the fix never landed, so the bug remains live.Suggested fix: change both guards from
quote is None(verify_receipt)and
evidence.quote is None(verify_evidence) tonot quote, so anempty string is treated identically to
None. Add regression testsmirroring the existing
test_locate_span_returns_none_for_empty_quotefor both
verify_receipt/verify_evidencedirectly and forevaluate_claim_receiptsend-to-end, so the mint-time and verify-timeguards stay in lockstep.