mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-17 12:43:42 +00:00
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:
parent
81701c7781
commit
fcf0595eef
7 changed files with 306 additions and 112 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)| {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue