fix(ruvector-retrieval-receipt): correct provenance claims, harden verification per review

Doc corrections (merge gate for PR #824):
- State the actual threat model everywhere: receipts detect post-issuance
  mutation of a receipt/result pair; they do NOT protect against a
  dishonest query engine and do NOT prove write-chain membership. Leaves
  commit to COPIES of WriteReceipt fields and verification never consults
  the write gate, so a mutated ingestion history leaves already-issued
  receipts verifying. Named future work: bind leaves to MerkleGate's MMR
  membership proofs (HashChainGate::verify_receipt needs the live chain;
  no offline membership proof exists).
- Soften "verifiable offline without trusting the query engine" to match
  reality (unsigned, engine-chosen leaves).
- Relabel the 2x/2.1x benchmark comparison: PerResultReceipt proof size
  is defined as genesis-anchored replay O(idx); a head-anchored verifier
  needs only the O(k-idx) suffix, so the durable claim is asymptotic
  O(log k) vs O(k), not the constant. Reframe 200/200 tamper detection
  as a SHA-256 regression check, not an empirical rate (incl. ADR-304
  Rejection Criteria).
- Reword ADR-304 Evidence line citing
  index_state_root_changes_receipts_across_reingestion (that test
  compares roots only; it builds no receipt).
- Applications table: legal/medical row no longer marketed as
  chain-of-custody.

Code fixes:
- verify_full now fails closed on empty result sets (was vacuously true);
  documented + tested for all variants.
- RetrievalIndex::search uses total_cmp instead of
  partial_cmp().unwrap() (NaN no longer panics a public API).
- gate_variant is bound into each result leaf so a NullGate receipt
  (all-zero commitment) is distinguishable from a gated one; tested.

Tests: 14 passed (was 12). Clippy -D warnings and fmt clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-08-13 11:11:59 -04:00
parent 81701c7781
commit fcf0595eef
7 changed files with 306 additions and 112 deletions

View file

@ -1,19 +1,30 @@
# ruvector-retrieval-receipt
**Witness-chained provenance for ANN retrieval results** — cryptographic receipts that bind a
query's top-k results to the write-provenance of every returned vector, so a retrieval event can
be audited independently of the system that ran the query. Part of the
[ruvector](https://github.com/ruvnet/ruvector) ecosystem.
**Witness-chained provenance for ANN retrieval results** — cryptographic receipts that commit a
query's top-k results (together with copies of each vector's ingestion `WriteReceipt`) so that a
receipt/result pair, once issued, cannot be silently mutated in transit or in storage. Part of
the [ruvector](https://github.com/ruvnet/ruvector) ecosystem.
> `ruvector-proof-gate` proves what was *written*. This crate proves what a query actually
> *returned* — the read-side half of agent-memory provenance that no major vector database
> (Qdrant, Milvus, Weaviate, LanceDB, FAISS, pgvector, Chroma, Vespa, Pinecone) documents today.
> `ruvector-proof-gate` proves what was *written*. This crate makes the record of what a query
> *returned* tamper-evident after issuance — a read-side provenance primitive no major vector
> database (Qdrant, Milvus, Weaviate, LanceDB, FAISS, pgvector, Chroma, Vespa, Pinecone)
> documents today.
## What it gives you
Search a `RetrievalIndex` (a brute-force cosine index whose ingestion path is a real
`ruvector_proof_gate::HashChainGate`), wrap the result set in a `RetrievalReceipt`, and hand the
receipt to a verifier who never has to trust — or even talk to — the system that ran the query.
`ruvector_proof_gate::HashChainGate`), wrap the result set in a `RetrievalReceipt`, and a later
holder of the receipt can check — offline, without talking to the query engine — that the
results they hold are the ones the engine committed to at query time.
**Threat model, stated plainly:** receipts are unsigned commitments produced by the query
engine itself. They detect *post-issuance mutation* of a receipt/result pair. They do **not**
protect against a dishonest query engine (leaves are engine-chosen; nothing binds a score to an
actual cosine computation or the committed set to the true top-k), and they do **not** prove
write-chain membership — leaves commit to *copies* of `WriteReceipt` fields, verification never
consults the write gate, so mutating the ingestion history after issuance leaves existing
receipts verifying. Anchoring leaves to `MerkleGate`'s MMR membership proofs is the named
future-work item. See ADR-304's Threat Model section.
## Variants
@ -45,9 +56,11 @@ assert!(receipt.verify_full(qh, root, &results));
Measured (n=5,000, dims=128, k=10, release build): `MerkleReceipt` generation ≈ 19.6 µs
(1.8% of a 1.1 ms brute-force search), single-result verification ≈ 3.8 µs, worst-case proof
size 160 bytes — 2x smaller and 2.1x faster to verify than `PerResultReceipt`'s equivalent
(320 bytes, 8.2 µs). Both variants detect 100% (200/200) of injected tampering across four
tamper kinds. Full methodology and raw output:
size 160 bytes, vs 320 bytes / 8.2 µs for `PerResultReceipt` — where the per-result figure is
defined as the genesis-anchored chain replay (O(idx)); the durable comparison is the
asymptotic O(log k) vs O(k) proof size, not the specific constant at k=10. Both variants
rejected all 200/200 injected tamper trials — expected from SHA-256 by construction, a
regression check rather than an empirical detection rate. Full methodology and raw output:
[`docs/research/nightly/2026-08-13-retrieval-receipts/README.md`](../../docs/research/nightly/2026-08-13-retrieval-receipts/README.md).
See [`ADR-304`](../../docs/adr/ADR-304-retrieval-receipts.md) for the design rationale,

View file

@ -6,10 +6,12 @@ pub struct ResultItem {
pub vector_id: u64,
pub rank: u32,
pub score: f32,
/// The write receipt produced when this vector was ingested. Binding it
/// into the retrieval receipt lets an auditor walk from a query result
/// back to the exact ingestion event, closing the write->read provenance
/// loop that ruvector-proof-gate alone (write-only) cannot provide.
/// The write receipt produced when this vector was ingested. The
/// retrieval receipt binds *copies* of this receipt's fields, making
/// the cited ingestion record part of what the retrieval receipt
/// commits to. This is a tamper-evident record of "which write receipt
/// was cited", not a proof of write-chain membership — see the threat
/// model in `receipt::result_leaf` and the crate docs.
pub write_receipt: WriteReceipt,
}
@ -124,7 +126,9 @@ impl RetrievalIndex {
.enumerate()
.map(|(i, v)| (i, Self::cosine(query, v)))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
// total_cmp: NaN-safe total order (a NaN-scored candidate sorts
// last instead of panicking a public API on adversarial input).
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored
.into_iter()
.take(k)

View file

@ -1,18 +1,29 @@
//! Witness-chained provenance receipts for ANN retrieval results.
//!
//! `ruvector-proof-gate` answers "what was written, and can I prove it
//! hasn't been tampered with?" for the *write* path. This crate answers
//! the symmetric question for the *read* path: "what did this query
//! actually return, against which index state, and can an auditor
//! independently confirm that — without re-running the query or trusting
//! whoever ran it?"
//! hasn't been tampered with?" for the *write* path. This crate addresses
//! the read path: it commits a query's result set to a receipt so that a
//! receipt/result pair, once issued, cannot be silently mutated in transit
//! or in storage without verification failing.
//!
//! Every result item is bound into its receipt together with the
//! `WriteReceipt` produced when that vector was ingested, so a retrieval
//! receipt is not just "these IDs and scores were returned" but "these IDs,
//! scores, and their entire write-provenance chain were returned" — closing
//! the loop between ingestion integrity and retrieval integrity that a RAG
//! auditor needs to replay an agent's evidence trail.
//! # Threat model — read this before relying on the receipts
//!
//! Receipts are **unsigned commitments produced by the query engine
//! itself**. What they do and do not guarantee:
//!
//! - They **detect post-issuance mutation** of a receipt/result pair. That
//! is the guarantee: an auditor holding the receipt can tell whether the
//! result set they were handed is the one the engine committed to.
//! - They do **not** protect against a dishonest query engine. Leaves are
//! engine-chosen; nothing binds a leaf's `score` to an actual cosine
//! computation, or the committed k-set to the true top-k.
//! - They do **not** prove write-chain membership. Each leaf commits to
//! *copies* of the `WriteReceipt`'s fields; verification never consults
//! the write gate, so mutating the ingestion history after a receipt is
//! issued leaves that receipt verifying. Binding each result to an
//! offline membership proof via `ruvector_proof_gate::MerkleGate`'s MMR
//! is the named future-work item that would make the write→read link
//! real.
//!
//! # Variants
//!
@ -104,6 +115,9 @@ impl RetrievalReceipt {
}
}
/// Verify a complete result set against the receipt. Empty result sets
/// fail closed for every variant: "no evidence" must never be
/// reportable as "verified evidence".
pub fn verify_full(&self, qh: [u8; 32], index_root: [u8; 32], results: &[ResultItem]) -> bool {
match self {
RetrievalReceipt::None => false,
@ -250,4 +264,36 @@ mod tests {
let index_b = RetrievalIndex::ingest(50, 8, 2);
assert_ne!(index_a.index_state_root(), index_b.index_state_root());
}
#[test]
fn empty_result_set_fails_closed_for_all_variants() {
let (_, _, _, qh, root) = setup(50, 8, 3);
let empty: Vec<ResultItem> = Vec::new();
for variant in [
ReceiptVariant::None,
ReceiptVariant::PerResult,
ReceiptVariant::Merkle,
] {
let receipt = RetrievalReceipt::build(variant, qh, root, &empty);
assert!(
!receipt.verify_full(qh, root, &empty),
"{variant:?}: an empty result set must not verify vacuously"
);
}
}
#[test]
fn gate_variant_is_bound_into_the_leaf() {
use ruvector_proof_gate::GateVariant;
let (_, _, results, qh, root) = setup(100, 16, 4);
for variant in [ReceiptVariant::PerResult, ReceiptVariant::Merkle] {
let receipt = RetrievalReceipt::build(variant, qh, root, &results);
let mut tampered = results.clone();
// Same (copied) commitment/payload hashes, but claim they came
// from a NullGate: must not verify — otherwise an ungated
// all-zero receipt would be indistinguishable from a gated one.
tampered[0].write_receipt.gate_variant = GateVariant::Null;
assert!(!receipt.verify_item(0, qh, root, &tampered[0]));
}
}
}

View file

@ -36,10 +36,25 @@ pub fn query_hash(query: &[f32]) -> [u8; 32] {
}
/// Domain-separated leaf commitment for one result. Binds: the query, the
/// index state root at query time, the result's rank/id/score, and the
/// *write* receipt's chain commitment + payload hash. That last binding is
/// the write->read provenance link: mutate the ingestion history and every
/// retrieval receipt that ever served that vector becomes unverifiable.
/// index state root at query time, the result's rank/id/score, and *copies*
/// of the write receipt's gate variant, chain commitment, and payload hash.
///
/// Threat model — stated precisely so it is not over-read:
///
/// - The receipt detects **post-issuance mutation** of a receipt/result
/// pair (in transit or in storage). That is the whole guarantee.
/// - It does **not** protect against a dishonest query engine: leaves are
/// engine-chosen and unsigned; nothing binds `score` to an actual cosine
/// computation or the committed set to the true top-k.
/// - It does **not** prove write-chain membership: verification recomputes
/// hashes over the caller-supplied copies of the `WriteReceipt` fields
/// and never consults the write gate, so mutating the ingestion history
/// *after* a receipt is issued leaves that receipt verifying. Making the
/// write→read link real requires an offline membership proof — i.e.
/// anchoring each leaf to `ruvector_proof_gate::MerkleGate`'s MMR
/// inclusion proofs. That is the named future-work item, not implemented
/// here (`HashChainGate::verify_receipt` requires the live gate's full
/// chain and offers no offline membership proof).
fn result_leaf(query_hash: &[u8; 32], index_root: &[u8; 32], item: &ResultItem) -> [u8; 32] {
let mut h = Sha256::new();
h.update(b"ruvector:retrieval:leaf:");
@ -48,6 +63,9 @@ fn result_leaf(query_hash: &[u8; 32], index_root: &[u8; 32], item: &ResultItem)
h.update(item.vector_id.to_le_bytes());
h.update(item.rank.to_le_bytes());
h.update(item.score.to_bits().to_le_bytes());
// gate_variant is bound so a NullGate receipt (all-zero commitment and
// payload hash) cannot masquerade as a gated one under the same leaf.
h.update([item.write_receipt.gate_variant as u8]);
h.update(item.write_receipt.chain_commitment);
h.update(item.write_receipt.payload_hash);
h.finalize().into()
@ -133,8 +151,13 @@ impl PerResultReceipt {
prev == self.commitments[idx]
}
/// Empty result sets fail closed: with zero leaves the length check and
/// the per-item loop would both pass vacuously, making "no evidence"
/// indistinguishable from "verified evidence". A caller with a
/// legitimately empty result set has nothing to prove and must not
/// treat a receipt as attesting to it.
pub fn verify_full(&self, qh: [u8; 32], index_root: [u8; 32], results: &[ResultItem]) -> bool {
if results.len() != self.leaves.len() {
if results.is_empty() || results.len() != self.leaves.len() {
return false;
}
(0..results.len()).all(|i| self.verify_item(i, qh, index_root, &results[i]))
@ -250,8 +273,11 @@ impl MerkleReceipt {
node == root
}
/// Empty result sets fail closed (same rationale as
/// [`PerResultReceipt::verify_full`]): a zero-leaf receipt would
/// otherwise verify vacuously.
pub fn verify_full(&self, qh: [u8; 32], index_root: [u8; 32], results: &[ResultItem]) -> bool {
if results.len() != self.leaves.len() {
if results.is_empty() || results.len() != self.leaves.len() {
return false;
}
results.iter().enumerate().all(|(i, item)| {

View file

@ -18,7 +18,10 @@ agentic RAG: **given a result set an agent used to produce an answer, can a
third party — an auditor, a compliance reviewer, or another agent —
independently confirm that a specific vector was genuinely part of that
result set, against a specific index state, without re-running the query
and without trusting whoever ran it?**
and without trusting whoever ran it?** (What this ADR actually delivers
is narrower than that ideal — unsigned commitments that detect
post-issuance mutation, with the engine still trusted at issuance time;
see Threat Model.)
This is the same problem write-receipts solve for ingestion, applied to
retrieval. Its absence matters because:
@ -76,11 +79,19 @@ Add `crates/ruvector-retrieval-receipt`, a small crate that:
`ruvector_proof_gate::HashChainGate`, so every stored vector carries an
actual `WriteReceipt`.
2. Defines `ResultItem { vector_id, rank, score, write_receipt }` — the
unit a retrieval receipt commits to. Binding the *write* receipt's
`chain_commitment` and `payload_hash` into each result leaf is the core
design choice: it links read-time evidence to write-time evidence in
one hash, so tampering with either the ingestion history or the result
set invalidates the receipt.
unit a retrieval receipt commits to. Each result leaf binds *copies* of
the write receipt's `gate_variant`, `chain_commitment`, and
`payload_hash`. This makes the write-time evidence part of what the
receipt commits to, so a receipt/result pair cannot be mutated after
issuance without detection — but it is a commitment to copies, not a
membership proof. Verification never consults the write gate, so it
does **not** prove the bound `WriteReceipt` belongs to any live write
chain, and mutating the ingestion history *after* issuance leaves
already-issued receipts verifying. (`HashChainGate::verify_receipt`
requires the live gate's full chain; `ruvector-proof-gate`'s hash-chain
variant offers no offline membership proof.) Anchoring each leaf to
`MerkleGate`'s MMR inclusion proofs is the named future-work item that
would turn the write→read link into a real membership binding.
3. Implements three variants behind `ReceiptVariant`:
- `None` — establishes the search-only cost floor.
- `PerResult` — sequential SHA-256 chain over the k result leaves,
@ -90,6 +101,18 @@ Add `crates/ruvector-retrieval-receipt`, a small crate that:
RFC-6962-style domain-separated leaf/internal-node hashing (distinct
`b"...leaf:"` / `b"...node:"` prefixes) and O(log k) inclusion proofs.
## Threat Model
Stated plainly so the guarantee is not over-read: a retrieval receipt
detects **post-issuance mutation** of a receipt/result pair — in transit
or in storage. It does **not** protect against a dishonest query engine
(leaves are engine-chosen and unsigned; nothing binds a leaf's score to an
actual cosine computation, or the committed k-set to the true top-k), and
it does **not** prove write-chain membership (see Decision §2). "Offline
verification" therefore means: a holder of the receipt can check, without
talking to the engine, that the results they hold are the ones the engine
committed to — not that the engine computed them honestly.
## Evidence
Measured via `cargo run --release -p ruvector-retrieval-receipt --bin
@ -98,25 +121,35 @@ variant — 50 per tamper kind × 4 kinds). See the nightly research README
for the full output table and raw numbers; do not restate rounded figures
here as a substitute for the actual run.
Unit-level correctness (12 tests in `src/lib.rs`) independently confirms,
Unit-level correctness (14 tests in `src/lib.rs`) independently confirms,
per variant:
- Honest result sets always verify (`per_result_receipt_verifies_honest_results`,
`merkle_receipt_verifies_honest_results`).
- Score mutation, reordering, and cross-query vector-ID substitution are
each individually detected.
- Score mutation, reordering, cross-query vector-ID substitution, and
gate-variant substitution are each individually detected. (Detection of
a mutated preimage is expected from SHA-256 by construction; these are
regression checks on the implementation, not an empirical detection
rate.)
- Empty result sets fail closed for every variant
(`empty_result_set_fails_closed_for_all_variants`).
- `MerkleReceipt`'s worst-case proof is smaller than `PerResultReceipt`'s
at k=10 (`merkle_proof_bytes_are_sublinear_vs_per_result_at_k10`).
- Re-ingesting the same logical dataset under a different seed produces a
different `index_state_root`, so receipts cannot be replayed across
index instances undetected.
- Re-ingesting under a different seed produces a different
`index_state_root` (`index_state_root_changes_receipts_across_reingestion`).
Note that test compares roots only — it does not construct a receipt —
so cross-index replay rejection follows from the leaf's binding to
`index_root` (exercised by the honest/tamper tests), rather than being
directly exercised end-to-end by that test.
## Consequences
**Positive:**
- Closes the write→read provenance gap: an agent's RAG evidence trail can
now be replayed end to end (ingestion receipt → retrieval receipt) using
only existing `ruvector-proof-gate` primitives, no new cryptographic
machinery.
- Makes a query's committed result set tamper-evident after issuance: an
agent's RAG evidence record (ingestion receipt copy + retrieval receipt)
can be checked for post-hoc mutation using only existing
`ruvector-proof-gate` hash primitives, no new cryptographic machinery.
This is integrity of the *record* of retrieval, not proof of honest
retrieval and not write-chain membership — see Threat Model.
- `MerkleReceipt` gives a compact (O(log k)), portable proof for a single
disputed result — useful when only one cited memory needs to be
challenged, not the whole answer.
@ -167,10 +200,14 @@ per variant:
2. If promoted: integrate as an optional wrapper around
`ruvector-agent-memory` query paths, gated behind a Cargo feature so the
default build pays zero cost.
3. Wire `index_state_root`/`chain_head` signing through the existing
3. Bind each result leaf to a `MerkleGate` MMR inclusion proof so a
receipt proves write-chain *membership* offline instead of merely
committing to copies of `WriteReceipt` fields — the prerequisite for
any chain-of-custody-grade claim.
4. Wire `index_state_root`/`chain_head` signing through the existing
witness-signing story once `ruvector-proof-gate` gains one (currently
neither crate signs; both are commitment-only).
4. MCP surface: a narrow `retrieval_verify` read-only tool that accepts a
5. MCP surface: a narrow `retrieval_verify` read-only tool that accepts a
receipt + one result item and returns a boolean, never exposing raw
index internals.
@ -238,7 +275,11 @@ no other crate depends on it.
This direction should be rejected for production promotion if any of the
following hold on re-measurement at larger scale (n≥100k, k≥100):
- Tamper-detection rate drops below 100% for any tamper kind.
- Any tamper-kind regression test fails. Detection of a mutated preimage
follows from SHA-256 collision resistance — the 200/200 trial result is
a correctness regression check, not an empirical detection rate — so a
failure here would indicate an implementation bug, which is
disqualifying.
- `MerkleReceipt`'s proof-size advantage disappears or inverts at larger k
(it should not, asymptotically, but must be re-confirmed rather than
assumed).

View file

@ -1,6 +1,6 @@
# Retrieval Receipts: Witness-Chained Provenance for ANN Query Results
**150-char summary:** Cryptographic receipts for ANN query results binding retrieved vectors to their ingestion write-history — MerkleReceipt gives 2x-smaller audit proofs than a hash chain.
**150-char summary:** Cryptographic receipts making ANN query results tamper-evident after issuance — MerkleReceipt gives O(log k) audit proofs vs a hash chain's O(k).
**Date:** 2026-08-13
**Crate:** `crates/ruvector-retrieval-receipt`
@ -14,9 +14,9 @@
*writes*: a SHA-256 hash chain or Merkle Mountain Range commits to every
admitted vector. No public vector database (Milvus, Qdrant, Weaviate,
Pinecone, LanceDB, FAISS, pgvector, Chroma, Vespa) documents an equivalent
mechanism for the *read* path — none produce evidence that a specific
result set was actually what a query returned, verifiable independently of
the system that ran the query.
mechanism for the *read* path — none produce evidence that lets a later
holder of a result set check, independently of the system that ran the
query, that the set was not mutated after it was returned.
This nightly implements and benchmarks **retrieval receipts**: cryptographic
commitments over a query's top-k result set that bind each result to the
@ -30,12 +30,20 @@ measured on real Rust release builds:
| `MerkleReceipt` | 19,582 ns | 3,839 ns | **160 bytes** | 200/200 |
**Key measured result:** `MerkleReceipt`'s worst-case single-result
verification proof is **2x smaller** than `PerResultReceipt`'s (160 vs 320
bytes at k=10) and **2.1x faster to verify** (3,839 ns vs 8,229 ns), while
both variants detect **100% (200/200)** of injected tampering across four
distinct tamper kinds. Receipt generation adds **1.6-1.8%** to the
1.1 ms brute-force search it accompanies — far under the 15% acceptance
threshold set before the run.
verification proof is 160 bytes and verifies in 3,839 ns, vs 320 bytes /
8,229 ns for `PerResultReceipt` at k=10. One baseline caveat applies to
that comparison: `PerResultReceipt`'s proof size is defined here as the
genesis-anchored chain replay (`(idx+1) * 32` bytes — leaves `0..=idx`);
a verifier anchored at the chain head instead would need only the
`kidx` suffix, which at the measured worst index is smaller than the
Merkle path. The durable claim is therefore the asymptotic one — O(log k)
Merkle proofs vs O(k) chain replay regardless of which result is disputed
— not the specific 2x constant. Both variants rejected all 200/200
injected tamper trials; that is expected by construction (a mutated
preimage changes its SHA-256 hash), so it is a regression check on the
implementation, not an empirical detection rate. Receipt generation adds
**1.6-1.8%** to the 1.1 ms brute-force search it accompanies — far under
the 15% acceptance threshold set before the run.
All numbers are from `cargo run --release -p ruvector-retrieval-receipt
--bin benchmark -- 5000 128 10 200` on the hardware below. Raw output is
@ -85,12 +93,15 @@ not merely a vector database. Two integrity primitives already exist:
*authorized to read* a vector at all.
Neither answers: given a result set an agent actually used to produce an
answer, can a third party confirm — independently, offline, without
re-running the query or trusting the query engine — that this was
genuinely what got retrieved? That is the read-side analogue of write
provenance, and it is the missing link for agent evidence trails: an audit
of "why did the agent say X" needs to walk both the ingestion history *and*
the retrieval event that surfaced it.
answer, can a third party later confirm — offline, without re-running the
query — that the result set they hold is the one the engine committed to
at query time? Note the precise shape of that guarantee: receipts are
unsigned commitments produced by the engine itself, so they detect
post-issuance mutation of a receipt/result pair; they do not make a
dishonest engine honest (see [Threat Model](#threat-model)). That is
still the missing link for agent evidence trails: an audit of "why did
the agent say X" needs a tamper-evident record of the retrieval event
that surfaced it.
This connects five RuVector ecosystem capabilities in one crate:
@ -137,12 +148,30 @@ flowchart LR
```
Each result leaf commits to: the query hash, the index state root at query
time, the result's rank/vector_id/score, and — critically — the underlying
`WriteReceipt`'s `chain_commitment` and `payload_hash`. This last binding
is the write-read provenance link: an auditor holding only a retrieval
receipt can confirm not just "this ID/score was returned" but "this exact
ingestion event was returned," and tampering with either the write history
or the read result independently invalidates the receipt.
time, the result's rank/vector_id/score, and *copies* of the underlying
`WriteReceipt`'s `gate_variant`, `chain_commitment`, and `payload_hash`.
Binding those copies makes the write-time evidence part of what the
receipt commits to: an auditor can confirm "this exact ingestion record
was cited," and any post-issuance mutation of either the result fields or
the bound write-receipt copy breaks verification.
### Threat Model
What a retrieval receipt does and does not prove:
- **Does:** detect post-issuance mutation of a receipt/result pair, in
transit or in storage. That is the whole guarantee.
- **Does not:** protect against a dishonest query engine. Leaves are
engine-chosen and unsigned; nothing binds a leaf's score to an actual
cosine computation, or the committed k-set to the true top-k.
- **Does not:** prove write-chain membership. Verification recomputes
hashes over the caller-supplied copies and never consults the write
gate, so mutating the ingestion history *after* a receipt is issued
leaves that receipt verifying. `HashChainGate::verify_receipt` requires
the live gate's full chain — the hash-chain variant offers no offline
membership proof. Anchoring leaves to `MerkleGate`'s MMR inclusion
proofs is the named future-work item that would make the write→read
link a real membership binding.
---
@ -158,9 +187,9 @@ or the read result independently invalidates the receipt.
`b"...leaf:"` vs `b"...node:"` prefixes prevent leaf/internal-node type
confusion).
- `src/lib.rs``RetrievalReceipt` enum unifying all three variants for
benchmarking, plus 12 unit tests covering honest verification, four
independent tamper kinds per structured variant, and cross-index replay
rejection.
benchmarking, plus 14 unit tests covering honest verification, four
independent tamper kinds per structured variant, gate-variant binding,
empty-result fail-closed behavior, and cross-index root divergence.
- `src/bin/benchmark.rs` — the benchmark producing the numbers below,
including the tamper-detection trial harness.
@ -227,10 +256,11 @@ generation overhead < 15% of baseline search: merkle=1.8% per_result=1.6% -> tru
ACCEPTANCE RESULT: ACCEPT
```
`cargo test --release -p ruvector-retrieval-receipt`: **12 passed, 0
`cargo test --release -p ruvector-retrieval-receipt`: **14 passed, 0
failed** (deterministic-seed unit tests covering honest verification, four
tamper kinds independently, cross-index replay rejection, and the
proof-size sublinearity claim in isolation from the benchmark binary).
tamper kinds independently, gate-variant binding, empty-result
fail-closed behavior, cross-index root divergence, and the proof-size
sublinearity claim in isolation from the benchmark binary).
## Acceptance Result
@ -238,11 +268,14 @@ proof-size sublinearity claim in isolation from the benchmark binary).
ACCEPT
```
All three clauses of the formalized hypothesis held: (a) 100%
tamper-detection for both structured variants across 200 trials each, (b)
`MerkleReceipt`'s worst-case proof (160 bytes) is smaller than
`PerResultReceipt`'s (320 bytes) at k=10, (c) generation overhead
(1.6-1.8%) is well under the 15% threshold fixed before this run.
All three clauses of the formalized hypothesis held: (a) all 200/200
tamper trials rejected for both structured variants — expected from
SHA-256 by construction, reported as a regression check rather than an
empirical detection rate; (b) `MerkleReceipt`'s worst-case proof (160
bytes) is smaller than `PerResultReceipt`'s (320 bytes) at k=10 under the
genesis-anchored proof-size definition (see the baseline caveat in the
abstract); (c) generation overhead (1.6-1.8%) is well under the 15%
threshold fixed before this run.
---
@ -254,14 +287,18 @@ tamper-detection for both structured variants across 200 trials each, (b)
- `MerkleReceipt`: `leaves` (32 bytes each) + `root` (32 bytes) →
`(k + 1) * 32` bytes total (352 bytes at k=10, matches measured).
- Worst-case single-item proof: `PerResultReceipt` needs `(idx+1)*32`
bytes (320 at idx=9); `MerkleReceipt` needs `32 + ceil(log2 k)*32` bytes
(160 at k=10, `ceil(log2 10) = 4` sibling hashes).
- At k=100 the asymptotic gap widens sharply: `PerResultReceipt` worst-case
proof ≈ 3,200 bytes; `MerkleReceipt``32 + 7*32` = 256 bytes — a ~12.5x
gap instead of 2x. This crate does not re-measure that scale-up; it is a
direct consequence of the O(idx) vs O(log k) complexity already confirmed
at k=10 and is stated here as arithmetic, not fabricated as a second
benchmark run.
bytes (320 at idx=9) under the genesis-anchored replay definition used
throughout this experiment — a head-anchored verifier would instead need
only the suffix from `idx` to the chain head, so this figure is a
property of the chosen baseline definition, not of hash chains in
general. `MerkleReceipt` needs `32 + ceil(log2 k)*32` bytes (160 at
k=10, `ceil(log2 10) = 4` sibling hashes) regardless of anchoring.
- At k=100 the asymptotic gap widens under the same genesis-anchored
definition: `PerResultReceipt` worst-case proof ≈ 3,200 bytes;
`MerkleReceipt``32 + 7*32` = 256 bytes. This crate does not
re-measure that scale-up; it is a direct consequence of the O(idx) vs
O(log k) complexity already confirmed at k=10 and is stated here as
arithmetic, not fabricated as a second benchmark run.
## Performance Math
@ -372,7 +409,7 @@ query while preserving disputability for a bounded recent window.
| 1 | Compliance-regulated agent deployments | "Prove what evidence the agent actually used" | `MerkleReceipt` + `ruvector-proof-gate` | Wrap `ruvector-agent-memory` queries | Audit-passable RAG | Signing story still open (ADR-304) | Now-2027 |
| 2 | Multi-agent code assistants | Disputed "the agent hallucinated this function" claims | Retrieval receipt as ground truth | MCP `retrieval_verify` tool | Reduced trust-repair cost | Adoption friction (opt-in feature) | Now-2027 |
| 3 | Enterprise RAG platforms | Silent retrieval-layer bugs swapping results | Tamper-evident result sets | Feature-flagged wrapper | Faster incident diagnosis | False sense of security if misapplied to writes | 2027-2029 |
| 4 | Legal/medical retrieval systems | Chain-of-custody requirements on cited evidence | Write+read receipt binding | RVF portable bundle | Regulatory eligibility | Merkle padding weakness needs hardening first | 2027-2030 |
| 4 | Legal/medical retrieval systems | Tamper-evident records of cited evidence | Write+read receipt binding | RVF portable bundle | Regulatory eligibility | Not chain-of-custody today: needs MerkleGate membership proofs + signed roots (neither built), plus Merkle padding hardening | 2027-2030 |
| 5 | Federated agent memory (edge + cloud sync) | Confirming synced results match origin index state | `index_state_root` binding | RVM coherence domain | Detects sync corruption | Needs signed roots, not yet built | 2028-2032 |
| 6 | Scientific literature search agents | Reproducible citation trails | Deterministic receipt replay | RVF replay bundle | Reproducibility compliance | Requires persisted receipts (storage cost) | 2027-2030 |
| 7 | Security incident-response retrieval | "What did the SOC agent actually pull from the threat-intel index" | Full write→read chain | ruFlo audit workflow | Faster post-incident review | Needs retention policy | Now-2028 |
@ -397,9 +434,10 @@ See ADR-304 "Rejection Criteria" — reproduced here for completeness:
## Rejection Criteria (Not Yet Triggered)
- Tamper-detection rate drops below 100% for any kind at larger scale
(n≥100k, k≥100) — not observed at this scale; must be re-checked, not
assumed to hold.
- Any tamper-kind regression test fails at larger scale (n≥100k, k≥100).
Detection follows from SHA-256 collision resistance — 200/200 is a
regression check, not an empirical rate — so a failure would indicate
an implementation bug, which is disqualifying.
- `MerkleReceipt`'s proof-size advantage disappears or inverts at larger
k — should not happen asymptotically but is unverified beyond k=10.
- Receipt overhead exceeds 15% once measured against a real HNSW/ANN
@ -411,6 +449,11 @@ See ADR-304 "Rejection Criteria" — reproduced here for completeness:
- Only exact brute-force retrieval was measured; approximate-index
composition is unverified.
- Receipts commit to *copies* of `WriteReceipt` fields, not to write-chain
membership: a mutated ingestion history does not invalidate
already-issued receipts, and a dishonest query engine is out of scope
entirely — see [Threat Model](#threat-model). MerkleGate MMR membership
binding is the named future-work item.
- No signing of roots/heads — receipts are commitments only, matching
`ruvector-proof-gate`'s current scope, not a complete non-repudiation
system on their own.

View file

@ -37,12 +37,21 @@ top-10 query's result set in a cryptographic receipt should:
The core primitive is a `ResultItem`: `{vector_id, rank, score,
write_receipt}`. The `write_receipt` field is the design's load-bearing
decision — it's the actual `WriteReceipt` `ruvector-proof-gate` produced
when that vector was ingested, not a re-derived stand-in. Binding it into
every retrieval receipt means tampering with either the *ingestion*
history or the *retrieval* result independently breaks verification. This
is the write→read provenance link: an auditor holding only a retrieval
receipt can trace a result back through the exact write event that put it
in the index.
when that vector was ingested, not a re-derived stand-in. Each result
leaf binds *copies* of that receipt's `gate_variant`, `chain_commitment`,
and `payload_hash`, so a receipt/result pair cannot be mutated after
issuance without verification failing.
The threat model must be stated plainly: receipts are unsigned
commitments produced by the query engine itself. They detect
**post-issuance mutation** of a receipt/result pair (in transit or in
storage). They do **not** protect against a dishonest query engine
(nothing binds a score to an actual cosine computation, or the committed
set to the true top-k), and they do **not** prove write-chain membership
— verification never consults the write gate, so mutating the ingestion
history after issuance leaves existing receipts verifying. Anchoring
leaves to `MerkleGate`'s MMR inclusion proofs is the named future-work
item that would make the write→read link a real membership binding.
Two structured receipt variants wrap a query's k-result set:
@ -77,9 +86,10 @@ couldn't cleanly attribute a regression to either cause.
reproducibility without an RNG dependency).
- `receipt.rs``PerResultReceipt` and `MerkleReceipt`, including Merkle
tree construction, inclusion-proof generation, and verification.
- `lib.rs` — a unifying `RetrievalReceipt` enum plus 12 unit tests: honest
- `lib.rs` — a unifying `RetrievalReceipt` enum plus 14 unit tests: honest
verification for both variants, four independently tested tamper kinds
(score mutation, reordering, cross-query ID substitution), and a direct
(score mutation, reordering, cross-query ID substitution, gate-variant
substitution), empty-result fail-closed behavior, and a direct
assertion that Merkle proof bytes are smaller than per-result proof
bytes at k=10.
- `bin/benchmark.rs` — the measurement harness below.
@ -110,18 +120,29 @@ generation overhead < 15% of baseline search: merkle=1.8% per_result=1.6% -> tru
ACCEPTANCE RESULT: ACCEPT
```
`cargo test --release -p ruvector-retrieval-receipt`: 12/12 passing.
`cargo test --release -p ruvector-retrieval-receipt`: 14/14 passing.
The 200/200 tamper-rejection result is expected from SHA-256 by
construction (a mutated preimage changes its hash) — it is a regression
check on the implementation, not an empirical detection rate.
MerkleReceipt's advantage compounds with k: at k=10 the proof-size gap is
2x (160 vs 320 bytes); the same O(log k) vs O(idx) arithmetic implies a
~12.5x gap at k=100 (256 vs ~3,200 bytes worst case) — stated here as
arithmetic extrapolation, not as a re-run benchmark result, and flagged
explicitly in the ADR's rejection criteria as needing direct
One caveat on the proof-size comparison: `PerResultReceipt`'s proof is
defined as the genesis-anchored chain replay (`(idx+1)*32` bytes); a
head-anchored verifier would need only the `kidx` suffix, so the "160 vs
320 bytes" gap at the worst index is a property of that baseline
definition. The durable claim is the asymptotic one — O(log k) Merkle
proofs vs O(k) chain replay regardless of which result is disputed. Under
the same definition the gap at k=100 works out to 256 vs ~3,200 bytes
worst case — stated as arithmetic extrapolation, not a re-run benchmark
result, and flagged in the ADR's rejection criteria as needing direct
re-measurement before being treated as a production claim.
## Limitations
- Brute-force only; composition with a real ANN index is unmeasured.
- Receipts commit to *copies* of `WriteReceipt` fields, not to write-chain
membership: a mutated ingestion history does not invalidate
already-issued receipts, and a dishonest query engine is out of scope.
MerkleGate MMR membership binding is the named future-work item.
- No signature scheme over receipt roots — this crate produces
commitments, matching `ruvector-proof-gate`'s current scope, not a
complete non-repudiation system by itself.