From fcf0595eef00626a821050db7ee47670cd9f780e Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 13 Aug 2026 11:11:59 -0400 Subject: [PATCH] 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 --- crates/ruvector-retrieval-receipt/README.md | 37 +++-- .../ruvector-retrieval-receipt/src/index.rs | 14 +- crates/ruvector-retrieval-receipt/src/lib.rs | 68 +++++++-- .../ruvector-retrieval-receipt/src/receipt.rs | 38 ++++- docs/adr/ADR-304-retrieval-receipts.md | 79 ++++++++--- .../2026-08-13-retrieval-receipts/README.md | 133 ++++++++++++------ .../2026-08-13-retrieval-receipts/gist.md | 49 +++++-- 7 files changed, 306 insertions(+), 112 deletions(-) diff --git a/crates/ruvector-retrieval-receipt/README.md b/crates/ruvector-retrieval-receipt/README.md index 215b33269..86a960e94 100644 --- a/crates/ruvector-retrieval-receipt/README.md +++ b/crates/ruvector-retrieval-receipt/README.md @@ -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, diff --git a/crates/ruvector-retrieval-receipt/src/index.rs b/crates/ruvector-retrieval-receipt/src/index.rs index a22c618e3..4b799c95f 100644 --- a/crates/ruvector-retrieval-receipt/src/index.rs +++ b/crates/ruvector-retrieval-receipt/src/index.rs @@ -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) diff --git a/crates/ruvector-retrieval-receipt/src/lib.rs b/crates/ruvector-retrieval-receipt/src/lib.rs index bd786bddb..755c822f0 100644 --- a/crates/ruvector-retrieval-receipt/src/lib.rs +++ b/crates/ruvector-retrieval-receipt/src/lib.rs @@ -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 = 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])); + } + } } diff --git a/crates/ruvector-retrieval-receipt/src/receipt.rs b/crates/ruvector-retrieval-receipt/src/receipt.rs index d01991569..9ad3f3225 100644 --- a/crates/ruvector-retrieval-receipt/src/receipt.rs +++ b/crates/ruvector-retrieval-receipt/src/receipt.rs @@ -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)| { diff --git a/docs/adr/ADR-304-retrieval-receipts.md b/docs/adr/ADR-304-retrieval-receipts.md index fcc4627f7..cda06aa3a 100644 --- a/docs/adr/ADR-304-retrieval-receipts.md +++ b/docs/adr/ADR-304-retrieval-receipts.md @@ -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). diff --git a/docs/research/nightly/2026-08-13-retrieval-receipts/README.md b/docs/research/nightly/2026-08-13-retrieval-receipts/README.md index 5dfaf7e19..528a75664 100644 --- a/docs/research/nightly/2026-08-13-retrieval-receipts/README.md +++ b/docs/research/nightly/2026-08-13-retrieval-receipts/README.md @@ -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 +`k−idx` 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. diff --git a/docs/research/nightly/2026-08-13-retrieval-receipts/gist.md b/docs/research/nightly/2026-08-13-retrieval-receipts/gist.md index fe1c50a10..56eed5e3e 100644 --- a/docs/research/nightly/2026-08-13-retrieval-receipts/gist.md +++ b/docs/research/nightly/2026-08-13-retrieval-receipts/gist.md @@ -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 `k−idx` 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.