diff --git a/Cargo.lock b/Cargo.lock index 4f4ffc292f..11a9a1acbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8910,6 +8910,7 @@ dependencies = [ name = "ruvector-agent-memory" version = "0.1.0" dependencies = [ + "ed25519-dalek", "rand 0.8.6", "ruvector-mincut 2.3.1", "ruvector-proof-gate", diff --git a/crates/ruvector-agent-memory/Cargo.toml b/crates/ruvector-agent-memory/Cargo.toml index cfb8136029..178c434a95 100644 --- a/crates/ruvector-agent-memory/Cargo.toml +++ b/crates/ruvector-agent-memory/Cargo.toml @@ -22,6 +22,10 @@ serde = { workspace = true } # ed25519-dalek + rand_core; the always-compiled `sha256` module needs no # feature. rvf-types = { version = "0.2", path = "../rvf/rvf-types", features = ["ed25519"] } +# ADR-347: hold an `ed25519_dalek::SigningKey` directly in `SignedWitnessSink` +# (no per-signature secret copies) and use `verify_strict`. Same crate and +# spec rvf-types already pulls in (2.2.x) — no new crate in the tree. +ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } # Existing ADR-194/047 proof-gated write machinery; adapts to the ledger's # ProofGate trait behind the `proof-gate` feature (see src/ledger.rs). ruvector-proof-gate = { path = "../ruvector-proof-gate", optional = true } diff --git a/crates/ruvector-agent-memory/examples/witness_signing_bench.rs b/crates/ruvector-agent-memory/examples/witness_signing_bench.rs new file mode 100644 index 0000000000..dc172a059c --- /dev/null +++ b/crates/ruvector-agent-memory/examples/witness_signing_bench.rs @@ -0,0 +1,224 @@ +//! Nightly research benchmark (2026-09-16, PIR follow-up to ADR-134 §9): +//! measures the real latency/throughput cost of closing the `WitnessSigner` +//! gap named in `ops.rs` and `ledger.rs`, comparing three variants: +//! +//! baseline — unsigned `MemoryWitnessLog` (today's shipped behavior) +//! candidate_a — `SignedWitnessSink` with `SigningStrategy::PerRecord` +//! candidate_b — `SignedWitnessSink` with `SigningStrategy::BatchTail` +//! at a few batch sizes +//! +//! Workload: N sequential `add` + `accept` pairs (2N witness records), +//! deterministic content, single-threaded, release build. Reports mean/p50/ +//! p95/p99 per-`add`+`accept` wall latency, total run time, signatures +//! produced, and a correctness gate (every produced signature verifies, +//! every constructed diligent-forgery attempt is rejected). +//! +//! Run: +//! cargo run --release -p ruvector-agent-memory --example witness_signing_bench + +use ruvector_agent_memory::{ + verify_signed_chain, AlwaysAdmitGate, MemoryWitnessLog, SignedAnchor, SignedWitnessSink, + SigningStrategy, TransactionalLedger, +}; +use rvf_types::ed25519::Ed25519Keypair; +use std::time::{Duration, Instant}; + +const N_ENTRIES: usize = 20_000; +const SECRET: [u8; 32] = [11u8; 32]; + +fn percentile(sorted_ns: &[u64], p: f64) -> f64 { + if sorted_ns.is_empty() { + return 0.0; + } + let idx = ((sorted_ns.len() - 1) as f64 * p).round() as usize; + sorted_ns[idx] as f64 / 1000.0 // microseconds +} + +struct RunResult { + label: &'static str, + total: Duration, + per_op_us: Vec, + signatures_produced: usize, + correctness_ok: bool, +} + +fn report(r: &RunResult) { + let mut sorted = r.per_op_us.clone(); + sorted.sort_unstable(); + let mean: f64 = sorted.iter().sum::() as f64 / sorted.len() as f64 / 1000.0; + let throughput = N_ENTRIES as f64 / r.total.as_secs_f64(); + println!( + "{:<14} total={:>9.3}ms mean={:>8.3}us p50={:>8.3}us p95={:>8.3}us p99={:>8.3}us \ + throughput={:>10.1} ops/s signatures={:>7} correctness={}", + r.label, + r.total.as_secs_f64() * 1000.0, + mean, + percentile(&sorted, 0.50), + percentile(&sorted, 0.95), + percentile(&sorted, 0.99), + throughput, + r.signatures_produced, + if r.correctness_ok { "PASS" } else { "FAIL" }, + ); +} + +fn run_baseline() -> RunResult { + let mut ledger = + TransactionalLedger::new(MemoryWitnessLog::default(), AlwaysAdmitGate::default()); + let mut per_op = Vec::with_capacity(N_ENTRIES); + let t0 = Instant::now(); + for i in 0..N_ENTRIES { + let op0 = Instant::now(); + let id = ledger + .add(format!("memory entry {i}"), &[], "bench", "synthetic") + .expect("add"); + ledger + .accept(id, "bench", "synthetic verification") + .expect("accept"); + per_op.push(op0.elapsed().as_nanos() as u64); + } + let total = t0.elapsed(); + let log = ledger.into_witness_sink(); + RunResult { + label: "baseline", + total, + per_op_us: per_op, + signatures_produced: 0, + correctness_ok: log.verify_chain(), + } +} + +fn run_signed(label: &'static str, strategy: SigningStrategy) -> RunResult { + let sink = SignedWitnessSink::from_keypair( + MemoryWitnessLog::default(), + &Ed25519Keypair::from_secret(&SECRET), + strategy, + ) + .expect("valid strategy"); + let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default()); + let mut per_op = Vec::with_capacity(N_ENTRIES); + let t0 = Instant::now(); + for i in 0..N_ENTRIES { + let op0 = Instant::now(); + let id = ledger + .add(format!("memory entry {i}"), &[], "bench", "synthetic") + .expect("add"); + ledger + .accept(id, "bench", "synthetic verification") + .expect("accept"); + per_op.push(op0.elapsed().as_nanos() as u64); + } + let total = t0.elapsed(); + let mut sink = ledger.into_witness_sink(); + sink.seal(); + let pk = sink.public_key(); + let correctness_ok = + verify_signed_chain(sink.inner(), sink.spans(), &pk, &sink.anchor()).is_ok(); + RunResult { + label, + total, + per_op_us: per_op, + signatures_produced: sink.spans().len(), + correctness_ok, + } +} + +/// Construct a diligent, fully-recomputed forgery (see +/// `witness_signing::tests::diligent_forgery_defeats_chain_walk_alone_but_not_signatures`) +/// against a signed run and confirm it is rejected. Returns `true` iff the +/// forgery is correctly rejected (the desired, secure outcome). +fn diligent_forgery_is_rejected(strategy: SigningStrategy) -> bool { + let sink = SignedWitnessSink::from_keypair( + MemoryWitnessLog::default(), + &Ed25519Keypair::from_secret(&SECRET), + strategy, + ) + .expect("valid strategy"); + let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default()); + for i in 0..200 { + let id = ledger.add(format!("m{i}"), &[], "bench", "r").unwrap(); + ledger.accept(id, "bench", "r").unwrap(); + } + let mut sink = ledger.into_witness_sink(); + sink.seal(); + let pk = sink.public_key(); + + let mut forged = sink.inner().clone(); + let tamper_at = forged.records.len() / 2; + forged.records[tamper_at].payload ^= 0xDEAD_BEEF_u64; + let mut prev_hash = if tamper_at == 0 { + 0 + } else { + forged.records[tamper_at - 1].chain_hash() + }; + for r in forged.records.iter_mut().skip(tamper_at) { + r.prev_hash = prev_hash; + r.record_hash = r.compute_record_hash(); + prev_hash = r.chain_hash(); + } + forged.committed_head = prev_hash; + forged.committed_count = forged.records.len() as u64; + + let chain_walk_alone_passes = forged.verify_chain(); + let signed_check_passes = + verify_signed_chain(&forged, sink.spans(), &pk, &SignedAnchor::genesis()).is_ok(); + // The whole point of signing: the unsigned chain walk is fooled, the + // signed check is not. + chain_walk_alone_passes && !signed_check_passes +} + +fn main() { + println!("ruvector-agent-memory witness signing benchmark"); + println!("N_ENTRIES={N_ENTRIES} (each = 1 add + 1 accept = 2 witness records)\n"); + + let baseline = run_baseline(); + let per_record = run_signed("candidate_a", SigningStrategy::PerRecord); + let batch_16 = run_signed( + "candidate_b16", + SigningStrategy::BatchTail { batch_size: 16 }, + ); + let batch_64 = run_signed( + "candidate_b64", + SigningStrategy::BatchTail { batch_size: 64 }, + ); + let batch_256 = run_signed( + "candidate_b256", + SigningStrategy::BatchTail { batch_size: 256 }, + ); + + report(&baseline); + report(&per_record); + report(&batch_16); + report(&batch_64); + report(&batch_256); + + println!("\nDiligent-forgery rejection (chain-walk-alone fooled, signed check must reject):"); + for (label, strategy) in [ + ("candidate_a", SigningStrategy::PerRecord), + ( + "candidate_b64", + SigningStrategy::BatchTail { batch_size: 64 }, + ), + ] { + let ok = diligent_forgery_is_rejected(strategy); + println!( + " {label:<16} forgery_rejected={}", + if ok { "PASS" } else { "FAIL" } + ); + } + + let per_record_mean: f64 = + per_record.per_op_us.iter().sum::() as f64 / per_record.per_op_us.len() as f64; + let batch64_mean: f64 = + batch_64.per_op_us.iter().sum::() as f64 / batch_64.per_op_us.len() as f64; + println!( + "\nAmortization: candidate_a mean/op = {:.1}ns, candidate_b64 mean/op = {:.1}ns, ratio = {:.2}x", + per_record_mean, + batch64_mean, + per_record_mean / batch64_mean.max(1.0) + ); + println!( + "Signature count: candidate_a={} candidate_b64={} (expected ratio ~{}x)", + per_record.signatures_produced, batch_64.signatures_produced, 64 + ); +} diff --git a/crates/ruvector-agent-memory/src/ledger.rs b/crates/ruvector-agent-memory/src/ledger.rs index 5293b201b4..1d948407cb 100644 --- a/crates/ruvector-agent-memory/src/ledger.rs +++ b/crates/ruvector-agent-memory/src/ledger.rs @@ -525,6 +525,14 @@ impl TransactionalLedger { &self.sink } + /// Consume the ledger and recover ownership of its witness sink, e.g. + /// to call `SignedWitnessSink::flush` and inspect signed spans once a + /// run is done. Ledger state (`entries`, `history`) is discarded; the + /// sink already holds everything durable. + pub fn into_witness_sink(self) -> S { + self.sink + } + /// Read access to the acceptance gate (e.g. for offline receipt or /// chain-integrity verification). pub fn proof_gate(&self) -> &G { diff --git a/crates/ruvector-agent-memory/src/lib.rs b/crates/ruvector-agent-memory/src/lib.rs index 63a12a2a0a..d1b27b32f7 100644 --- a/crates/ruvector-agent-memory/src/lib.rs +++ b/crates/ruvector-agent-memory/src/lib.rs @@ -57,6 +57,7 @@ pub mod memory; pub mod observation; pub mod ops; pub mod scoring; +pub mod witness_signing; pub mod witnessed_compaction; pub use arbitration::{ @@ -87,6 +88,10 @@ pub use ops::{ MemoryWitnessLog, NoopWitnessSink, TransitionKind, TransitionRecord, WitnessSink, }; pub use scoring::{coherence_score, cosine_sim, normalize}; +pub use witness_signing::{ + verify_signed_chain, SignPurpose, SignedAnchor, SignedChainError, SignedChainReport, + SignedSpan, SignedWitnessSink, SigningStrategy, WitnessSignerError, +}; pub use witnessed_compaction::{compact_witnessed, EvictionWitnessChain}; /// Compact `store` in-place using `policy`, retaining `target_size` entries. diff --git a/crates/ruvector-agent-memory/src/witness_signing.rs b/crates/ruvector-agent-memory/src/witness_signing.rs new file mode 100644 index 0000000000..9eb3b41f82 --- /dev/null +++ b/crates/ruvector-agent-memory/src/witness_signing.rs @@ -0,0 +1,487 @@ +//! Ed25519 signing for the TARL witness chain — the ADR-134 §9 +//! `WitnessSigner` follow-up gate named by [`crate::ops`]'s tamper-evidence +//! note and [`crate::ledger`]'s WP8 comment (ADR-347). +//! +//! ## What is signed +//! +//! Records are grouped into *spans* of consecutive sequence numbers. For +//! each span the signer computes +//! +//! ```text +//! records_digest = SHA-256(RECORDS_TAG || rec[from].to_bytes() || … || rec[to].to_bytes()) +//! message = MSG_TAG || purpose || from || to || prev_link || records_digest +//! link = SHA-256(message) // prev_link of the next span +//! signature = Ed25519(message) +//! ``` +//! +//! Every record's full canonical 64-byte encoding is inside a SHA-256 +//! preimage, so the per-record keyless FNV-1a `record_hash` / `chain_hash` +//! are NOT load-bearing for signed verification: forging a record means +//! finding a SHA-256 collision or an Ed25519 forgery, not a 64-bit FNV +//! collision. Each span also commits to the previous span's `link` +//! (`[0; 32]` at genesis), so spans form their own SHA-256 chain and cannot +//! be reordered, dropped from the middle, or spliced in from another log +//! signed with the same key. +//! +//! ## What [`verify_signed_chain`] guarantees +//! +//! Given a trusted public key and a trusted [`SignedAnchor`], `Ok(_)` means: +//! +//! 1. **Coverage.** The spans tile the log exactly: the first span starts +//! at sequence 0, each next span starts one past the previous span's +//! end, the last span ends at the newest record, and every record's +//! `sequence` equals its index. An empty or partial span list never +//! verifies a non-empty log. +//! 2. **Integrity.** Every record's full 64 bytes match what the key holder +//! signed, and the span order is the order it signed them in. +//! 3. **No unsigned tail.** A record not yet covered by a closed span makes +//! verification fail with [`SignedChainError::UnsignedTail`] — that +//! error is only returned after the signed prefix has fully verified, +//! so it precisely reports "prefix authentic, `unsigned` newest records +//! unauthenticated". Under [`SigningStrategy::BatchTail`] callers must +//! [`SignedWitnessSink::seal`] before verifying. +//! 4. **Rollback floor.** The log is at least as long as the anchor and +//! the span ending at `anchor.record_count - 1` has exactly the anchor's +//! chained digest. Truncating the log together with its span list below +//! the anchor fails with [`SignedChainError::AnchorMismatch`]. +//! +//! ## Limits (not guaranteed) +//! +//! - **Rollback above the anchor.** Records appended after the anchor was +//! captured can be truncated (log + spans together) without detection. +//! Callers must persist [`SignedChainReport::head`] (or +//! [`SignedWitnessSink::anchor`]) out-of-band after every verified run and +//! pass it as the next run's anchor. [`SignedAnchor::genesis`] disables +//! rollback protection entirely. +//! - **Crash before seal.** Pending `BatchTail` state is in memory only; a +//! crash leaves the unsealed tail permanently unsigned, and only a key +//! holder can re-sign it. Verification fails closed in that case. +//! - **Key compromise / key management** (generation, rotation, storage, +//! revocation) is out of scope. Anyone holding the signing key can sign +//! an arbitrary alternative history. +//! - `evidence_grade` is not part of `to_bytes()`; it is bound to the +//! hashed `flags` nibble only by [`MemoryWitnessLog::verify_chain`], which +//! is why verification still runs the unsigned chain walk first. + +use crate::ops::{LedgerError, LedgerWitnessRecord, MemoryWitnessLog, WitnessSink}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use rvf_types::ed25519::Ed25519Keypair; +use rvf_types::sha256::{sha256, Sha256}; + +const MSG_TAG: &[u8] = b"ruvector-agent-memory:witness-signer:v2:span\0"; +const RECORDS_TAG: &[u8] = b"ruvector-agent-memory:witness-signer:v2:records\0"; +/// `purpose` (1) + `from`/`to` (2 × 8 LE) + `prev_link` (32) + `records_digest` (32). +const MESSAGE_LEN: usize = MSG_TAG.len() + 1 + 16 + 32 + 32; + +/// Distinguishes a per-record signature from a batch-tail signature so a +/// signature produced for one purpose can never be replayed as the other. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum SignPurpose { + PerRecord = 1, + BatchTail = 2, +} + +/// One Ed25519-signed statement: "the witness records with sequence numbers +/// `[covers_from_seq, covers_to_seq]` have SHA-256 digest `records_digest`, +/// and they directly follow the span whose chained link is `prev_link`". +/// `prev_link` is not stored: the verifier derives it from the preceding +/// span, which is what makes the span list a chain. +#[derive(Clone, Copy, Debug)] +pub struct SignedSpan { + pub purpose: SignPurpose, + pub covers_from_seq: u64, + pub covers_to_seq: u64, + pub records_digest: [u8; 32], + pub signature: [u8; 64], +} + +impl SignedSpan { + fn message( + purpose: SignPurpose, + from: u64, + to: u64, + prev_link: &[u8; 32], + records_digest: &[u8; 32], + ) -> [u8; MESSAGE_LEN] { + let mut m = [0u8; MESSAGE_LEN]; + let mut off = MSG_TAG.len(); + m[..off].copy_from_slice(MSG_TAG); + m[off] = purpose as u8; + off += 1; + m[off..off + 8].copy_from_slice(&from.to_le_bytes()); + off += 8; + m[off..off + 8].copy_from_slice(&to.to_le_bytes()); + off += 8; + m[off..off + 32].copy_from_slice(prev_link); + off += 32; + m[off..off + 32].copy_from_slice(records_digest); + m + } +} + +/// Streaming SHA-256 over the full 64-byte encodings of a span's records. +struct RecordsHasher(Sha256); + +impl RecordsHasher { + fn new() -> Self { + let mut h = Sha256::new(); + h.update(RECORDS_TAG); + Self(h) + } + fn push(&mut self, r: &LedgerWitnessRecord) { + self.0.update(&r.to_bytes()); + } + fn finish(self) -> [u8; 32] { + self.0.finalize() + } +} + +/// A trusted checkpoint of the signed chain: the number of records covered +/// and the chained SHA-256 link of the span ending at `record_count - 1`. +/// Persist it out-of-band (it is the signed analogue of +/// [`MemoryWitnessLog::head_commitment`]) and pass it back to +/// [`verify_signed_chain`] to detect rollback below it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SignedAnchor { + pub record_count: u64, + pub head_digest: [u8; 32], +} + +impl SignedAnchor { + /// The empty-chain anchor. Passing it to [`verify_signed_chain`] + /// explicitly opts OUT of rollback detection. + pub const fn genesis() -> Self { + Self { + record_count: 0, + head_digest: [0u8; 32], + } + } +} + +/// Which records get their own signature vs. share an amortized one. +#[derive(Clone, Copy, Debug)] +pub enum SigningStrategy { + PerRecord, + /// Sign once every `batch_size` records (and on [`SignedWitnessSink::seal`]). + /// `batch_size` must be at least 1. + BatchTail { + batch_size: usize, + }, +} + +/// Construction errors for [`SignedWitnessSink`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WitnessSignerError { + ZeroBatchSize, +} + +impl std::fmt::Display for WitnessSignerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ZeroBatchSize => write!(f, "BatchTail batch_size must be at least 1"), + } + } +} + +impl std::error::Error for WitnessSignerError {} + +struct PendingSpan { + from: u64, + to: u64, + count: usize, + hasher: RecordsHasher, +} + +/// A [`WitnessSink`] decorator that signs spans of the records it forwards +/// to an inner sink. The inner sink commits first, so a refused batch is +/// never signed; a batch whose sequence numbers do not continue the signed +/// chain contiguously is refused before reaching the inner sink. +pub struct SignedWitnessSink { + inner: S, + signing_key: SigningKey, + strategy: SigningStrategy, + next_seq: u64, + pending: Option, + head: SignedAnchor, + spans: Vec, +} + +impl SignedWitnessSink { + pub fn new( + inner: S, + signing_key: SigningKey, + strategy: SigningStrategy, + ) -> Result { + if let SigningStrategy::BatchTail { batch_size: 0 } = strategy { + return Err(WitnessSignerError::ZeroBatchSize); + } + Ok(Self { + inner, + signing_key, + strategy, + next_seq: 0, + pending: None, + head: SignedAnchor::genesis(), + spans: Vec::new(), + }) + } + + /// Convenience constructor from an `rvf-types` keypair; copies the + /// secret out exactly once (the sink then holds only a `SigningKey`). + pub fn from_keypair( + inner: S, + keypair: &Ed25519Keypair, + strategy: SigningStrategy, + ) -> Result { + let mut secret = keypair.secret_key(); + let signing_key = SigningKey::from_bytes(&secret); + secret.fill(0); + std::hint::black_box(&secret); + Self::new(inner, signing_key, strategy) + } + + pub fn public_key(&self) -> [u8; 32] { + self.signing_key.verifying_key().to_bytes() + } + + /// Every span signed so far, in emission order. + pub fn spans(&self) -> &[SignedSpan] { + &self.spans + } + + pub fn inner(&self) -> &S { + &self.inner + } + + /// The signed head: records covered by closed spans and the newest + /// span's chained link. Persist it out-of-band as the next anchor. + pub fn anchor(&self) -> SignedAnchor { + self.head + } + + /// Records forwarded to the inner sink but not yet covered by a closed + /// span (always 0 under `PerRecord`). + pub fn unsigned_pending(&self) -> usize { + self.pending.as_ref().map_or(0, |p| p.count) + } + + fn close(&mut self, purpose: SignPurpose, from: u64, to: u64, records_digest: [u8; 32]) { + let msg = SignedSpan::message(purpose, from, to, &self.head.head_digest, &records_digest); + let signature = self.signing_key.sign(&msg).to_bytes(); + self.spans.push(SignedSpan { + purpose, + covers_from_seq: from, + covers_to_seq: to, + records_digest, + signature, + }); + self.head = SignedAnchor { + record_count: to + 1, + head_digest: sha256(&msg), + }; + } + + /// Sign whatever `BatchTail` span is still open. Must be called before + /// verification (e.g. at shutdown); a no-op when nothing is pending. + pub fn seal(&mut self) { + if let Some(p) = self.pending.take() { + self.close(SignPurpose::BatchTail, p.from, p.to, p.hasher.finish()); + } + } +} + +impl WitnessSink for SignedWitnessSink { + fn emit_batch(&mut self, records: &[LedgerWitnessRecord]) -> Result<(), LedgerError> { + for (i, r) in records.iter().enumerate() { + let expected = self.next_seq + i as u64; + if r.sequence != expected { + return Err(LedgerError::WitnessRejected(format!( + "witness signer: expected sequence {expected}, got {}", + r.sequence + ))); + } + } + // Witness-first: the inner sink commits before anything is signed. + self.inner.emit_batch(records)?; + self.next_seq += records.len() as u64; + match self.strategy { + SigningStrategy::PerRecord => { + for r in records { + let mut h = RecordsHasher::new(); + h.push(r); + self.close(SignPurpose::PerRecord, r.sequence, r.sequence, h.finish()); + } + } + SigningStrategy::BatchTail { batch_size } => { + for r in records { + let p = self.pending.get_or_insert_with(|| PendingSpan { + from: r.sequence, + to: r.sequence, + count: 0, + hasher: RecordsHasher::new(), + }); + p.hasher.push(r); + p.to = r.sequence; + p.count += 1; + if p.count >= batch_size { + self.seal(); + } + } + } + } + Ok(()) + } +} + +/// Why a signed witness log failed verification. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SignedChainError { + InvalidPublicKey, + /// The unsigned FNV-1a chain walk / head commitment / evidence-grade + /// binding ([`MemoryWitnessLog::verify_chain`]) failed. + ChainWalkFailed, + /// The record at `index` carries a different `sequence`. + SequenceMismatch { + index: u64, + found: u64, + }, + /// Span `span_index` does not start where the previous one ended + /// (or the first span does not start at 0). + CoverageGap { + span_index: usize, + expected_from: u64, + }, + /// Span `span_index` is inverted, or a `PerRecord` span covers >1 record. + MalformedSpan { + span_index: usize, + }, + /// Span `span_index` covers records the log does not contain. + SpanBeyondLog { + span_index: usize, + log_len: u64, + }, + /// The log's records in span `span_index` do not hash to the signed digest. + RecordsMismatch { + span_index: usize, + }, + /// Span `span_index`'s Ed25519 signature (strict verification) failed. + BadSignature { + span_index: usize, + }, + /// No span boundary at the anchor, or its chained digest differs — + /// a rollback below the anchor, or an anchor from another chain. + AnchorMismatch, + /// The signed prefix (`signed` records) verified, but the newest + /// `unsigned` records are not covered by any span and are NOT + /// authenticated. + UnsignedTail { + signed: u64, + unsigned: u64, + }, +} + +impl std::fmt::Display for SignedChainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "signed witness chain verification failed: {self:?}") + } +} + +impl std::error::Error for SignedChainError {} + +/// Result of a successful [`verify_signed_chain`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SignedChainReport { + pub records_verified: u64, + pub spans_verified: usize, + /// The verified signed head; persist it as the next anchor. + pub head: SignedAnchor, +} + +/// Verify a signed witness log against a trusted public key and a trusted +/// anchor. See the module docs for exactly what `Ok` does and does not +/// guarantee. +pub fn verify_signed_chain( + log: &MemoryWitnessLog, + spans: &[SignedSpan], + public_key: &[u8; 32], + anchor: &SignedAnchor, +) -> Result { + use SignedChainError as E; + let vk = VerifyingKey::from_bytes(public_key).map_err(|_| E::InvalidPublicKey)?; + if !log.verify_chain() { + return Err(E::ChainWalkFailed); + } + for (i, r) in log.records.iter().enumerate() { + if r.sequence != i as u64 { + return Err(E::SequenceMismatch { + index: i as u64, + found: r.sequence, + }); + } + } + let log_len = log.records.len() as u64; + let mut prev_link = [0u8; 32]; + let mut next = 0u64; + let mut anchor_ok = anchor.record_count == 0 && anchor.head_digest == [0u8; 32]; + for (k, span) in spans.iter().enumerate() { + let (from, to) = (span.covers_from_seq, span.covers_to_seq); + if from != next { + return Err(E::CoverageGap { + span_index: k, + expected_from: next, + }); + } + if to < from || (span.purpose == SignPurpose::PerRecord && to != from) { + return Err(E::MalformedSpan { span_index: k }); + } + if to >= log_len { + return Err(E::SpanBeyondLog { + span_index: k, + log_len, + }); + } + let mut h = RecordsHasher::new(); + for r in &log.records[from as usize..=to as usize] { + h.push(r); + } + let digest = h.finish(); + if digest != span.records_digest { + return Err(E::RecordsMismatch { span_index: k }); + } + let msg = SignedSpan::message(span.purpose, from, to, &prev_link, &digest); + let sig = Signature::from_bytes(&span.signature); + if vk.verify_strict(&msg, &sig).is_err() { + return Err(E::BadSignature { span_index: k }); + } + prev_link = sha256(&msg); + next = to + 1; + if next == anchor.record_count { + if prev_link != anchor.head_digest { + return Err(E::AnchorMismatch); + } + anchor_ok = true; + } + } + if !anchor_ok { + return Err(E::AnchorMismatch); + } + if next < log_len { + return Err(E::UnsignedTail { + signed: next, + unsigned: log_len - next, + }); + } + Ok(SignedChainReport { + records_verified: log_len, + spans_verified: spans.len(), + head: SignedAnchor { + record_count: next, + head_digest: prev_link, + }, + }) +} + +#[cfg(test)] +#[path = "witness_signing_tests.rs"] +mod tests; diff --git a/crates/ruvector-agent-memory/src/witness_signing_tests.rs b/crates/ruvector-agent-memory/src/witness_signing_tests.rs new file mode 100644 index 0000000000..708c2499ba --- /dev/null +++ b/crates/ruvector-agent-memory/src/witness_signing_tests.rs @@ -0,0 +1,381 @@ +use super::*; +use crate::ledger::{AlwaysAdmitGate, TransactionalLedger}; +use crate::ops::MemoryWitnessLog; + +const TEST_SECRET: [u8; 32] = [7u8; 32]; +const GENESIS: SignedAnchor = SignedAnchor::genesis(); + +fn keypair() -> Ed25519Keypair { + Ed25519Keypair::from_secret(&TEST_SECRET) +} + +fn sink_for(strategy: SigningStrategy) -> SignedWitnessSink { + SignedWitnessSink::from_keypair(MemoryWitnessLog::default(), &keypair(), strategy).unwrap() +} + +/// Drive `n` add+accept pairs through a ledger (2 witness records each). +fn run( + sink: SignedWitnessSink, + tag: &str, + n: usize, +) -> SignedWitnessSink { + let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default()); + for i in 0..n { + let id = ledger.add(format!("{tag} {i}"), &[], "actor", "r").unwrap(); + ledger.accept(id, "actor", "verified").unwrap(); + } + ledger.into_witness_sink() +} + +fn populated(strategy: SigningStrategy, n: usize) -> SignedWitnessSink { + let mut sink = run(sink_for(strategy), "memory", n); + sink.seal(); + sink +} + +const STRATEGIES: [SigningStrategy; 3] = [ + SigningStrategy::PerRecord, + SigningStrategy::BatchTail { batch_size: 4 }, + SigningStrategy::BatchTail { batch_size: 5 }, +]; + +/// Diligent adversary: recompute the whole FNV chain from `at` onward and +/// fix the unsigned head commitment, so `verify_chain` passes. +fn rehash_from(log: &mut MemoryWitnessLog, at: usize) { + let mut prev = if at == 0 { + 0 + } else { + log.records[at - 1].chain_hash() + }; + for r in log.records.iter_mut().skip(at) { + r.prev_hash = prev; + r.record_hash = r.compute_record_hash(); + prev = r.chain_hash(); + } + log.committed_count = log.records.len() as u64; + log.committed_head = prev; +} + +fn truncate(log: &mut MemoryWitnessLog, len: usize) { + log.records.truncate(len); + log.committed_count = len as u64; + log.committed_head = log.records.last().map_or(0, |r| r.chain_hash()); +} + +fn verify( + sink: &SignedWitnessSink, + log: &MemoryWitnessLog, + spans: &[SignedSpan], + anchor: &SignedAnchor, +) -> Result { + verify_signed_chain(log, spans, &sink.public_key(), anchor) +} + +// ---------------------------------------------------------------- positive + +#[test] +fn honest_chain_verifies_under_every_strategy() { + for s in STRATEGIES { + let sink = populated(s, 12); + let anchor = sink.anchor(); + let report = verify(&sink, sink.inner(), sink.spans(), &anchor).unwrap(); + assert_eq!(report.records_verified, 24); + assert_eq!(report.spans_verified, sink.spans().len()); + assert_eq!(report.head, anchor); + assert_eq!(anchor.record_count, 24); + // Genesis anchor also accepts an honest chain. + assert!(verify(&sink, sink.inner(), sink.spans(), &GENESIS).is_ok()); + } +} + +#[test] +fn batch_tail_amortizes_signature_count() { + let per_record = populated(SigningStrategy::PerRecord, 23); + let batch = populated(SigningStrategy::BatchTail { batch_size: 5 }, 23); + assert_eq!(per_record.spans().len(), 46); + assert_eq!(batch.spans().len(), 10); // 9 full batches + 1 sealed partial +} + +#[test] +fn old_anchor_still_verifies_a_grown_log() { + for s in STRATEGIES { + let mut ledger = TransactionalLedger::new(sink_for(s), AlwaysAdmitGate::default()); + let mut old = SignedAnchor::genesis(); + for i in 0..17 { + if i == 10 { + // Last closed span; any pending BatchTail records stay unsigned. + old = ledger.witness_sink().anchor(); + assert!(old.record_count > 0); + } + let id = ledger.add(format!("m {i}"), &[], "a", "r").unwrap(); + ledger.accept(id, "a", "v").unwrap(); + } + let mut sink = ledger.into_witness_sink(); + sink.seal(); + let report = verify(&sink, sink.inner(), sink.spans(), &old).unwrap(); + assert_eq!(report.records_verified, 34); + assert_eq!(report.head, sink.anchor()); + } +} + +#[test] +fn empty_log_verifies_only_with_genesis_anchor() { + let sink = sink_for(SigningStrategy::PerRecord); + assert!(verify(&sink, sink.inner(), &[], &GENESIS).is_ok()); + let bogus = SignedAnchor { + record_count: 1, + head_digest: [1; 32], + }; + assert_eq!( + verify(&sink, sink.inner(), &[], &bogus), + Err(SignedChainError::AnchorMismatch) + ); +} + +// ---------------------------------------------------------------- negative + +#[test] +fn zero_batch_size_is_an_error_not_a_panic() { + let r = SignedWitnessSink::from_keypair( + MemoryWitnessLog::default(), + &keypair(), + SigningStrategy::BatchTail { batch_size: 0 }, + ); + assert_eq!(r.err(), Some(WitnessSignerError::ZeroBatchSize)); +} + +#[test] +fn wrong_public_key_fails() { + let sink = populated(SigningStrategy::PerRecord, 5); + let wrong = Ed25519Keypair::from_secret(&[9u8; 32]).public_key(); + assert_eq!( + verify_signed_chain(sink.inner(), sink.spans(), &wrong, &sink.anchor()), + Err(SignedChainError::BadSignature { span_index: 0 }) + ); +} + +#[test] +fn naive_tamper_fails_chain_walk() { + let sink = populated(SigningStrategy::PerRecord, 10); + let mut forged = sink.inner().clone(); + forged.records[3].payload ^= 1; + assert_eq!( + verify(&sink, &forged, sink.spans(), &sink.anchor()), + Err(SignedChainError::ChainWalkFailed) + ); +} + +#[test] +fn diligent_forgery_passes_chain_walk_but_not_signatures() { + for s in STRATEGIES { + let sink = populated(s, 16); + let mut forged = sink.inner().clone(); + forged.records[6].payload ^= 0xDEAD_BEEF; + rehash_from(&mut forged, 6); + assert!(forged.verify_chain(), "documented unsigned-chain gap"); + assert!(matches!( + verify(&sink, &forged, sink.spans(), &GENESIS), + Err(SignedChainError::RecordsMismatch { .. }) + )); + } +} + +/// PoC 1 (review finding 1): a consistently re-hashed forged log with an +/// EMPTY span list used to verify, because coverage was never checked. +#[test] +fn poc1_empty_span_list_on_forged_log_fails() { + for s in STRATEGIES { + let sink = populated(s, 16); + let mut forged = sink.inner().clone(); + forged.records[0].payload ^= 0xFFFF; + rehash_from(&mut forged, 0); + assert!(forged.verify_chain()); + let r = verify(&sink, &forged, &[], &GENESIS); + assert_eq!( + r, + Err(SignedChainError::UnsignedTail { + signed: 0, + unsigned: 32 + }) + ); + // With a real anchor the empty list is also an anchor failure. + assert_eq!( + verify(&sink, &forged, &[], &sink.anchor()), + Err(SignedChainError::AnchorMismatch) + ); + } +} + +/// PoC 1 variant: a partial span list (middle span dropped, forged records +/// only inside the dropped span) fails on coverage. +#[test] +fn poc1_partial_span_list_fails_on_coverage_gap() { + let sink = populated(SigningStrategy::BatchTail { batch_size: 4 }, 16); + let mut spans = sink.spans().to_vec(); + spans.remove(2); // records 8..=11 + let mut forged = sink.inner().clone(); + forged.records[9].payload ^= 0x55; + rehash_from(&mut forged, 9); + assert_eq!( + verify(&sink, &forged, &spans, &GENESIS), + Err(SignedChainError::CoverageGap { + span_index: 2, + expected_from: 8 + }) + ); +} + +/// PoC 2 (review finding 2): under BatchTail the unsealed tail used to be +/// silently accepted and forgeable. Now an unsealed tail never verifies as +/// authenticated — whether honest, forged, or partially truncated. +#[test] +fn poc2_unsigned_batch_tail_is_never_reported_as_verified() { + // 11 pairs = 22 records; batch 8 => 16 signed, 6 pending. + let sink = run( + sink_for(SigningStrategy::BatchTail { batch_size: 8 }), + "m", + 11, + ); + assert_eq!(sink.unsigned_pending(), 6); + let tail = SignedChainError::UnsignedTail { + signed: 16, + unsigned: 6, + }; + assert_eq!( + verify(&sink, sink.inner(), sink.spans(), &sink.anchor()), + Err(tail) + ); + + let mut forged = sink.inner().clone(); + forged.records[19].payload ^= 0xABCD; + rehash_from(&mut forged, 19); + assert!(forged.verify_chain()); + assert_eq!( + verify(&sink, &forged, sink.spans(), &sink.anchor()), + Err(tail) + ); + + let mut truncated = sink.inner().clone(); + truncate(&mut truncated, 20); + assert_eq!( + verify(&sink, &truncated, sink.spans(), &sink.anchor()), + Err(SignedChainError::UnsignedTail { + signed: 16, + unsigned: 4 + }) + ); +} + +/// PoC 2, sealed: once `seal()` signs the partial batch, forging or +/// truncating the former tail fails outright. +#[test] +fn poc2_sealed_tail_resists_forgery_and_truncation() { + let mut sink = run( + sink_for(SigningStrategy::BatchTail { batch_size: 8 }), + "m", + 11, + ); + sink.seal(); + assert_eq!(sink.unsigned_pending(), 0); + let anchor = sink.anchor(); + assert!(verify(&sink, sink.inner(), sink.spans(), &anchor).is_ok()); + + let mut forged = sink.inner().clone(); + forged.records[21].aux ^= 1; + rehash_from(&mut forged, 21); + assert!(matches!( + verify(&sink, &forged, sink.spans(), &anchor), + Err(SignedChainError::RecordsMismatch { span_index: 2 }) + )); + + let mut truncated = sink.inner().clone(); + truncate(&mut truncated, 20); + assert!(matches!( + verify(&sink, &truncated, sink.spans(), &anchor), + Err(SignedChainError::SpanBeyondLog { span_index: 2, .. }) + )); +} + +/// PoC 3 (review finding 3): dropping trailing spans AND truncating the log +/// to match is internally consistent. It is rejected against a persisted +/// anchor; with the genesis anchor it verifies (documented limit). +#[test] +fn poc3_rollback_of_log_and_spans_is_caught_by_anchor() { + for s in STRATEGIES { + let sink = populated(s, 16); + let anchor = sink.anchor(); + let mut spans = sink.spans().to_vec(); + spans.truncate(spans.len() / 2); + let keep = spans.last().unwrap().covers_to_seq as usize + 1; + let mut rolled = sink.inner().clone(); + truncate(&mut rolled, keep); + assert!(rolled.verify_chain()); + assert!( + verify(&sink, &rolled, &spans, &GENESIS).is_ok(), + "without an anchor, rollback is undetectable (documented)" + ); + assert_eq!( + verify(&sink, &rolled, &spans, &anchor), + Err(SignedChainError::AnchorMismatch) + ); + } +} + +#[test] +fn splicing_two_logs_signed_by_the_same_key_fails() { + for s in STRATEGIES { + let a = populated(s, 8); + let b = run(sink_for(s), "other", 8); + let mut b = b; + b.seal(); + let cut = a.spans()[1].covers_to_seq as usize + 1; + let mut spliced = a.inner().clone(); + spliced.records.truncate(cut); + spliced.records.extend_from_slice(&b.inner().records[cut..]); + rehash_from(&mut spliced, cut); + let mut spans = a.spans()[..2].to_vec(); + spans.extend( + b.spans() + .iter() + .skip_while(|sp| sp.covers_from_seq < cut as u64), + ); + assert!(verify(&a, &spliced, &spans, &GENESIS).is_err()); + // Even without re-hashing (records byte-identical to what B signed), + // B's spans are chained to B's history, not A's. + let mut raw = a.inner().clone(); + raw.records.truncate(cut); + raw.records.extend_from_slice(&b.inner().records[cut..]); + assert!(verify(&a, &raw, &spans, &GENESIS).is_err()); + } +} + +#[test] +fn reordered_spans_fail() { + let sink = populated(SigningStrategy::BatchTail { batch_size: 4 }, 8); + let mut spans = sink.spans().to_vec(); + spans.swap(0, 1); + assert!(matches!( + verify(&sink, sink.inner(), &spans, &GENESIS), + Err(SignedChainError::CoverageGap { span_index: 0, .. }) + )); +} + +#[test] +fn non_contiguous_sequence_is_refused_before_inner_commit() { + let mut sink = populated(SigningStrategy::PerRecord, 2); + let mut bogus = *sink.inner().records.last().unwrap(); + bogus.sequence += 5; + assert!(sink.emit_batch(&[bogus]).is_err()); + assert_eq!(sink.inner().records.len(), 4); + assert!(verify(&sink, sink.inner(), sink.spans(), &sink.anchor()).is_ok()); +} + +#[test] +fn second_ledger_restarting_at_sequence_zero_is_refused() { + let sink = populated(SigningStrategy::PerRecord, 3); + let mut ledger = TransactionalLedger::new(sink, AlwaysAdmitGate::default()); + assert!(ledger.add("restart".to_string(), &[], "a", "r").is_err()); + let sink = ledger.into_witness_sink(); + assert_eq!(sink.inner().records.len(), 6); + assert!(verify(&sink, sink.inner(), sink.spans(), &sink.anchor()).is_ok()); +} diff --git a/docs/adr/ADR-347-witness-signer-tarl-ledger.md b/docs/adr/ADR-347-witness-signer-tarl-ledger.md new file mode 100644 index 0000000000..5bb554b5a5 --- /dev/null +++ b/docs/adr/ADR-347-witness-signer-tarl-ledger.md @@ -0,0 +1,216 @@ +# ADR-347: Ed25519 `WitnessSigner` for the TARL Ledger's Witness Chain + +## Status + +Accepted. New, opt-in, non-default module +(`ruvector-agent-memory::witness_signing`). Revised 2026-09-26 after a +security review of PR #989 found four HIGH issues in the first design +(no coverage check, forgeable unsigned `BatchTail` tail, undetected +rollback, signatures over a 64-bit FNV-1a hash). This text describes the +revised design; the guarantees and limits below are the ones the code +actually enforces and tests. (Originally drafted as ADR-346; renumbered +because ADR-346 is allocated to the mincut partition ADR, PR #979.) + +## Context + +`ruvector-agent-memory`'s TARL ledger (`ledger.rs`, ADR-307) chains every +witness record (ADR-134 schema) with keyless FNV-1a. `ops.rs`'s +tamper-evidence note is explicit that this chain only detects accidental +corruption and naive edits: an adversary who can write the log can relink +it in one O(n) pass, and `ops.rs` estimates a targeted FNV-1a collision at +roughly 2^32 work. It names ADR-134's `WitnessSigner` (§9) as the follow-up +gate that must land before WP8 cross-repo anchoring. + +`ruvector-agent-memory` already depends on `rvf-types` with its `ed25519` +feature (ADR-320) and on `rvf-types::sha256`, so both primitives are +available without adding new crates to the tree. + +## Decision + +1. Add `witness_signing` with `SignedWitnessSink` (a + `WitnessSink` decorator), `SigningStrategy::{PerRecord, BatchTail { + batch_size }}`, `SignedSpan`, `SignPurpose`, `SignedAnchor`, + `verify_signed_chain`, and typed errors/report. +2. **What is signed.** Records are grouped into spans of consecutive + sequence numbers (one record per span for `PerRecord`, up to + `batch_size` for `BatchTail`). Per span: + + ```text + records_digest = SHA-256(RECORDS_TAG || rec[from].to_bytes() || … || rec[to].to_bytes()) + message = MSG_TAG || purpose || from || to || prev_link || records_digest + link = SHA-256(message) // prev_link of the next span; [0;32] at genesis + signature = Ed25519(signing_key, message) + ``` + + Both tags are `v2` domain-separation strings specific to this crate, + so no signature from the first (FNV-signing) design or from any other + `rvf-types` user can be replayed. Each record's full canonical 64-byte + encoding is inside a SHA-256 preimage, so the FNV-1a hashes carry no + security weight in signed verification. `prev_link` chains spans with + SHA-256, so spans cannot be reordered, dropped from the middle, or + spliced from another log signed with the same key. +3. **Coverage (fail closed).** `verify_signed_chain` requires the spans to + tile the log exactly: first span starts at sequence 0, each next span + starts at the previous `to + 1`, every record's `sequence` equals its + index, and nothing is left uncovered. +4. **Unsigned tail (fail closed, explicitly reported).** Records not yet + covered by a closed span make verification return + `Err(SignedChainError::UnsignedTail { signed, unsigned })`. This error + is only produced after the signed prefix fully verified, so it + precisely reports "prefix of `signed` records authentic, `unsigned` + newest records NOT authenticated". Under `BatchTail` the caller must + call `SignedWitnessSink::seal()` (signs the open partial batch) before + verifying. We chose fail-closed over a success-with-a-count report so a + caller that only checks `is_ok()` can never mistake unsigned records + for verified ones. +5. **Rollback anchor.** `verify_signed_chain` takes a required + `&SignedAnchor { record_count, head_digest }` — the signed analogue of + `MemoryWitnessLog::head_commitment`, exported by + `SignedWitnessSink::anchor()` and returned as + `SignedChainReport::head`. Verification requires a span boundary at + exactly `record_count - 1` whose chained `link` equals `head_digest`; + the log may extend beyond it (append-only growth). Truncating log and + span list together below the anchor fails with `AnchorMismatch`. + `SignedAnchor::genesis()` is the explicit opt-out. +6. **Key handling.** The sink holds an `ed25519_dalek::SigningKey` + (`new(inner, SigningKey, strategy)`; `from_keypair(&Ed25519Keypair)` + copies the secret once) instead of copying the secret per signature. + Verification parses the `VerifyingKey` once and uses `verify_strict` + locally; shared `rvf-types::ed25519_verify` is unchanged for its other + callers. `ed25519-dalek` becomes a direct dependency with the same + spec `rvf-types` already uses (2.2.x in the lockfile) — no new crate. +7. **Sink hygiene.** `new` returns `Err(WitnessSignerError::ZeroBatchSize)` + instead of panicking. `emit_batch` refuses (before forwarding to the + inner sink, preserving "no witness, no mutation") any batch whose + sequence numbers do not continue the signed chain contiguously — e.g. + a second `TransactionalLedger` restarting at sequence 0 over the same + sink. +8. `TransactionalLedger` gains `into_witness_sink(self) -> S` so a caller + can `seal()` and read spans after a run. +9. Not feature-gated; callers opt in by constructing a + `SignedWitnessSink`. Neither strategy is a default. + +## Guarantees + +Given a trusted public key and a trusted anchor, `Ok(report)` means: + +- every record in the log (all `report.records_verified` of them) is + byte-for-byte what the key holder signed, in the order it signed them; +- the log contains no record the key holder did not sign; +- the log is not shorter than the anchor and extends the anchored history; +- the unsigned chain walk (`MemoryWitnessLog::verify_chain`, which also + binds the serde `evidence_grade` to the hashed `flags` nibble — + `to_bytes()` excludes `evidence_grade`) passed. + +Security reduces to SHA-256 collision resistance and Ed25519 +(strict) unforgeability, given the signing key stays secret. + +## Limits (explicitly not guaranteed) + +- **Rollback above the anchor.** Records appended after the anchor was + captured can be truncated together with their spans undetectably. + Callers must persist `report.head` / `sink.anchor()` out-of-band after + each verified run and pass it next time. With `genesis()`, any + truncation to a span boundary verifies. +- **Crash before `seal()`.** Pending `BatchTail` state is in memory; a + crash leaves the tail permanently unsigned. Verification fails closed + (`UnsignedTail`); only a key holder can re-sign it. Deleting an unsigned + tail entirely yields a log that verifies as its signed prefix — unsigned + records were never authenticated, so their absence cannot be detected + without a newer anchor. +- **Unsigned-availability window.** Under `BatchTail`, up to + `batch_size - 1` committed records are unauthenticated until the batch + closes or `seal()` runs. No wall-clock fill timeout in this pass. +- **Key lifecycle** (generation, rotation, storage, revocation, + multi-issuer) is out of scope. A key holder can sign any history. +- **Only `MemoryWitnessLog`** is verifiable today; durable sinks need an + equivalent reader. + +## Evidence + +Tests (`src/witness_signing_tests.rs`, 17 tests) include the three review +PoCs as regressions that must fail verification: (1) empty or partial +span list over a consistently re-hashed forged log; (2) forged / truncated +unsigned `BatchTail` tail, both unsealed (`UnsignedTail`) and sealed +(`RecordsMismatch` / `SpanBeyondLog`); (3) trailing spans dropped with the +log truncated to match (`AnchorMismatch` against the real anchor; verifies +with `genesis()`, documenting the limit). Also: same-key splice, +reordered spans, wrong key, naive tamper, zero batch size, non-contiguous +sequence refusal, anchor-then-grow, positive round-trips under both +strategies. + +One `cargo run --release -p ruvector-agent-memory --example +witness_signing_bench` run of the revised design (N_ENTRIES=20,000, +40,000 witness records; single run on a shared workstation, so treat +figures as indicative): + +| Variant | Mean latency/op | Throughput | Signatures | Correctness | +|---|---|---|---|---| +| baseline (unsigned) | 2.18µs | 452,099 ops/s | 0 | PASS | +| `PerRecord` | 87.54µs | 11,405 ops/s | 40,000 | PASS | +| `BatchTail{16}` | 6.49µs | 153,484 ops/s | 2,500 | PASS | +| `BatchTail{64}` | 3.29µs | 299,454 ops/s | 625 | PASS | +| `BatchTail{256}` | 1.92µs | 513,809 ops/s | 157 | PASS | + +Diligent-forgery rejection: PASS for both strategies. Amortization +`PerRecord` vs `BatchTail{64}`: 26.7x. The nightly README +(`docs/research/nightly/2026-09-16-witness-signer-agent-memory/`) records +the first design's run and its threat-model statements, which predate +this revision. + +## Consequences + +- Admission history can be signed at the source, beneath the already + signed `ruvector-retrieval-receipt` lineage (ADR-340/343) — subject to + the anchor-persistence and seal requirements above. +- Purely additive for existing callers; `MemoryWitnessLog`, + `NoopWitnessSink` and other `WitnessSink` implementors are untouched. + +## Alternatives Considered + +- **Sign the FNV-1a `chain_hash` (first design).** Rejected by review: a + 64-bit keyless hash is the thing signing must not depend on. +- **Report the unsigned tail as `Ok` with a count.** Rejected: a caller + checking only `is_ok()` would treat unauthenticated records as + verified. +- **Depend on `ruvector-retrieval-receipt`'s `Issuer`/`BatchAnchor`.** + Rejected: new dependency edge; its Merkle machinery targets unordered + batches, whereas these records are ordered and span-chained. +- **Wall-clock `BatchTail` fill timeout.** Deferred. + +## API Shape + +```rust +pub struct SignedSpan { pub purpose: SignPurpose, pub covers_from_seq: u64, + pub covers_to_seq: u64, pub records_digest: [u8; 32], pub signature: [u8; 64] } +pub struct SignedAnchor { pub record_count: u64, pub head_digest: [u8; 32] } +impl SignedAnchor { pub const fn genesis() -> Self; } + +impl SignedWitnessSink { + pub fn new(inner: S, key: SigningKey, s: SigningStrategy) -> Result; + pub fn from_keypair(inner: S, kp: &Ed25519Keypair, s: SigningStrategy) -> Result; + pub fn public_key(&self) -> [u8; 32]; + pub fn spans(&self) -> &[SignedSpan]; + pub fn anchor(&self) -> SignedAnchor; + pub fn unsigned_pending(&self) -> usize; + pub fn seal(&mut self); + pub fn inner(&self) -> &S; +} + +pub fn verify_signed_chain(log: &MemoryWitnessLog, spans: &[SignedSpan], + public_key: &[u8; 32], anchor: &SignedAnchor) + -> Result; +``` + +## Rollback + +Remove `witness_signing.rs`, its tests, the `lib.rs` wiring, the direct +`ed25519-dalek` dependency, and `into_witness_sink`. No other caller in +the repository depends on them. + +## Open Questions + +1. Should `BatchTail` gain a wall-clock fill timeout, and at what default? +2. Should the anchor be signed and published (e.g. to a transparency log) + so rollback above a locally persisted anchor becomes detectable? +3. Multi-issuer verification for federated / swarm agent memory. diff --git a/docs/adr/INDEX.md b/docs/adr/INDEX.md index 0fff866d23..798d610c43 100644 --- a/docs/adr/INDEX.md +++ b/docs/adr/INDEX.md @@ -344,6 +344,7 @@ | ADR-344 | ADR-344: Global-Min-Cut Gated Streaming Memory Admission | [`ADR-344-mincut-gated-streaming-memory-admission.md`](./ADR-344-mincut-gated-streaming-memory-admission.md) | 2026-09-05 | Proposed. Experimental crate (`ruvector-memory-admission`), not wired into | | | ADR-345 | ADR-345: Mincut-Gated Forgetting — Structural Eviction Signal and Eviction Witnesses for Agent Memory | [`ADR-345-mincut-gated-forgetting.md`](./ADR-345-mincut-gated-forgetting.md) | 2026-09-05 | Rejected (for production use as designed). Experimental crate addition | | | ADR-346 | ADR-346: Deterministic, Non-Degenerate Witness Partitions in `ruvector-mincut` | [`ADR-346-deterministic-mincut-witness-partition.md`](./ADR-346-deterministic-mincut-witness-partition.md) | 2026-09-26 | Accepted. Non-breaking bug fix, merged into `ruvector-mincut` directly (no | | +| ADR-347 | ADR-347: Ed25519 `WitnessSigner` for the TARL Ledger's Witness Chain | [`ADR-347-witness-signer-tarl-ledger.md`](./ADR-347-witness-signer-tarl-ledger.md) | 2026-09-16 | Accepted. New, opt-in, non-default module | | | ADR-349 | ADR-349: pi.ruv.io — Three Independent 504 Mechanisms, a UTF-8 Crash, and the Unauthenticated Write Path | [`ADR-349-ruvbrain-504-mechanisms-and-write-path-auth.md`](./ADR-349-ruvbrain-504-mechanisms-and-write-path-auth.md) | 2026-09-26 | Accepted (code landed) | | | ADR-CE-001 | ADR-CE-001: Sheaf Laplacian Defines Coherence Witness | [`coherence-engine/ADR-CE-001-sheaf-laplacian-coherence.md`](./coherence-engine/ADR-CE-001-sheaf-laplacian-coherence.md) | 2026-08-20 | Accepted | | | ADR-CE-002 | ADR-CE-002: Incremental Coherence Computation | [`coherence-engine/ADR-CE-002-incremental-computation.md`](./coherence-engine/ADR-CE-002-incremental-computation.md) | 2026-08-20 | Accepted | | diff --git a/docs/research/nightly/2026-09-16-witness-signer-agent-memory/README.md b/docs/research/nightly/2026-09-16-witness-signer-agent-memory/README.md new file mode 100644 index 0000000000..164f570d50 --- /dev/null +++ b/docs/research/nightly/2026-09-16-witness-signer-agent-memory/README.md @@ -0,0 +1,863 @@ +# Nightly Research — Closing the ADR-134 `WitnessSigner` Gap in the TARL Ledger + +## Summary + +`ruvector-agent-memory`'s TARL ledger (`ledger.rs`, ADR-307) chains every +witness record with keyless FNV-1a — tamper-evident against accidental +corruption only, by its own doc comment's admission, not against a +log-writing adversary. Two prior points in this codebase name the fix but +do not build it: `ops.rs`'s tamper-evidence note and `ledger.rs`'s WP8 +comment both call out an "ADR-134 §9 `WitnessSigner` follow-up gate", and +the 2026-09-05 nightly's Next Research item 4 asks for it explicitly +("wire an Ed25519 `WitnessSigner`... so eviction receipts are signed, not +just hash-chained"). + +This run builds it: `crates/ruvector-agent-memory/src/witness_signing.rs`, +a `WitnessSink` decorator that Ed25519-signs witness records either +per-record or via an amortized batch-tail strategy, reusing the crate's +*existing* `rvf-types` Ed25519 primitive (ADR-320) — zero new Cargo +dependency. It is benchmarked end to end against the unsigned baseline, +and its actual security value is isolated with a constructed "diligent +forgery" test: an attacker who edits one record and consistently +recomputes every downstream hash, producing a chain that passes the +existing unsigned `verify_chain()` walk. Both signing strategies reject +this forgery in every trial; the unsigned baseline (correctly, per its own +documentation) does not. + +## Update (2026-09-26): Post-Review Hardening + +PR #989's review (`ruvnet`) found four HIGH-severity gaps in the design +this document originally reported, plus several low-severity nits. All +four are fixed in `crates/ruvector-agent-memory/src/witness_signing.rs` +as of commit `01b939567`; the fix is summarized here so this document +stays accurate, and the rest of the document below is left as originally +written (including now-superseded numbers and claims) so the before/after +is visible rather than silently rewritten. + +1. **Coverage was not checked.** The original `verify_signed_chain` looped + over whatever `spans` slice it was given and never confirmed the spans + actually covered the whole log — an empty or partial span list still + returned `true` against a forged log with zero real signature coverage. + Fixed: spans must now tile the log exactly from sequence 0 to the + newest record, with no gaps, or verification fails + (`SignedChainError::CoverageGap`). +2. **The unsigned `BatchTail` tail was invisible to the check.** Records + not yet covered by a closed span were silently unauthenticated with no + signal. Fixed: `SignedChainError::UnsignedTail { signed, unsigned }`, + returned only after the signed prefix independently verified — callers + must call the renamed `SignedWitnessSink::seal` (was `flush`) before + verifying. +3. **Rollback (truncating the log and its span list together) was + undetected.** Nothing anchored how many records *should* exist. Fixed: + `SignedAnchor { record_count, head_digest }`, a signed analogue of + `MemoryWitnessLog::head_commitment`, persisted out-of-band and passed + into `verify_signed_chain`; a truncation below the anchor now fails + with `SignedChainError::AnchorMismatch`. +4. **The signed digest was the 64-bit FNV-1a `chain_hash`, not a + cryptographic hash.** This document's original "What This Run Does Not + Claim" section treated the crate's own "~2^32 work" FNV-1a + second-preimage estimate as an open question deliberately left + unverified. The review correctly identified that leaving it open was + itself the residual risk this design should not have accepted: FNV-1a's + `hash = (hash ^ byte) * PRIME mod 2^64` step is invertible (the prime is + odd, hence has a modular inverse), so an attacker with 8+ bytes of + freedom in a record (e.g. `payload`, `aux`) does not need a generic + 2^64 brute-force preimage search at all — the invertibility gives a + direct algebraic construction, plausibly around the estimated 2^32 + figure or cheaper depending on how many free bytes align, not a + brute-force floor. Fixed by removing FNV-1a from the signed path + entirely: each span now signs a SHA-256 digest (`rvf-types::sha256`) + over the full 64-byte encoding of every record it covers, chained + span-to-span via SHA-256 links. The per-record FNV-1a `chain_hash` is + no longer load-bearing for signed verification at all (only for the + pre-existing, independent, unsigned `verify_chain` walk, which still + runs first). This resolves Next Research item 2 below by design rather + than by measurement — the question "how cheap is an FNV-1a + second-preimage" no longer matters for this module's security, because + nothing security-relevant depends on the answer anymore. + +Also fixed (LOW): `SignedWitnessSink` now holds an `ed25519_dalek::SigningKey` +directly instead of copying the 32-byte secret out on every signature; +verification uses `verify_strict`; `SigningStrategy::BatchTail { batch_size: 0 }` +is a constructor error (`WitnessSignerError::ZeroBatchSize`) instead of a +panic; and `emit_batch` now refuses a non-contiguous sequence range before +it reaches the inner sink. + +`verify_signed_chain`'s signature changed from `-> bool` to +`-> Result`, and now takes an +additional `anchor: &SignedAnchor` parameter +(`SignedAnchor::genesis()` opts out of rollback protection for a +first run). Six new regression tests cover each finding directly +(`poc1_*`, `poc2_*`, `poc3_*` in `witness_signing_tests.rs`), alongside +splice/reorder/positive-path cases. All 45 tests in the crate pass; +`cargo clippy --all-targets` and `cargo fmt --check` are clean. The ADR +was renumbered `ADR-346` → `ADR-347` (346 was independently allocated to +the mincut ADR from PR #979) — see `docs/adr/ADR-347-witness-signer-tarl-ledger.md`. + +Re-running the benchmark below against the hardened implementation +(`cargo run --release -p ruvector-agent-memory --example witness_signing_bench`, +same `N_ENTRIES=20,000`, same deterministic key) gives: + +``` +baseline total= 24.562ms mean= 1.192us p50= 0.833us p95= 2.574us p99= 4.076us throughput= 814264.5 ops/s signatures= 0 correctness=PASS +candidate_a total= 1588.032ms mean= 79.348us p50= 73.839us p95= 98.675us p99= 128.847us throughput= 12594.2 ops/s signatures= 40000 correctness=PASS +candidate_b16 total= 140.925ms mean= 7.006us p50= 1.614us p95= 38.224us p99= 48.737us throughput= 141919.1 ops/s signatures= 2500 correctness=PASS +candidate_b64 total= 66.008ms mean= 3.263us p50= 1.501us p95= 3.605us p99= 38.838us throughput= 302994.6 ops/s signatures= 625 correctness=PASS +candidate_b256 total= 57.651ms mean= 2.844us p50= 1.445us p95= 3.177us p99= 21.642us throughput= 346915.6 ops/s signatures= 157 correctness=PASS + +Amortization: candidate_a mean/op = 79347.7ns, candidate_b64 mean/op = 3263.2ns, ratio = 24.32x +``` + +`candidate_a` (`PerRecord`) got *faster* (137.4µs → 79.3µs mean/op) despite +adding a SHA-256 hash per record, because per-signature `Ed25519` cost +(not hashing) always dominated, and the v2 message layout is a fixed-size +stack array either way; `candidate_b64` is similarly faster (3.77µs → +3.26µs). The amortization ratio (`PerRecord` vs `BatchTail{64}`) shifted +from 36.4x to 24.3x, still a large, real, measured advantage for batching. +All correctness gates still PASS, including the diligent-forgery +rejection test, now run against the SHA-256/anchor design rather than the +original FNV-based one. The acceptance result is unchanged: **ACCEPT.** + +## Abstract + +We add `SignedWitnessSink`, wrapping any inner +`WitnessSink` and signing either every record (`PerRecord`) or the tail +record of every `batch_size`-record run (`BatchTail`) with Ed25519, +domain-separated by purpose so a per-record signature can never be +replayed as a batch-tail signature or vice versa. `verify_signed_chain` +combines the existing unsigned chain walk with a per-span signature check +cross-bound to the log's *current* content at the covered sequence. A +20,000-entry (40,000-witness-record) synthetic workload measures real +`cargo run --release` latency/throughput for the unsigned baseline and +four signed configurations (`PerRecord`, `BatchTail` at 16/64/256), plus a +targeted correctness experiment constructing a fully self-consistent +forged chain and confirming both signing strategies reject it while the +unsigned chain walk alone does not. All results below are from a single +real execution; the exact command is given so any engineer can reproduce +them. + +## Hypothesis + +```text +Given the TARL ledger's existing FNV-1a witness chain and its own +documented ADR-134 WitnessSigner gap, + +when witness records are Ed25519-signed either per-record or via an +amortized batch-tail-only strategy (reusing rvf-types's existing Ed25519 +primitive, no new dependency), + +then batch-tail signing should reduce mean per-operation latency +substantially relative to per-record signing, while both strategies +retain identical detection of a "diligent" forgery (a fully +self-consistent, forward-recomputed chain edit) that the existing +unsigned chain-walk alone does not detect, + +subject to: zero false negatives (an undetected diligent forgery) and +zero false positives (an honest, unmodified chain rejected) across the +full test and benchmark matrix, with every latency/throughput number +coming from an actual `cargo run --release` execution, not an estimate. +``` + +## Why This Matters Now (2026) + +`ruvector-agent-memory`'s witness chain is explicitly named, in its own +source, as not yet load-bearing for anything beyond accidental-corruption +detection. The 2026-08-31 through 2026-09-05 nightly runs built an +entire signed-provenance lineage one layer up the stack +(`ruvector-retrieval-receipt`: Ed25519-signed retrieval receipts, batch +Merkle anchoring, batch-fill latency modeling, periodic state-root +anchoring) while the *admission* ledger directly beneath it — the thing +those receipts would ultimately need to cite as its source of truth for +"this memory was legitimately added" — stayed unsigned. This run closes +that specific, named, two-nightly-old gap rather than opening a new one. + +## Long Horizon Thesis + +**2036**: agent memory stores accumulate years of provenance; the +question "did this belief enter the store honestly, or was the log +rewritten after the fact by a compromised host" needs an answer that +doesn't depend on trusting whoever currently has filesystem access to the +log. A single, small, offline-verifiable Ed25519 public key is a much +smaller trust anchor to carry forward for a decade than "the current +value of an ever-growing `(count, hash)` pair" (today's documented +out-of-band recommendation for `MemoryWitnessLog::head_commitment`). + +**2046**: if agent memory becomes a portable, cross-system artifact (RVF's +stated direction), a signed witness chain is a *prerequisite* for +importing someone else's memory store with any confidence — an unsigned +FNV-1a chain proves internal self-consistency, not provenance. + +## RuVector Ecosystem Fit + +This connects five existing pieces without introducing a new one: + +1. **`ruvector-agent-memory`** — the TARL ledger and its witness chain + (ADR-307, ADR-134 schema) — the system modified. +2. **`rvf-types::ed25519`** (ADR-320) — the signing primitive reused + verbatim; already an unconditional dependency of this crate for + `AtomicObservation` signatures (`fusion.rs`, `observation.rs`). +3. **`ruvector-proof-gate` / `ledger.rs`'s `ProofGate`** (ADR-194/047) — + the sibling gate this ledger already wires for *acceptance*; witness + signing is the analogous close for *provenance*, not a replacement. +4. **`ruvector-retrieval-receipt`** (ADR-340/343/345 nightly lineage) — + the one layer up the stack that already solved this exact + per-record-vs-batch-amortization tradeoff for retrieval receipts; this + run deliberately reuses that lineage's *methodology* (compare + per-record vs. batch-amortized signing cost) without taking on that + crate as a dependency, since `ruvector-agent-memory` already has + everything it needs in `rvf-types`. +5. **`ruvector-mincut` / `graph_forget`** (ADR-345, 2026-09-05) — the + sibling `witnessed_compaction` module in this same crate emits + eviction witnesses through the identical `WitnessSink` trait this run + signs; `SignedWitnessSink` applies to eviction witnesses with no + additional code, since it wraps any `WitnessSink`. + +## MetaHarness Role + +`npx metaharness --help` (v0.4.16, freshly resolved from the npm +registry) is a real, installed scaffolding/orchestration CLI in this +environment — it generates new agent harness projects and offers +`score`/`analyze`/`genome`/`learn`/`avo`/`proxy` subcommands for +*external* repos, not an in-repo multi-agent research orchestrator for +this specific nightly workflow. `npx ruvector harness doctor --json` / +`status` resolve to no executable (`npm error could not determine +executable to run`) — no `ruvector harness` CLI is installed in this +checkout. Per this prompt's own instruction ("do not assume a package +exists solely because it appears in this prompt; verify first"), this run +does not fabricate MetaHarness role decomposition, Darwin generations, or +Flywheel gate calls that this environment cannot actually execute. The +role MetaHarness's prompt template assigns to "Goal Planner / SOTA +Researcher / RuVector Architect / ... / Evidence Judge" was instead +carried out by one Claude session across the sequential steps recorded in +this document and its commit history — an honest substitution, not a +simulated one. + +## Flywheel Role + +No `flywheel` CLI is installed (see above). This document, its ADR, and +the crate's own doc comments (which now say the gap is closed, with a +link to this run) serve as the durable record a future nightly run would +otherwise get from a Flywheel query — the same role prior nightly READMEs +play for topics like `2026-09-05-mincut-gated-forgetting`'s rejected +`graph_forget` hypothesis. + +## Darwin Role + +Not run. No `darwin` CLI is installed, and the implementation here has +exactly two hyperparameters worth exploring (batch size; which fields a +signature covers) — both swept directly and exhaustively in the benchmark +below (batch sizes 16/64/256) rather than through a bounded evolutionary +search. A real Darwin-style sweep would be a legitimate follow-up if +`batch_size` needs to be tuned per-deployment against a real workload's +arrival-rate distribution (see Next Research item 3). + +## Architecture + +```mermaid +flowchart TD + subgraph "TransactionalLedger (ledger.rs, unchanged)" + A["add / accept / ignore / revise / reject / defer"] + end + A -->|"emit_batch(&[LedgerWitnessRecord])"| S["SignedWitnessSink<S> (new)"] + S -->|"1. forward first (witness-first)"| I["inner: S: WitnessSink\n(e.g. MemoryWitnessLog)"] + S -->|"2. then sign"| P{"SigningStrategy"} + P -->|"PerRecord"| SIG1["sign chain_hash(record)\n1 signature / record"] + P -->|"BatchTail{batch_size}"| BUF["accumulate pending run"] + BUF -->|"run reaches batch_size, or flush()"| SIG2["sign chain_hash(tail record)\n1 signature / batch_size records"] + SIG1 --> SPANS["spans: Vec<SignedSpan>"] + SIG2 --> SPANS + SPANS -.->|"verify_signed_chain(log, spans, pubkey)"| V["1. inner.verify_chain()\n2. each span.verify(pubkey)\n3. span.chain_hash == log record's\n ACTUAL current chain_hash()"] + I -.->|"reference"| V +``` + +## Implementation + +New file: `crates/ruvector-agent-memory/src/witness_signing.rs` (full +source; see the crate for authoritative code). Key pieces: + +- `SignPurpose { PerRecord = 1, BatchTail = 2 }` — domain-separates the + two signature kinds so one can never be replayed as the other. +- `SignedSpan { purpose, covers_from_seq, covers_to_seq, chain_hash, + signature }` — one signed statement; `.verify(pubkey)` checks the + signature in isolation. +- `SigningStrategy::{PerRecord, BatchTail { batch_size }}`. +- `SignedWitnessSink` — implements `WitnessSink` itself + (so it composes transparently with `TransactionalLedger`), + forwards to the inner sink *before* signing (preserving "no witness, no + mutation" — a refused batch is never signed), and cannot itself fail + (`emit_batch`'s `Result` is never turned `Err` by this wrapper — signing + is deterministic local computation). +- `verify_signed_chain(log, spans, pubkey)` — the sole security-relevant + function. It does three things, in order: (1) run the existing + `MemoryWitnessLog::verify_chain()` unsigned walk; (2) verify each span's + Ed25519 signature; (3) **cross-check that each span's `chain_hash` + equals what the log's record at `covers_to_seq` hashes to *right now*** + — this third check is what actually detects a diligent forgery; without + it, a span's stored `chain_hash` field would just be an attacker's word + for what the tampered content used to hash to. + +`TransactionalLedger` gained one small accessor, `into_witness_sink(self) +-> S`, needed to recover the sink (and its `spans()`) after driving a +run — previously only a `&S` borrow was exposed. + +No Cargo.toml changes: `rvf-types` with the `ed25519` feature was already +an unconditional dependency of `ruvector-agent-memory` (for +`observation.rs`/`fusion.rs`'s `AtomicObservation` signatures, ADR-320), +so this module needed zero new dependencies. + +## Benchmark Methodology + +- Workload: `N_ENTRIES = 20,000` sequential `add` + `accept` pairs (2 + witness records each = 40,000 total), deterministic content + (`format!("memory entry {i}")`), single-threaded. +- Signing key: fixed `[11u8; 32]` secret (deterministic — "fix random + seeds"). +- Hardware/OS/toolchain: this session's container (`Linux + 6.18.44-fc-v33`); `cargo --version` / `rustc --version` as resolved by + the workspace toolchain at run time (see command output below). +- Build: `cargo run --release -p ruvector-agent-memory --example + witness_signing_bench` (release profile; warmup is inherent to running + 20,000 iterations before percentiles are computed — no separate + warmup phase was used since this benchmark measures amortized + steady-state cost, not cold-start). +- Per-operation latency is `add` + `accept` wall time around + `std::time::Instant`; mean/p50/p95/p99 computed over all 20,000 samples. +- Signature count and a `verify_signed_chain` correctness gate are + computed once at the end of each run (see source for exact assertions). +- A separate, smaller (200-entry) run constructs the diligent-forgery + scenario for `PerRecord` and `BatchTail{64}` and reports pass/fail. + +Reproduce exactly: + +```bash +cargo test -p ruvector-agent-memory --lib witness_signing +cargo run --release -p ruvector-agent-memory --example witness_signing_bench +``` + +## Benchmark Results (raw, from one `cargo run --release` execution) + +``` +ruvector-agent-memory witness signing benchmark +N_ENTRIES=20000 (each = 1 add + 1 accept = 2 witness records) + +baseline total= 24.994ms mean= 1.220us p50= 0.813us p95= 2.456us p99= 3.972us throughput= 800197.4 ops/s signatures= 0 correctness=PASS +candidate_a total= 2749.272ms mean= 137.423us p50= 130.546us p95= 158.842us p99= 188.425us throughput= 7274.7 ops/s signatures= 40000 correctness=PASS +candidate_b16 total= 193.788ms mean= 9.651us p50= 1.015us p95= 65.604us p99= 83.382us throughput= 103205.5 ops/s signatures= 2500 correctness=PASS +candidate_b64 total= 76.084ms mean= 3.774us p50= 0.973us p95= 3.015us p99= 65.929us throughput= 262867.7 ops/s signatures= 625 correctness=PASS +candidate_b256 total= 40.826ms mean= 2.011us p50= 0.959us p95= 2.512us p99= 7.074us throughput= 489882.3 ops/s signatures= 157 correctness=PASS + +Diligent-forgery rejection (chain-walk-alone fooled, signed check must reject): + candidate_a forgery_rejected=PASS + candidate_b64 forgery_rejected=PASS + +Amortization: candidate_a mean/op = 137423.2ns, candidate_b64 mean/op = 3773.9ns, ratio = 36.41x +Signature count: candidate_a=40000 candidate_b64=625 (expected ratio ~64x) +``` + +`cargo test -p ruvector-agent-memory --lib` (34 tests, including the 6 new +`witness_signing` tests): `34 passed; 0 failed`. + +## Acceptance Result + +| Gate | Threshold | Measured | Result | +|---|---|---|---| +| Honest-chain correctness (5 variants) | 5/5 `verify_signed_chain` = true | 5/5 | PASS | +| Diligent-forgery rejection | 2/2 tested strategies reject | 2/2 | PASS | +| Amortization (candidate_b64 vs candidate_a) | >= 5x lower mean latency | 36.4x lower | PASS | +| Signature-count exactness | `candidate_bN` spans = `40000/N` | 40000/64=625 (exact), 40000/16=2500 (exact) | PASS | +| No existing test regressed | 28 pre-existing crate tests still pass | 28/28 pass (34 total - 6 new) | PASS | + +**ACCEPT.** All mandatory gates pass on real, reproducible, `--release` +measured evidence. + +## Memory Math + +- `SignedSpan` is 1 (purpose) + 8 + 8 + 8 (u64 fields) + 64 (signature) = + 89 bytes, plus enum/struct padding (measured `size_of::()` + not separately instrumented in this pass — flagged as a Next Research + gap alongside the WASM binary-size question, following the same + deferred item this crate's sibling `ruvector-retrieval-receipt` nightly + runs have repeatedly flagged and not yet closed). +- `PerRecord` at N witness records: N × 89 bytes of signature state, on + top of the 40 bytes-per-record baseline `MemoryWitnessLog` already + retains — roughly 2.2x the unsigned log's memory footprint. +- `BatchTail{batch_size}` at N witness records: `ceil(N/batch_size)` × + 89 bytes — at `batch_size=64`, ~1.4 bytes/record amortized, i.e. + signature memory becomes negligible relative to the unsigned log itself. + +## Performance Math + +Per-signature cost dominates `PerRecord`'s per-op time: 137.4µs/op for 2 +records/op implies ~68.7µs per Ed25519 sign call in this environment — +consistent with published software Ed25519 signing costs in the tens of +microseconds on a general-purpose CPU core without hardware acceleration. +`BatchTail{64}`'s 3.77µs/op amortizes that same per-signature cost across +64 records (128 ledger ops), landing close to (but, per the p99 column, +not perfectly at) `baseline + (per_signature_cost / batch_size)` — the +gap is the batch-close bookkeeping plus one full-cost sign call landing on +whichever op happens to close the batch, visible as the elevated p95/p99 +relative to p50 in the `candidate_b*` rows (p50 tracks the +mostly-unsigned interior ops; p95/p99 catch the batch-closing op). + +## Failure Modes + +1. **`PerRecord` throughput collapse.** 7,275 ops/s vs. baseline's + 800,197 ops/s (a 110x drop) is a real, measured cost, not a rounding + artifact — `PerRecord` is not a drop-in replacement for the unsigned + sink on any workload where >10K witnessed transitions/sec matter. +2. **`BatchTail` availability window.** A record inside an unclosed batch + has *no* signature until the batch fills or `flush()` runs — identical + in kind to the fill-timeout tradeoff `ruvector-retrieval-receipt`'s + 2026-08-31/09-01 nightly runs already characterized for retrieval + receipts. This implementation does not include a wall-clock fill + timeout (fixed-size-only, matching `BatchFillPolicy::fixed_size` in + that lineage) — a deployment needing bounded worst-case signature + latency would need to add one (Next Research item 3). +3. **`BatchTail` blast radius.** If the signer crashes mid-batch, the + *entire* open batch is unsigned (not partially signed) — `PerRecord` + degrades one record at a time; `BatchTail` degrades in units of + `batch_size`. Not measured quantitatively in this pass (would require + fault injection); named here per Step 14's "may not hide a regression + behind one favorable metric." +4. **Not addressed at all: FNV-1a preimage resistance.** See "What This + Run Does Not Claim" below — the module's own doc comment is explicit + about this scope boundary. + +## What This Run Does Not Claim + +`ops.rs`'s existing doc comment estimates the unsigned chain's FNV-1a hash +is "second-preimage-able in ~2^32 work." This run's "diligent forgery" +test constructs a forgery that changes the target record's `chain_hash` +value (the common, no-special-effort case — an attacker who edits a +record and recomputes forward gets a *different* hash almost certainly, +not a matching one) and confirms signing catches exactly that case. It +does **not** attempt to construct, or estimate the true cost of, an +FNV-1a second preimage that reproduces an *identical* `chain_hash` after +a semantic edit — that would be a materially different (and, done +carelessly, easy to get wrong or overstate) piece of applied +cryptanalysis, and this run explicitly declines to guess at it rather +than risk reporting a fabricated or under-verified complexity figure. If +that specific 2^32 figure is accurate, it does not change this run's +conclusion (Ed25519 signing is unconditionally secure against forgery +under a secret key, independent of the inner hash's own weaknesses); it +would matter only for whether `verify_chain()` *alone*, unsigned, is +ever safe to rely on — which this crate's own documentation already +answers "no" to, independent of this run. + +## Rejected Alternatives + +- **Depend on `ruvector-retrieval-receipt`'s `Issuer`/`BatchAnchor` + directly**, rather than `rvf-types::ed25519`. Rejected: would add a + real new dependency edge for no benefit — `ruvector-agent-memory` + already has an Ed25519 primitive in its existing dependency tree + (`rvf-types`, ADR-320), and `BatchAnchor`'s Merkle-proof machinery + solves a different problem (random-access inclusion proofs into an + unordered batch) that this ledger's already-ordered, already-hash-chained + records don't need — the chain itself gives every record's inclusion + "proof" for free via `prev_hash`. +- **Sign `record_hash` (the 48-byte-input hash) instead of `chain_hash` + (the full 64-byte hash).** Rejected: `chain_hash` is the field that + actually propagates as `prev_hash` into the next record, so it's the + one binding a signature transitively into "everything downstream is + covered too" for `BatchTail`; signing `record_hash` would leave `aux` + (proof-gate receipt commitment data) and the record's own `prev_hash` + link outside the signed statement. +- **A wall-clock batch-fill timeout (à la `BatchFillPolicy::hybrid`) + reused from `ruvector-retrieval-receipt`.** Deferred, not rejected — + see Next Research item 3; out of scope for this pass, which fixes the + batch-size axis alone to keep the benchmark matrix and its acceptance + gates tractable in one run. + +## Security + +- Reuses `rvf-types::ed25519` (`ed25519-dalek` under the hood) verbatim — + no new cryptographic primitive introduced. +- Domain separation (`DOMAIN_TAG` + `SignPurpose`) prevents a signature + produced for one purpose (or one deployment of this crate, given the + tag is crate-specific) from being replayed as another. +- `verify_signed_chain`'s three-step check (chain walk, signature check, + cross-bind to current content) is the security-load-bearing function; + a caller that checks only `span.verify(pubkey)` without also + re-deriving `chain_hash` from the log's *current* record would be + trivially bypassable (an attacker could keep an old, honestly-signed + span and just claim it covers new, different content) — this is why + `SignedSpan::verify` is documented as insufficient in isolation. +- Signing key management (generation, rotation, secure storage) is out of + scope, matching the equivalent disclaimer in + `ruvector-retrieval-receipt::signing`. + +## Governance + +None beyond the existing "no witness, no mutation" invariant, which +`SignedWitnessSink::emit_batch` preserves by forwarding to the inner sink +*before* signing — a refused batch is never signed, matching the +crate-wide pattern. + +## MCP Implications + +Not exposed via MCP in this pass. A narrow future tool, +`agent_memory_verify_witness_chain(log, spans, public_key) -> bool`, +would be a legitimate read-only wrapper around `verify_signed_chain` with +no mutation authority — flagged as a possible follow-up, not built here +(Step 30 requires the analysis, not that every capability get a tool). + +## WASM Implications + +Not measured in this pass. `rvf-types`'s `ed25519` feature already +compiles for this crate's existing non-WASM targets; whether it is +WASM-compatible (via `ed25519-dalek`'s `wasm32` support) and what it +costs in binary size is an open question shared with the same deferred +item `ruvector-retrieval-receipt`'s ADR-340 nightly run already flagged +and has not yet closed — not re-measured here (Next Research item 4). + +## Edge Implications + +`PerRecord`'s ~69µs/signature cost is likely prohibitive on a constrained +edge core without hardware Ed25519 acceleration at any meaningful +witness-emission rate; `BatchTail` at a large batch size is the +edge-appropriate choice if bandwidth/storage to a durable log is cheap +but CPU is scarce — consistent with, not contradicting, the general +edge-deployment guidance already established by the +`ruvector-retrieval-receipt` signed-anchoring lineage. + +## RVF Implications + +A signed `SignedSpan` sequence is a natural candidate for inclusion in an +RVF portable cognitive package's provenance section: it lets an importer +verify a memory store's admission history against a single public key +without needing the exporting system online. Not implemented; flagged as +a real, concrete RVF integration path (state portability + signed +lineage, directly, from Step 27's checklist) for a future pass that +actually builds RVF export/import for `ruvector-agent-memory`. + +## RVM Implications + +Marginal. `verify_signed_chain` is a pure, side-effect-free verification +function — a natural fit for an RVM proof-gated read path if +`ruvector-agent-memory` ever runs inside an RVM coherence domain, but +nothing about this specific capability requires RVM enforcement today; no +forced integration is proposed (Step 28's explicit "do not force"). + +## ruFlo Implications + +A concrete workflow: a scheduled ruFlo job that periodically calls +`verify_signed_chain` against the production witness log and the +deployment's known public key, alerting on the first failure — the same +"automatic staleness-alerting" role the 2026-09-03 `state-root-anchoring` +nightly named for its own anchor log, applicable here without +modification since `verify_signed_chain` is already a pure function +suitable for such a job. + +## Practical Applications + +1. **User**: an operator running `ruvector-agent-memory` in production. + **Problem**: cannot currently prove a memory's admission history + wasn't rewritten after an incident. **Capability**: + `SignedWitnessSink` + `verify_signed_chain`. **Ecosystem**: none + beyond this crate. **Path**: wrap the existing sink at + `TransactionalLedger::new` call sites. **Value**: incident forensics + gain a cryptographic anchor instead of an unsigned log. **Risk**: key + management is the caller's responsibility (unaddressed here). + **Horizon**: immediate. +2. **User**: a compliance team auditing agent decisions. **Problem**: + needs to show which memories were accepted, by whom, and that the + record wasn't altered post hoc. **Capability**: signed `Accept` + witness spans. **Ecosystem**: `ledger.rs`'s `ProofGate`. **Path**: + already composable today, zero new code. **Value**: audit trail with a + single small trust anchor (one public key) instead of an ever-growing + one (`head_commitment`). **Risk**: none new. **Horizon**: immediate. +3. **User**: a multi-tenant agent platform. **Problem**: one tenant's + compromised host should not be able to silently rewrite that tenant's + memory history without detection by the platform operator holding the + public key. **Capability**: `verify_signed_chain` run out-of-band by + the platform, not the tenant. **Ecosystem**: `ruvector-agent-memory` + multi-tenant deployments. **Path**: platform holds public keys, + tenants hold secret keys, matches standard key-custody separation. + **Value**: tamper detection survives a fully compromised tenant host. + **Risk**: requires the platform to actually run verification, which + this module enables but does not schedule. **Horizon**: near-term. +4. **User**: `ruvector-retrieval-receipt` itself, one layer up. **Problem**: + its signed retrieval receipts currently cite ledger content that is + itself unsigned — a receipt can honestly attest to a query result over + dishonestly-admitted memories. **Capability**: this module closes that + specific gap at the source. **Ecosystem**: connects the two crates' + signing lineages. **Path**: no code change needed in + `ruvector-retrieval-receipt`; the admission side is simply now also + signed. **Value**: end-to-end provenance from admission through + retrieval. **Risk**: none new. **Horizon**: immediate (already true as + of this commit). +5. **User**: an agent memory compaction pipeline (`witnessed_compaction`, + ADR-345). **Problem**: eviction witnesses are FNV-1a-chained only, + same gap. **Capability**: `SignedWitnessSink` wraps any `WitnessSink`, + including the one `compact_witnessed` writes through. **Ecosystem**: + directly connects to the 2026-09-05 nightly's surviving contribution. + **Path**: wrap the sink passed to `compact_witnessed`. **Value**: + signed eviction receipts, the exact item ADR-345's Next Research + flagged. **Risk**: none new — additive. **Horizon**: immediate. +6. **User**: a security researcher validating this crate's own claims. + **Problem**: "tamper-evident against accidental corruption only" is an + assertion, not evidence. **Capability**: the diligent-forgery test in + `witness_signing.rs` and this benchmark's forgery-rejection check. + **Ecosystem**: general research/audit tooling. **Path**: `cargo test`. + **Value**: an executable, re-runnable demonstration rather than a + prose claim. **Risk**: none. **Horizon**: immediate. +7. **User**: an RVF export author (near-term roadmap). **Problem**: + exporting an agent memory store needs an importer-verifiable + provenance section. **Capability**: `SignedSpan` sequences are + directly embeddable. **Ecosystem**: RVF. **Path**: build RVF + export/import for this crate (not done here). **Value**: portable, + independently verifiable cognitive state. **Risk**: RVF integration + itself is unbuilt. **Horizon**: medium-term. +8. **User**: an edge deployment (Cognitum-class device) running agent + memory locally with periodic sync to a durable store. **Problem**: CPU + budget for cryptographic signing is scarce. **Capability**: + `BatchTail` at a large batch size, tuned to the device's actual + witness-emission rate. **Ecosystem**: edge / Cognitum. **Path**: pick + `batch_size` from the measured amortization curve above. **Value**: + tamper evidence at near-baseline throughput. **Risk**: larger + unsigned-availability window on a device more likely to lose power + mid-batch — a real, named tradeoff (Failure Modes item 2/3), not + hidden. **Horizon**: near-term. + +## Long Horizon Applications + +1. **Thesis**: agent memory as a legally / contractually admissible + record. **Required advances**: key custody standards, timestamping + authority integration. **RuVector role**: this module is the + substrate-level primitive such a system would build on. + **Why this experiment matters**: proves the primitive works and is + cheap enough (`BatchTail`) to run continuously. **Primary + uncertainty**: whether Ed25519 alone (vs. a timestamping/notary + service) suffices for legal admissibility. **Falsification path**: a + legal requirement for third-party timestamping would falsify + "sufficient on its own." +2. **Thesis**: cross-organization agent memory federation, where each + organization signs its own contributions to a shared graph. + **Required advances**: multi-issuer `verify_signed_chain` (this run's + version assumes one key). **RuVector role**: `fusion.rs`'s + cross-source `AtomicObservation` model already tags provenance by + source; this module's per-issuer signing is the missing enforcement + layer. **Why this experiment matters**: establishes the single-issuer + base case first. **Primary uncertainty**: multi-issuer key rotation + and revocation. **Falsification path**: if per-source signing proves + too expensive at federation scale even with `BatchTail`, the whole + direction needs a different primitive (e.g. aggregate signatures, + already flagged as open in the `ruvector-retrieval-receipt` lineage). +3. **Thesis**: synthetic nervous systems / agent operating systems where + memory writes are proof-gated system calls. **Required advances**: + kernel-level (RVM) enforcement, not library-level opt-in. **RuVector + role**: this module's `verify_signed_chain` is the exact primitive an + RVM syscall gate would call. **Why this experiment matters**: proves + the primitive's cost profile (candidate_b64: 3.77µs) is compatible + with syscall-frequency invocation. **Primary uncertainty**: whether + RVM would want per-record or per-batch granularity at the kernel + level. **Falsification path**: if RVM's actual write frequency exceeds + what any batch size keeps under budget, the tradeoff curve here would + need re-measurement at that specific rate. +4. **Thesis**: self-healing agent memory that can prove *which* healing + actions it took and why, for post-hoc audit of autonomous repair. + **Required advances**: extending signed witnesses to + `graph_forget`/`MincutGatedForgetting`-style structural decisions, not + just admission/eviction. **RuVector role**: `SignedWitnessSink` + already composes with any `WitnessSink`, so this is additive, not a + redesign. **Why this experiment matters**: the composition point + already exists and is tested. **Primary uncertainty**: whether + structural (graph) decisions need a richer signed statement than a + single `chain_hash`. **Falsification path**: if a structural decision + can't be reduced to "this witness record's chain_hash", the statement + schema here would need extending, not replacing. +5. **Thesis**: robotics memory (`ruvector-robotics`, + `agentic-robotics-*`) needing tamper-evident sensor-fusion history for + safety certification. **Required advances**: real-time signing budget + analysis on embedded hardware (ARM, no hardware Ed25519). **RuVector + role**: same `WitnessSink` composition point. **Why this experiment + matters**: this run's software-only signing costs are the first real + data point for whether that's feasible without hardware acceleration. + **Primary uncertainty**: embedded CPU headroom at actual sensor rates. + **Falsification path**: measuring `PerRecord`/`BatchTail` cost on + actual embedded hardware (not this container) at the target sensor + rate. +6. **Thesis**: scientific autonomous systems where an agent's memory of + its own experimental history must be independently auditable by + reviewers who don't trust the lab's infrastructure. **Required + advances**: publication-grade key/anchor distribution. **RuVector + role**: this module's public-key-only trust anchor. **Why this + experiment matters**: establishes the anchor is small and static, a + prerequisite for any external distribution scheme. **Primary + uncertainty**: none specific to this run beyond general PKI questions. + **Falsification path**: n/a — infrastructural, not a technical claim + this run makes. +7. **Thesis**: proof-gated autonomous infrastructure where every state + mutation across a fleet is independently attributable. + **Required advances**: fleet-scale key management, not addressed here. + **RuVector role**: the per-record primitive this run validates. + **Why this experiment matters**: without a cheap-enough per-record + primitive, fleet-scale attribution is a non-starter; `BatchTail` + shows a path to "cheap enough." **Primary uncertainty**: fleet-scale + key distribution and revocation. **Falsification path**: if + per-fleet-node signing cost at scale (not measured here) exceeds + budget even with large batches, the direction needs aggregate + signatures instead. +8. **Thesis**: swarm memory where many agents write to a shared, + eventually-consistent store and need to detect a Byzantine + participant's rewritten contribution. **Required advances**: + per-participant multi-issuer verification (as in long-horizon + application 2), plus CRDT-compatible witness merging. **RuVector + role**: `SignedSpan`'s per-issuer signature is a building block; CRDT + merge semantics are unaddressed. **Why this experiment matters**: + establishes the single-writer base case correctness before tackling + concurrent multi-writer merge. **Primary uncertainty**: whether + FNV-1a-chained (inherently sequential) witnesses can be reconciled + with CRDT merge at all, or need a different (e.g. Merkle-DAG) witness + structure. **Falsification path**: attempting a two-writer merge + scenario would likely falsify "no structural change needed" quickly — + flagged, not attempted, in this pass. + +## Competitor Comparison + +Not directly applicable: this is an internal admission-log signing +primitive, not a retrieval or indexing capability comparable to +Milvus/Qdrant/Weaviate/Pinecone/LanceDB/FAISS/pgvector/Chroma/Vespa/DiskANN. +None of those systems' documented public capabilities cover +"transaction-aware reliable ledger witness signing" as a comparable +surface; no comparison is attempted (avoiding Step 35's warning against +comparing architecture alone as if it were a measured result). + +## Evolution Results + +Darwin was not run (see "Darwin Role" above — no CLI installed, and the +two-parameter design space was swept exhaustively and directly instead). +`batch_size ∈ {16, 64, 256}` was compared; no evolutionary search was +needed to establish that larger batches trade latency for a larger +unsigned-availability window, which is not itself something a fitness +function needs to discover — it's an explicit primitive tradeoff. + +## Promotion Decision + +**Promote as an available, opt-in capability.** No default behavior +changes: `TransactionalLedger` callers who do not wrap their sink in +`SignedWitnessSink` are completely unaffected (same as `witnessed_compaction` +under ADR-345). Recommend: + +- `PerRecord` where immediate per-record signature availability matters + more than throughput (e.g. compliance-critical `Accept` transitions at + low volume). +- `BatchTail` at a batch size tuned to the deployment's actual + witness-emission rate and acceptable worst-case signature-availability + latency, where throughput matters more (the common case, per this + run's measured 36x-to-212x throughput advantage over `PerRecord` + across batch sizes 16-256). + +## Witness Evidence + +- Commit at run start: recorded in this branch's git history (first + commit of this nightly run). +- All benchmark output above is copied verbatim from one + `cargo run --release -p ruvector-agent-memory --example + witness_signing_bench` execution in this session's container; no + numbers were hand-edited. +- `cargo test -p ruvector-agent-memory --lib`: 34 passed, 0 failed (run + immediately before the benchmark, same commit). +- No signed/cryptographic witness chain covers this research process + itself (no Flywheel/witness-chain tooling is installed in this + environment, per "MetaHarness Role" above) — this document and the git + commit history are the durable record. + +## Production Path + +1. Wire `SignedWitnessSink` into whichever deployment's + `TransactionalLedger::new` call site needs signed provenance (opt-in, + today). +2. Add key generation/storage/rotation guidance (out of scope here, + matching `ruvector-retrieval-receipt::signing`'s existing disclaimer). +3. Concurrent-writer hardening: this run's `TransactionalLedger` (and + thus `SignedWitnessSink`) is single-threaded by construction; a + concurrent-access story is unaddressed here and inherited unchanged + from the base ledger. +4. Optional wall-clock batch-fill timeout (Next Research item 3) before + `BatchTail` is used anywhere with a bounded-latency requirement. + +## Falsification Criteria + +This hypothesis would have been rejected if any of: + +- Any of the 5 benchmark variants (`baseline` counts as a correctness + control) failed its `verify_chain`/`verify_signed_chain` correctness + gate — none did. +- `BatchTail{64}` failed to beat `PerRecord`'s mean latency by at least + 5x — it beat it by 36.4x. +- The diligent-forgery construction failed to fool the unsigned + `verify_chain()` baseline (i.e. if the existing crate's own + tamper-evidence claim were wrong in the *other* direction) — it did + fool it, confirming the crate's own documentation and this run's + starting premise. +- Either signing strategy failed to reject the diligent forgery — neither + did; both correctly rejected it in the tested trial. + +## Limitations + +- Single-threaded benchmark only; no concurrent-writer measurement. +- No WASM or embedded-hardware measurement (Next Research items 4/5). +- No fault-injection measurement of the `BatchTail` blast-radius claim + (Failure Modes item 3) — reasoned qualitatively, not measured. +- The FNV-1a preimage-resistance question, as originally scoped out (see + "What This Run Does Not Claim"), turned out to matter enough that the + post-review hardening removed the dependency on it entirely rather than + leaving it open — see "Update (2026-09-26)" above. +- `batch_size` was swept at three fixed points, not continuously; the + amortization curve's shape between 16 and 256 is interpolated, not + measured at every value. + +## Next Research + +1. A wall-clock batch-fill timeout for `BatchTail` (reusing the + `BatchFillPolicy`/`BatchScheduler` methodology already built and + measured in `ruvector-retrieval-receipt::batch_fill`, without adding + that crate as a dependency — port the pattern, not the code), so a + deployment gets a bounded worst-case signature-availability latency + instead of "whenever the batch happens to fill." +2. ~~Rigorously determine the actual cost of a chosen-target FNV-1a + second-preimage attack against a 64-byte `LedgerWitnessRecord`~~ — + **resolved by design, not by measurement:** PR #989's review pointed + out that FNV-1a's `(hash ^ byte) * PRIME mod 2^64` step is invertible + (odd prime), so a record with 8+ attacker-controllable bytes doesn't + need generic brute force at all — the ~2^32 figure was plausibly an + *upper* bound, not a floor. See "Update (2026-09-26)" above: the + signed path no longer depends on FNV-1a at all (SHA-256 digests + instead), so this question no longer affects this module's security, + whatever the true answer turns out to be. +3. Concurrent-writer and fault-injection hardening for both + `TransactionalLedger` and `SignedWitnessSink` (mid-batch signer crash, + the specific scenario named in Failure Modes item 3). +4. WASM binary-size and signing-latency measurement — the same deferred + item `ruvector-retrieval-receipt`'s ADR-340/343 nightly runs have + twice flagged and not yet closed; this run adds a third open instance + of the identical question, now against a second crate. +5. Multi-issuer `verify_signed_chain` (Long Horizon Application 2/8) for + federated or swarm agent-memory scenarios. +6. Wire `SignedWitnessSink` through `witnessed_compaction::compact_witnessed` + end to end as a concrete example (this run proves it composes; it does + not ship a wired example for the compaction path specifically). + +## References + +- `docs/adr/ADR-134-witness-schema-log-format.md` — the witness record + schema this run's signatures cover, and the §9 `WitnessSigner` gap this + run closes. +- `docs/adr/ADR-307-three-level-persistent-memory-livemem-tarl.md` + (referenced in `ledger.rs`'s module doc) — the TARL ledger this run + signs. +- `docs/adr/ADR-320-memfuse-pattern-atomic-observation-causal-graph.md` + (referenced in `lib.rs`'s module doc) — the `rvf-types` Ed25519 + primitive this run reuses. +- `docs/research/nightly/2026-08-31-signed-retrieval-receipts/README.md`, + `docs/research/nightly/2026-09-01-signed-receipt-batch-fill-latency/README.md` — + the sibling per-record-vs-batch-amortized signing lineage this run's + methodology deliberately parallels. +- `docs/research/nightly/2026-09-05-mincut-gated-forgetting/README.md` — + source of this run's Next Research item 6 (item 4 in that document's + own Next Research), and the `witnessed_compaction` module this run's + `SignedWitnessSink` composes with. +- `crates/ruvector-agent-memory/src/ledger.rs`, `src/ops.rs` — the + tamper-evidence notes this run's Abstract quotes. +- `crates/rvf/rvf-types/src/ed25519.rs` — the signing primitive. diff --git a/docs/research/nightly/2026-09-16-witness-signer-agent-memory/gist.md b/docs/research/nightly/2026-09-16-witness-signer-agent-memory/gist.md new file mode 100644 index 0000000000..19a437fc12 --- /dev/null +++ b/docs/research/nightly/2026-09-16-witness-signer-agent-memory/gist.md @@ -0,0 +1,177 @@ +# Closing a Named Security Gap: Signing RuVector's Agent-Memory Witness Chain + +## Problem + +`ruvector-agent-memory` implements a TARL (Transaction-Aware Reliable +Ledgers) executable memory ledger: every admission, revision, rejection, +or acceptance of a piece of agent memory emits a witness record, chained +together with FNV-1a hashing. The crate's own source code says, in its +own words, that this chain is "tamper-EVIDENT against accidental +corruption and naive edits only... Real tamper evidence against a +log-writing adversary requires ADR-134's `WitnessSigner` escape hatch." +That escape hatch — an Ed25519 signature over the chain — was named in at +least two places in the codebase and one prior nightly research run, and +never built. + +## Hypothesis + +Wire it up two ways — sign every witness record individually, or sign +only the last record of every N-record batch — and measure whether the +batched approach's latency savings are real, and whether both approaches +actually deliver on the security property the unsigned chain is +documented as lacking. + +## Technical Design + +`ruvector-agent-memory` already depends on `rvf-types`, a sibling crate +providing Ed25519 signing (used elsewhere in the same crate for signing +individual memory observations). No new cryptographic library and no new +Cargo dependency was needed — just a new module, +`witness_signing.rs`, implementing a `WitnessSink` decorator: + +```rust +pub enum SigningStrategy { + PerRecord, + BatchTail { batch_size: usize }, +} + +pub struct SignedWitnessSink { /* wraps any WitnessSink */ } +``` + +Every witness record already carries a `chain_hash` — an FNV-1a hash over +its full 64 bytes, which includes a `prev_hash` field pointing at the +previous record's `chain_hash`. That means signing just the *last* +record's `chain_hash` in a run of N records transitively authenticates +all N of them, as long as a verifier also independently walks the +unsigned chain to confirm it's actually linked correctly and hasn't been +truncated. `BatchTail` exploits exactly this, amortizing one signature +over many records; `PerRecord` signs every record as it lands, for +maximum immediacy at maximum cost. + +The one subtlety that matters: a signed record's `chain_hash` has to be +cross-checked against what the log's record at that position *actually +hashes to right now* — not just trusted as whatever value sits in the +signed statement. Get that wrong and the whole scheme is bypassable by +an attacker who just keeps an old, honestly-signed statement around and +claims it covers new content. `verify_signed_chain` does this cross-check +explicitly; it's the one function in the module that actually matters for +security. + +## Implementation + +- `crates/ruvector-agent-memory/src/witness_signing.rs` — the module + above, plus 6 unit tests. +- `crates/ruvector-agent-memory/src/ledger.rs` — one small addition, + `into_witness_sink(self) -> S`, so a caller can recover a wrapped + sink's signatures after driving a ledger run. +- `crates/ruvector-agent-memory/examples/witness_signing_bench.rs` — the + benchmark below. + +Zero Cargo.toml changes. Zero existing behavior changes: nothing that +doesn't explicitly opt into `SignedWitnessSink` is affected. + +## The Actual Test: Can You Fool the Unsigned Chain? + +Rather than just asserting "signing makes this more secure," the test +suite constructs an actual forgery. It builds an honest, signed witness +log, then plays the adversary: pick a record halfway through, flip a bit +in its `payload` field, and then — this is the important part — correctly +recompute every `record_hash`/`prev_hash`/`chain_hash` for every record +after it, exactly the way the ledger itself would. The result is a +completely self-consistent alternate history. + +Running the existing, already-shipped `MemoryWitnessLog::verify_chain()` +against this forged log: **it passes.** This isn't a surprise — it's +exactly what the crate's own documentation already said would happen — +but it's now something you can watch happen in a test, not just take on +faith from a code comment. + +Running `verify_signed_chain` (the new function) against the same forged +log, with the original signatures: **it fails**, for both `PerRecord` and +`BatchTail`. That's the whole point of the exercise, and it now has an +executable proof rather than a documentation comment. + +## Benchmark + +20,000 sequential `add` + `accept` operations (40,000 witness records), +release build, real wall-clock timing, deterministic signing key: + +``` +baseline mean= 1.220us throughput= 800197.4 ops/s signatures= 0 +candidate_a mean= 137.423us throughput= 7274.7 ops/s signatures= 40000 (PerRecord) +candidate_b16 mean= 9.651us throughput= 103205.5 ops/s signatures= 2500 (BatchTail, batch=16) +candidate_b64 mean= 3.774us throughput= 262867.7 ops/s signatures= 625 (BatchTail, batch=64) +candidate_b256 mean= 2.011us throughput= 489882.3 ops/s signatures= 157 (BatchTail, batch=256) +``` + +Per-record signing costs about 110x the unsigned baseline's throughput — +each Ed25519 signature in this environment costs roughly 69 microseconds, +and at two witness records per logical operation, that adds up fast. +Batching at 64 records per signature recovers most of that throughput +(36x faster than per-record signing) while — per the forgery test above — +providing the exact same tamper-detection guarantee against a diligent, +fully-recomputed attacker. The tradeoff `BatchTail` actually pays for that +speedup isn't weaker security; it's availability: a record inside a batch +that hasn't closed yet has no signature at all until it does. + +## Limitations + +This work does not attempt to determine the true difficulty of forging a +*specific* target `chain_hash` via an FNV-1a second-preimage attack — a +figure the crate's existing documentation estimates at "~2^32 work" but +which no prior work in this repository has actually verified. Getting +that number right requires real cryptanalysis, and getting it wrong in +either direction (overstating or understating the difficulty) would be +worse than not stating it at all, so this run explicitly declines to +guess. It doesn't change this work's conclusion — Ed25519 signature +unforgeability holds regardless of whatever the inner hash's own +weaknesses turn out to be — but it does mean the question "is the +*unsigned* chain safer than the doc comment suggests" remains open for a +future, properly-scoped pass. + +Also out of scope: concurrent-writer behavior (the ledger is +single-threaded by construction, unchanged by this work), WASM binary +size (an already-twice-deferred question for the sibling +`ruvector-retrieval-receipt` crate's own signing work), and fault +injection to quantify `BatchTail`'s larger blast radius if a signing +process crashes mid-batch. + +## Production Relevance + +This closes a specific, named gap between two already-built pieces of the +RuVector ecosystem: `ruvector-retrieval-receipt` already signs *what was +retrieved*; nothing signed *what was admitted* underneath it. An operator +who wants to prove a memory store's admission history wasn't rewritten +after an incident can now do that with a single, small, static public +key instead of needing to continuously re-anchor an ever-growing +`(count, hash)` commitment pair out-of-band — which was, until this +change, the documented alternative. + +## RuVector Ecosystem Implications + +The new `SignedWitnessSink` wraps *any* `WitnessSink`, +which means it composes for free with the eviction-witness path added by +a prior nightly run (`witnessed_compaction`, ADR-345) — signed eviction +receipts, an item that prior run's own "Next Research" section asked for, +now require zero new code, just wrapping the sink at the call site. + +## Future Direction + +1. A wall-clock batch-fill timeout for `BatchTail`, so a deployment gets + a bounded worst-case signature-availability latency instead of + "whenever the batch happens to fill" — reusing the methodology (not + the code) already built for retrieval receipts. +2. A properly-scoped attempt at the FNV-1a second-preimage question left + open above. +3. Concurrent-writer and fault-injection hardening. +4. Multi-issuer verification, for agent-memory scenarios where more than + one writer needs independent attribution. + +## References + +- `docs/adr/ADR-134-witness-schema-log-format.md` +- `docs/adr/ADR-347-witness-signer-tarl-ledger.md` (this work's ADR) +- `docs/research/nightly/2026-08-31-signed-retrieval-receipts/README.md` +- `docs/research/nightly/2026-09-05-mincut-gated-forgetting/README.md` +- Full methodology, benchmark reproduction command, and complete + evidence: `docs/research/nightly/2026-09-16-witness-signer-agent-memory/README.md`