Skip to main content

ant_node/replication/
mod.rs

1//! Replication subsystem for the Autonomi network.
2//!
3//! Implements Kademlia-style replication with:
4//! - Fresh replication with `PoP` verification
5//! - Neighbor sync with round-robin cycle management
6//! - Batched quorum verification
7//! - Storage audit protocol (anti-outsourcing)
8//! - `PaidForList` persistence and convergence
9//! - Responsibility pruning with hysteresis
10
11// The replication engine intentionally holds `RwLock` read guards across await
12// boundaries (e.g. reading sync_history while calling audit_tick). Clippy's
13// nursery lint `significant_drop_tightening` flags these, but the guards must
14// remain live for the duration of the call.
15#![allow(clippy::significant_drop_tightening)]
16
17pub mod admission;
18pub mod audit;
19pub mod bootstrap;
20pub mod commitment;
21pub mod commitment_state;
22pub mod config;
23pub mod fresh;
24pub mod neighbor_sync;
25pub mod paid_list;
26pub mod protocol;
27pub mod pruning;
28pub mod quorum;
29pub mod recent_provers;
30pub mod scheduling;
31pub mod storage_commitment_audit;
32pub mod subtree;
33pub mod types;
34
35use std::collections::{HashMap, HashSet};
36use std::path::Path;
37use std::sync::Arc;
38use std::time::{Duration, Instant};
39
40use std::pin::Pin;
41
42use crate::logging::{debug, error, info, warn};
43use futures::stream::FuturesUnordered;
44use futures::{Future, StreamExt};
45use rand::Rng;
46use tokio::sync::{mpsc, Notify, RwLock, Semaphore};
47use tokio::task::JoinHandle;
48use tokio_util::sync::CancellationToken;
49
50use crate::ant_protocol::XorName;
51use crate::error::{Error, Result};
52use crate::payment::{PaymentVerifier, VerificationContext};
53use crate::replication::audit::AuditTickResult;
54use crate::replication::commitment::{commitment_hash, StorageCommitment};
55use crate::replication::commitment_state::{PeerCommitmentRecord, ResponderCommitmentState};
56use crate::replication::config::{
57    max_parallel_fetch, storage_admission_width, ReplicationConfig, MAX_AUDIT_RESPONSES_PER_PEER,
58    MAX_CONCURRENT_AUDIT_RESPONSES, MAX_CONCURRENT_REPLICATION_SENDS, REPLICATION_PROTOCOL_ID,
59};
60use crate::replication::paid_list::PaidList;
61use crate::replication::protocol::{
62    FreshReplicationResponse, NeighborSyncResponse, ReplicationMessage, ReplicationMessageBody,
63    VerificationResponse,
64};
65use crate::replication::quorum::KeyVerificationOutcome;
66use crate::replication::recent_provers::RecentProvers;
67use crate::replication::scheduling::ReplicationQueues;
68use crate::replication::types::{
69    AuditFailureReason, BootstrapClaimObservation, BootstrapState, FailureEvidence, HintPipeline,
70    NeighborSyncState, PeerSyncRecord, RepairProofs, VerificationEntry, VerificationState,
71};
72use crate::storage::LmdbStorage;
73use saorsa_core::identity::{NodeIdentity, PeerId};
74use saorsa_core::{DhtNetworkEvent, P2PEvent, P2PNode, TrustEvent};
75
76// ---------------------------------------------------------------------------
77// Constants
78// ---------------------------------------------------------------------------
79
80/// Prefix used by saorsa-core's request-response mechanism.
81const RR_PREFIX: &str = "/rr/";
82
83fn fresh_offer_payment_context() -> VerificationContext {
84    VerificationContext::ClientPut
85}
86
87fn paid_notify_payment_context() -> VerificationContext {
88    VerificationContext::PaidListAdmission
89}
90
91/// Boxed future type for in-flight fetch tasks.
92type FetchFuture = Pin<Box<dyn Future<Output = (XorName, Option<FetchOutcome>)> + Send>>;
93
94/// Shared dependencies for one verification worker cycle.
95struct VerificationCycleContext<'a> {
96    p2p_node: &'a Arc<P2PNode>,
97    paid_list: &'a Arc<PaidList>,
98    storage: &'a Arc<LmdbStorage>,
99    queues: &'a Arc<RwLock<ReplicationQueues>>,
100    config: &'a ReplicationConfig,
101    bootstrap_state: &'a Arc<RwLock<BootstrapState>>,
102    is_bootstrapping: &'a Arc<RwLock<bool>>,
103    bootstrap_complete_notify: &'a Arc<Notify>,
104    /// v12 §6 holder-eligibility inputs. The verifier downgrades a
105    /// peer's Present claim to Unresolved unless they're a credited
106    /// holder of the key (i.e. they recently passed a commitment-bound
107    /// audit on it under their currently-credited commitment hash).
108    last_commitment_by_peer: &'a Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
109    ever_capable_peers: &'a Arc<RwLock<HashSet<PeerId>>>,
110    recent_provers: &'a Arc<RwLock<RecentProvers>>,
111}
112
113/// Fetch worker polling interval in milliseconds.
114const FETCH_WORKER_POLL_MS: u64 = 100;
115
116/// Verification worker polling interval in milliseconds.
117const VERIFICATION_WORKER_POLL_MS: u64 = 250;
118
119/// Verification cycle duration that is worth surfacing at info level.
120const VERIFICATION_CYCLE_SLOW_LOG_MS: u128 = 500;
121
122/// Standard trust event weight for per-operation success/failure signals.
123///
124/// Used for individual replication fetch outcomes, integrity check failures,
125/// and bootstrap claim abuse. Distinct from `AUDIT_FAILURE_TRUST_WEIGHT` which
126/// is reserved for confirmed audit failures.
127const REPLICATION_TRUST_WEIGHT: f64 = 1.0;
128
129/// Bootstrap drain check interval in seconds.
130const BOOTSTRAP_DRAIN_CHECK_SECS: u64 = 5;
131
132/// How often the responder rebuilds + rotates its storage commitment.
133///
134/// Each rebuild scans LMDB to compute leaf hashes; for ~10k keys this is
135/// sub-100ms (BLAKE3 + tree build). Retention is gossip-anchored, NOT
136/// rotation-anchored: the responder stays answerable for the current
137/// commitment plus the last `RETAINED_GOSSIPED_COMMITMENTS` (= 2) it
138/// actually gossiped, each kept for `GOSSIP_ANSWERABILITY_TTL` (3 h) after
139/// its last emission (see `commitment_state`). So the rotation cadence does
140/// not by itself bound answerability — a gossiped commitment stays
141/// answerable across rotations until its gossip TTL lapses.
142///
143/// Default: 1 hour, aligned with the worst-case neighbor-sync cooldown
144/// (`NEIGHBOR_SYNC_COOLDOWN_SECS = 3600`). Because the gossip TTL (3 h)
145/// comfortably exceeds the gap between our rotation and the next gossip
146/// arrival at a remote peer, this prevents the "unknown commitment hash" ->
147/// Idle audit-skip pattern from being the common case.
148///
149/// Why not faster: the v12 pin is bound to a specific point-in-time
150/// commitment, so rotation isn't security-critical for pin freshness —
151/// only for keeping the committed key set current as the responder
152/// writes new keys. 1 hour is plenty for that, and slow enough that
153/// honest auditors mostly hit `current` or `previous` rather than the
154/// "rotated past" case.
155const COMMITMENT_ROTATION_INTERVAL_SECS: u64 = 3600;
156
157/// Minimum interval between commitment signature verifications for a
158/// single peer (v10/v12 §2 step 3 + §11 `DoS`).
159///
160/// A sybil that bypasses the routing-table gate (e.g. by transient
161/// bucket pollution) could otherwise force one ML-DSA-65 verify (~1 ms)
162/// per gossip message. This rate limit caps the verify-per-peer rate
163/// at 1/min, which is comfortably above the legitimate gossip cadence
164/// (the 10-20 min neighbor-sync round on each peer).
165const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_secs(60);
166
167/// Hard cap on the size of `last_commitment_by_peer`.
168///
169/// Bounds the per-process memory cost of the auditor's per-peer
170/// commitment cache. Each entry holds a `StorageCommitment`
171/// (~5 KiB: 1952-byte pubkey + 3293-byte signature + small fields).
172/// At 4096 entries the cache is ~20 MiB, which comfortably covers a
173/// realistic close-group neighborhood. When the cap is hit, one
174/// arbitrary existing entry is evicted on insert (`HashMap` iteration
175/// order is unspecified; we do not track insertion order). The
176/// `PeerRemoved` handler proactively drops entries as the DHT
177/// detects departures, and `ingest_peer_commitment` only admits
178/// commitments from peers currently in the routing table — together
179/// the cap is the third line of defence against sybil/churn flooding.
180const MAX_LAST_COMMITMENT_BY_PEER: usize = 4096;
181
182/// Cap on the sticky `ever_capable_peers` set. Bounds memory so a
183/// long-running bootstrap node cannot have the set grow without limit
184/// from peer-id churn. Sized at 4x `MAX_LAST_COMMITMENT_BY_PEER` so
185/// the set comfortably outlives normal LRU churn but still caps the
186/// blast radius of identity-rotation attacks. Once full we refuse new
187/// inserts (no eviction) — keeps the historic set stable; new v12
188/// peers above the cap are treated as legacy on rejoin, which matches
189/// the behaviour before this set existed, not a security regression.
190const MAX_EVER_CAPABLE_PEERS: usize = 4 * MAX_LAST_COMMITMENT_BY_PEER;
191
192// ---------------------------------------------------------------------------
193// ReplicationEngine
194// ---------------------------------------------------------------------------
195
196/// The replication engine manages all replication background tasks and state.
197pub struct ReplicationEngine {
198    /// Replication configuration (shared across spawned tasks).
199    config: Arc<ReplicationConfig>,
200    /// P2P networking node.
201    p2p_node: Arc<P2PNode>,
202    /// Local chunk storage.
203    storage: Arc<LmdbStorage>,
204    /// Persistent paid-for-list.
205    paid_list: Arc<PaidList>,
206    /// Payment verifier for `PoP` validation.
207    payment_verifier: Arc<PaymentVerifier>,
208    /// Replication pipeline queues.
209    queues: Arc<RwLock<ReplicationQueues>>,
210    /// Neighbor sync cycle state.
211    sync_state: Arc<RwLock<NeighborSyncState>>,
212    /// Per-peer sync history (for `RepairOpportunity`).
213    ///
214    /// This map grows with peer churn and is intentionally unbounded: entries
215    /// are lightweight (`PeerSyncRecord` is two fields) and peer IDs are
216    /// naturally bounded by the routing table's k-bucket capacity.
217    sync_history: Arc<RwLock<HashMap<PeerId, PeerSyncRecord>>>,
218    /// Per-peer consecutive audit-timeout strike counter.
219    ///
220    /// A timeout increments the peer's strike count; a successful audit
221    /// response resets it to zero. Only when a peer reaches
222    /// [`config::AUDIT_TIMEOUT_STRIKE_THRESHOLD`] consecutive timeouts is a
223    /// timeout reported as an `ApplicationFailure` trust event. This separates
224    /// honest transient slowness (resets on the next normal response) from a
225    /// peer that does not store the data and is slow on every audit. Lives
226    /// outside `NeighborSyncState` so it is never wiped by a neighbor-sync
227    /// cycle reset. Grows with peer churn like `sync_history`; entries are a
228    /// single `u32` and peer IDs are bounded by k-bucket capacity.
229    audit_timeout_strikes: Arc<RwLock<HashMap<PeerId, u32>>>,
230    /// Per-peer cooldown for gossip-triggered subtree audits (ADR-0002).
231    ///
232    /// Records when each peer was last audited so a burst of gossiped
233    /// commitment changes cannot spawn back-to-back audits of the same peer.
234    /// Bounded by routing-table membership and cleaned on `PeerRemoved`.
235    audit_on_gossip_cooldown: Arc<RwLock<HashMap<PeerId, Instant>>>,
236    /// Completed local neighbor-sync cycle epoch for proof maturity.
237    sync_cycle_epoch: Arc<RwLock<u64>>,
238    /// Per-key repair proof tracking for audit eligibility.
239    repair_proofs: Arc<RwLock<RepairProofs>>,
240    /// Bootstrap state tracking.
241    bootstrap_state: Arc<RwLock<BootstrapState>>,
242    /// Whether this node is currently bootstrapping.
243    is_bootstrapping: Arc<RwLock<bool>>,
244    /// Trigger for early neighbor sync (signalled on topology changes).
245    sync_trigger: Arc<Notify>,
246    /// Notified when `is_bootstrapping` transitions from `true` to `false`.
247    bootstrap_complete_notify: Arc<Notify>,
248    /// Node identity (for signing storage commitments).
249    ///
250    /// Phase 3 of the v12 storage-bound audit design. The responder
251    /// uses this to sign its periodically-built `StorageCommitment`.
252    identity: Arc<NodeIdentity>,
253    /// Responder-side commitment state (two-slot atomic rotation).
254    ///
255    /// Periodically rebuilt from the live LMDB key set; gossiped on
256    /// outbound `NeighborSyncRequest`/`Response`; consulted by the
257    /// commitment-bound audit handler.
258    commitment_state: Arc<ResponderCommitmentState>,
259    /// Auditor-side per-peer commitment record (last known commitment +
260    /// sticky `commitment_capable` flag).
261    ///
262    /// Populated whenever an inbound gossip carries a verified
263    /// commitment from the sender. Used by `audit_tick` to snapshot
264    /// `expected_commitment_hash` into outbound challenges, and by
265    /// holder-eligibility (§6) to decide whether a peer's `recent_provers`
266    /// proof should be honoured. The sticky `commitment_capable` flag
267    /// flips true on first successful ingest and never reverts (§2
268    /// step 5).
269    last_commitment_by_peer: Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
270    /// Sticky set of peer IDs we have EVER seen carrying a v12
271    /// commitment, independent of whether their commitment bytes are
272    /// still in `last_commitment_by_peer`. The §6 holder-eligibility
273    /// closure consults this set to keep treating churned-out
274    /// previously-v12 peers as v12-capable (rather than degrading them
275    /// to "legacy" credit-unconditionally) when they re-appear on the
276    /// network before their next gossip arrives. Bounded growth: even
277    /// at one million peers seen over the node's lifetime, the set is
278    /// 32 MB.
279    ever_capable_peers: Arc<RwLock<HashSet<PeerId>>>,
280    /// Auditor-side holder-eligibility cache (v12 §6).
281    ///
282    /// Recorded on successful commitment-bound audit; read by future
283    /// quorum / paid-list eligibility checks (phase-3 stretch).
284    recent_provers: Arc<RwLock<RecentProvers>>,
285    /// Per-peer last sig-verify attempt timestamp for the §2 step 3 /
286    /// §11 `DoS` rate limit. Bumped on EVERY verify attempt (success or
287    /// failure) so a peer we've never successfully verified can't burn
288    /// CPU on a flood of structurally-plausible-but-invalid gossips.
289    /// Lives separately from `last_commitment_by_peer` because that
290    /// map's records only exist after a successful verify.
291    sig_verify_attempts: Arc<RwLock<HashMap<PeerId, Instant>>>,
292    /// Limits concurrent outbound replication sends to prevent bandwidth
293    /// saturation on home broadband connections.
294    send_semaphore: Arc<Semaphore>,
295    /// Bounds concurrent IN-FLIGHT audit-responder tasks (subtree round 1 +
296    /// byte round 2). Those are spawned off the serial message loop so disk
297    /// reads don't block replication; the semaphore restores a global
298    /// backpressure ceiling so the node can't fan out unbounded `get_raw` reads
299    /// / multi-MiB byte serves.
300    audit_responder_semaphore: Arc<Semaphore>,
301    /// Per-source in-flight audit-responder counts, capped at
302    /// [`MAX_AUDIT_RESPONSES_PER_PEER`]. The GLOBAL semaphore alone is not
303    /// flood-fair: one peer spamming challenges could occupy every slot and
304    /// starve honest auditors, whose dropped challenges then convert to
305    /// timeouts and record strikes on the HONEST peers (codex-r2 A). This
306    /// per-peer cap guarantees no single source can hold more than its share,
307    /// so a flood self-throttles without denying service to everyone else.
308    audit_responder_inflight: Arc<RwLock<HashMap<PeerId, u32>>>,
309    /// Receiver for fresh-write events from the chunk PUT handler.
310    ///
311    /// When present, `start()` spawns a drainer task that calls
312    /// `replicate_fresh` for each event.
313    fresh_write_rx: Option<mpsc::UnboundedReceiver<fresh::FreshWriteEvent>>,
314    /// Shutdown token.
315    shutdown: CancellationToken,
316    /// Background task handles.
317    task_handles: Vec<JoinHandle<()>>,
318}
319
320impl ReplicationEngine {
321    /// Create a new replication engine.
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if the `PaidList` LMDB environment cannot be opened
326    /// or if the configuration fails validation.
327    #[allow(clippy::too_many_arguments)]
328    pub async fn new(
329        config: ReplicationConfig,
330        p2p_node: Arc<P2PNode>,
331        storage: Arc<LmdbStorage>,
332        payment_verifier: Arc<PaymentVerifier>,
333        identity: Arc<NodeIdentity>,
334        root_dir: &Path,
335        fresh_write_rx: mpsc::UnboundedReceiver<fresh::FreshWriteEvent>,
336        shutdown: CancellationToken,
337    ) -> Result<Self> {
338        config.validate().map_err(Error::Config)?;
339
340        let paid_list = Arc::new(
341            PaidList::new(root_dir)
342                .await
343                .map_err(|e| Error::Storage(format!("Failed to open PaidList: {e}")))?,
344        );
345
346        let initial_neighbors = NeighborSyncState::new_cycle(Vec::new());
347        let config = Arc::new(config);
348
349        Ok(Self {
350            config: Arc::clone(&config),
351            p2p_node,
352            storage,
353            paid_list,
354            payment_verifier,
355            queues: Arc::new(RwLock::new(ReplicationQueues::new())),
356            sync_state: Arc::new(RwLock::new(initial_neighbors)),
357            sync_history: Arc::new(RwLock::new(HashMap::new())),
358            audit_timeout_strikes: Arc::new(RwLock::new(HashMap::new())),
359            audit_on_gossip_cooldown: Arc::new(RwLock::new(HashMap::new())),
360            sync_cycle_epoch: Arc::new(RwLock::new(0)),
361            repair_proofs: Arc::new(RwLock::new(RepairProofs::new())),
362            bootstrap_state: Arc::new(RwLock::new(BootstrapState::new())),
363            is_bootstrapping: Arc::new(RwLock::new(true)),
364            sync_trigger: Arc::new(Notify::new()),
365            bootstrap_complete_notify: Arc::new(Notify::new()),
366            identity,
367            commitment_state: Arc::new(ResponderCommitmentState::new()),
368            last_commitment_by_peer: Arc::new(RwLock::new(HashMap::new())),
369            ever_capable_peers: Arc::new(RwLock::new(HashSet::new())),
370            recent_provers: Arc::new(RwLock::new(RecentProvers::new())),
371            sig_verify_attempts: Arc::new(RwLock::new(HashMap::new())),
372            send_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_REPLICATION_SENDS)),
373            audit_responder_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_AUDIT_RESPONSES)),
374            audit_responder_inflight: Arc::new(RwLock::new(HashMap::new())),
375            fresh_write_rx: Some(fresh_write_rx),
376            shutdown,
377            task_handles: Vec::new(),
378        })
379    }
380
381    /// Get a reference to the `PaidList`.
382    #[must_use]
383    pub fn paid_list(&self) -> &Arc<PaidList> {
384        &self.paid_list
385    }
386
387    /// Get a reference to the responder's commitment state. Used by audit
388    /// handlers to look up commitments by hash; used by the rotation tick
389    /// to install fresh ones.
390    #[must_use]
391    pub fn commitment_state(&self) -> &Arc<ResponderCommitmentState> {
392        &self.commitment_state
393    }
394
395    /// Get a reference to the auditor's last-commitment-by-peer table.
396    #[must_use]
397    pub fn last_commitment_by_peer(&self) -> &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>> {
398        &self.last_commitment_by_peer
399    }
400
401    /// Get a reference to the holder-eligibility cache. Phase-3 stretch:
402    /// will be read by quorum / paid-list eligibility checks.
403    #[must_use]
404    pub fn recent_provers(&self) -> &Arc<RwLock<RecentProvers>> {
405        &self.recent_provers
406    }
407
408    /// Test-only: rebuild + rotate this node's storage commitment now over its
409    /// current key set (normally on a 1h timer). Lets a test commit to chunks it
410    /// just stored without waiting for the rotation cadence.
411    ///
412    /// # Errors
413    ///
414    /// Propagates any error from reading the local key set or building/signing
415    /// the commitment.
416    #[cfg(any(test, feature = "test-utils"))]
417    pub async fn rebuild_commitment_now(&self) -> Result<()> {
418        rebuild_and_rotate_commitment(
419            &self.storage,
420            &self.identity,
421            &self.commitment_state,
422            &self.p2p_node,
423            &self.config,
424        )
425        .await
426    }
427
428    /// Test-only: directly seed this node's cached commitment for `peer`,
429    /// simulating "we received `peer`'s gossiped commitment" without depending
430    /// on neighbor-sync propagation timing. Lets a two-node audit test pin the
431    /// peer's commitment deterministically.
432    #[cfg(any(feature = "test-utils", test))]
433    pub async fn inject_peer_commitment_for_test(
434        &self,
435        peer: &PeerId,
436        commitment: StorageCommitment,
437    ) {
438        let now = Instant::now();
439        self.last_commitment_by_peer
440            .write()
441            .await
442            .insert(*peer, PeerCommitmentRecord::from_verified(commitment, now));
443        self.ever_capable_peers.write().await.insert(*peer);
444    }
445
446    /// Test-only: run ONE subtree audit against `peer` right now, pinned to the
447    /// commitment this node has cached for it (from gossip), over the live wire.
448    /// Returns the audit outcome so tests can assert honest-pass / adversary-fail
449    /// in a real two-node setting without waiting for the gossip cadence.
450    ///
451    /// Returns `AuditTickResult::Idle` if we have no cached commitment for the
452    /// peer yet (gossip hasn't reached us). Gated to test builds.
453    #[cfg(any(test, feature = "test-utils"))]
454    pub async fn audit_peer_now(&self, peer: &PeerId) -> audit::AuditTickResult {
455        let target = {
456            let map = self.last_commitment_by_peer.read().await;
457            map.get(peer)
458                .and_then(PeerCommitmentRecord::last_commitment)
459                .and_then(|c| commitment_hash(c).map(|h| (h, c.key_count)))
460        };
461        let Some((pin, key_count)) = target else {
462            return audit::AuditTickResult::Idle;
463        };
464        let credit = storage_commitment_audit::AuditCredit {
465            recent_provers: &self.recent_provers,
466        };
467        storage_commitment_audit::run_subtree_audit(
468            &self.p2p_node,
469            &self.config,
470            peer,
471            pin,
472            key_count,
473            Some(&credit),
474        )
475        .await
476    }
477
478    /// Start all background tasks.
479    ///
480    /// `dht_events` must be subscribed **before** `P2PNode::start()` so that
481    /// the `BootstrapComplete` event emitted during DHT bootstrap is not
482    /// missed by the bootstrap-sync gate.
483    pub fn start(&mut self, dht_events: tokio::sync::broadcast::Receiver<DhtNetworkEvent>) {
484        if !self.task_handles.is_empty() {
485            error!("ReplicationEngine::start() called while already running — ignoring");
486            return;
487        }
488        info!("Starting replication engine");
489
490        self.start_message_handler();
491        self.start_neighbor_sync_loop();
492        self.start_self_lookup_loop();
493        // Audit #2 (responsible-chunk): periodic tick auditing peers for the
494        // chunks they SHOULD store (responsibility + prior hint).
495        self.start_audit_loop();
496        // Audit #1 (storage-commitment) is gossip-triggered in the message
497        // handler when a peer's commitment is ingested, not on a periodic tick.
498        self.start_commitment_rotation_loop();
499        self.start_fetch_worker();
500        self.start_verification_worker();
501        self.start_bootstrap_sync(dht_events);
502        self.start_fresh_write_drainer();
503
504        info!(
505            "Replication engine started with {} background tasks",
506            self.task_handles.len()
507        );
508    }
509
510    /// Returns `true` if the node is still in the replication bootstrap phase.
511    ///
512    /// During bootstrap, audit challenges return `Bootstrapping` instead of
513    /// digests, and neighbor sync responses carry `bootstrapping: true`.
514    pub async fn is_bootstrapping(&self) -> bool {
515        *self.is_bootstrapping.read().await
516    }
517
518    /// Wait until the replication bootstrap phase completes.
519    ///
520    /// Returns immediately if bootstrap has already completed. Useful for
521    /// readiness probes, health checks, and test harnesses that need the
522    /// node to be fully operational before proceeding.
523    ///
524    /// Returns `true` if bootstrap completed within the timeout, `false`
525    /// if the timeout elapsed first.
526    pub async fn wait_for_bootstrap_complete(&self, timeout: Duration) -> bool {
527        // Register the notification future *before* checking the flag so that
528        // a transition between the read and the await is not missed.
529        let notified = self.bootstrap_complete_notify.notified();
530        tokio::pin!(notified);
531        notified.as_mut().enable();
532
533        if !*self.is_bootstrapping.read().await {
534            return true;
535        }
536
537        tokio::time::timeout(timeout, notified).await.is_ok()
538    }
539
540    /// Cancel all background tasks and wait for them to terminate.
541    ///
542    /// This must be awaited before dropping the engine when the caller needs
543    /// the `Arc<LmdbStorage>` references held by background tasks to be
544    /// released (e.g. before reopening the same LMDB environment).
545    pub async fn shutdown(&mut self) {
546        self.shutdown.cancel();
547        for (i, mut handle) in self.task_handles.drain(..).enumerate() {
548            match tokio::time::timeout(std::time::Duration::from_secs(10), &mut handle).await {
549                Ok(Ok(())) => {}
550                Ok(Err(e)) if e.is_cancelled() => {}
551                Ok(Err(e)) => warn!("Replication task {i} panicked during shutdown: {e}"),
552                Err(_) => {
553                    warn!("Replication task {i} did not stop within 10s, aborting");
554                    handle.abort();
555                }
556            }
557        }
558    }
559
560    /// Trigger an early neighbor sync round.
561    ///
562    /// Useful after topology changes (new nodes joining, network heal after
563    /// partition) when the caller wants replication to converge faster than
564    /// the regular 10-20 minute cadence.
565    pub fn trigger_neighbor_sync(&self) {
566        self.sync_trigger.notify_one();
567    }
568
569    /// Execute fresh replication for a newly stored record.
570    pub async fn replicate_fresh(&self, key: &XorName, data: &[u8], proof_of_payment: &[u8]) {
571        fresh::replicate_fresh(
572            key,
573            data,
574            proof_of_payment,
575            &self.p2p_node,
576            &self.paid_list,
577            &self.config,
578            &self.send_semaphore,
579        )
580        .await;
581    }
582
583    // =======================================================================
584    // Background task launchers
585    // =======================================================================
586
587    /// Spawn a task that drains the fresh-write channel and triggers
588    /// replication for each newly-stored chunk.
589    fn start_fresh_write_drainer(&mut self) {
590        let Some(mut rx) = self.fresh_write_rx.take() else {
591            return;
592        };
593        let p2p = Arc::clone(&self.p2p_node);
594        let paid_list = Arc::clone(&self.paid_list);
595        let config = Arc::clone(&self.config);
596        let send_semaphore = Arc::clone(&self.send_semaphore);
597        let shutdown = self.shutdown.clone();
598
599        let handle = tokio::spawn(async move {
600            loop {
601                tokio::select! {
602                    () = shutdown.cancelled() => break,
603                    event = rx.recv() => {
604                        let Some(event) = event else { break };
605                        fresh::replicate_fresh(
606                            &event.key,
607                            &event.data,
608                            &event.payment_proof,
609                            &p2p,
610                            &paid_list,
611                            &config,
612                            &send_semaphore,
613                        )
614                        .await;
615                    }
616                }
617            }
618            debug!("Fresh-write drainer shut down");
619        });
620        self.task_handles.push(handle);
621    }
622
623    #[allow(clippy::too_many_lines)]
624    fn start_message_handler(&mut self) {
625        let mut p2p_events = self.p2p_node.subscribe_events();
626        let mut dht_events = self.p2p_node.dht_manager().subscribe_events();
627        let p2p = Arc::clone(&self.p2p_node);
628        let storage = Arc::clone(&self.storage);
629        let paid_list = Arc::clone(&self.paid_list);
630        let payment_verifier = Arc::clone(&self.payment_verifier);
631        let queues = Arc::clone(&self.queues);
632        let config = Arc::clone(&self.config);
633        let shutdown = self.shutdown.clone();
634        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
635        let bootstrap_state = Arc::clone(&self.bootstrap_state);
636        let sync_history = Arc::clone(&self.sync_history);
637        let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch);
638        let repair_proofs = Arc::clone(&self.repair_proofs);
639        let sync_trigger = Arc::clone(&self.sync_trigger);
640        let my_commitment_state = Arc::clone(&self.commitment_state);
641        let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer);
642        let ever_capable_peers = Arc::clone(&self.ever_capable_peers);
643        let recent_provers = Arc::clone(&self.recent_provers);
644        let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts);
645        let audit_timeout_strikes = Arc::clone(&self.audit_timeout_strikes);
646        let audit_on_gossip_cooldown = Arc::clone(&self.audit_on_gossip_cooldown);
647        let sync_state = Arc::clone(&self.sync_state);
648        let audit_responder_semaphore = Arc::clone(&self.audit_responder_semaphore);
649        let audit_responder_inflight = Arc::clone(&self.audit_responder_inflight);
650
651        // ADR-0002 gossip-audit trigger: bundled state so an ingested *changed*
652        // commitment can spawn a probabilistic, cooldown-gated subtree audit.
653        let gossip_audit = GossipAuditTrigger {
654            p2p_node: Arc::clone(&p2p),
655            config: Arc::clone(&config),
656            recent_provers: Arc::clone(&recent_provers),
657            sync_state: Arc::clone(&sync_state),
658            audit_timeout_strikes: Arc::clone(&audit_timeout_strikes),
659            cooldown: Arc::clone(&audit_on_gossip_cooldown),
660        };
661
662        let handle = tokio::spawn(async move {
663            loop {
664                tokio::select! {
665                    () = shutdown.cancelled() => break,
666                    event = p2p_events.recv() => {
667                        let Ok(event) = event else { continue };
668                        if let P2PEvent::Message {
669                            topic,
670                            source: Some(source),
671                            data,
672                            ..
673                        } = event {
674                            // Determine if this is a replication message
675                            // and whether it arrived via the /rr/ request-response
676                            // path (which wraps payloads in RequestResponseEnvelope).
677                            let rr_info = if topic == REPLICATION_PROTOCOL_ID {
678                                Some((data.clone(), None))
679                            } else if topic.starts_with(RR_PREFIX)
680                                && &topic[RR_PREFIX.len()..] == REPLICATION_PROTOCOL_ID
681                            {
682                                P2PNode::parse_request_envelope(&data)
683                                    .filter(|(_, is_resp, _)| !is_resp)
684                                    .map(|(msg_id, _, payload)| (payload, Some(msg_id)))
685                            } else {
686                                None
687                            };
688                            if let Some((payload, rr_message_id)) = rr_info {
689                                match handle_replication_message(
690                                    &source,
691                                    &payload,
692                                    &p2p,
693                                    &storage,
694                                    &paid_list,
695                                    &payment_verifier,
696                                    &queues,
697                                    &config,
698                                    &is_bootstrapping,
699                                    &bootstrap_state,
700                                    &sync_history,
701                                    &sync_cycle_epoch,
702                                    &repair_proofs,
703                                    &last_commitment_by_peer,
704                                    &ever_capable_peers,
705                                    &sig_verify_attempts,
706                                    &my_commitment_state,
707                                    &gossip_audit,
708                                    &audit_responder_semaphore,
709                                    &audit_responder_inflight,
710                                    rr_message_id.as_deref(),
711                                ).await {
712                                    Ok(()) => {}
713                                    Err(e) => {
714                                        debug!(
715                                            "Replication message from {source} error: {e}"
716                                        );
717                                    }
718                                }
719                            }
720                        }
721                    }
722                    // Gap 4: Topology churn handling (Section 13).
723                    //
724                    // The DHT routing table emits KClosestPeersChanged when the
725                    // K-closest peer set actually changes, which is the precise
726                    // signal for triggering neighbor sync. This replaces the
727                    // previous approach of checking every PeerConnected /
728                    // PeerDisconnected event against the close group.
729                    dht_event = dht_events.recv() => {
730                        let Ok(dht_event) = dht_event else { continue };
731                        match dht_event {
732                            DhtNetworkEvent::KClosestPeersChanged { old, new } => {
733                                let old_peers = old
734                                    .iter()
735                                    .take(config.neighbor_sync_scope)
736                                    .copied()
737                                    .collect::<HashSet<_>>();
738                                let new_scoped = new
739                                    .iter()
740                                    .take(config.neighbor_sync_scope)
741                                    .copied()
742                                    .collect::<Vec<_>>();
743                                let new_peers =
744                                    new_scoped.iter().copied().collect::<HashSet<_>>();
745                                let entrants = new_scoped
746                                    .iter()
747                                    .copied()
748                                    .filter(|peer| !old_peers.contains(peer))
749                                    .collect::<Vec<_>>();
750                                let entrant_count = entrants.len();
751                                let (priority_insertions, sync_removals) = {
752                                    let mut state = sync_state.write().await;
753                                    let sync_removals = state.retain_sync_peers(&new_peers);
754                                    let priority_insertions = state.queue_priority_peers(entrants);
755                                    (priority_insertions, sync_removals)
756                                };
757                                if priority_insertions > 0 {
758                                    debug!(
759                                        "K-closest peers changed, queued {priority_insertions}/{entrant_count} new close peers for priority neighbor sync and pruned {sync_removals} departed pending sync entries"
760                                    );
761                                } else {
762                                    debug!(
763                                        "K-closest peers changed, no additional close peers queued, pruned {sync_removals} departed pending sync entries, triggering early neighbor sync"
764                                    );
765                                }
766                                sync_trigger.notify_one();
767                            }
768                            DhtNetworkEvent::PeerRemoved { peer_id } => {
769                                sync_state.write().await.remove_peer(&peer_id);
770                                repair_proofs.write().await.remove_peer(&peer_id);
771                                // v12: drop the commitment bytes and the
772                                // recent-prover credit so a churn / sybil
773                                // attacker cannot leave behind one
774                                // StorageCommitment per identity in
775                                // `last_commitment_by_peer`. Also drop the
776                                // sig-verify rate-limit timestamp.
777                                last_commitment_by_peer.write().await.remove(&peer_id);
778                                recent_provers.write().await.forget_peer(&peer_id);
779                                sig_verify_attempts.write().await.remove(&peer_id);
780                                // Drop the timeout-strike entry too, so a
781                                // departed peer leaves no residual (keeps this
782                                // map bounded under churn, like its siblings).
783                                audit_timeout_strikes.write().await.remove(&peer_id);
784                                // Same for the gossip-audit cooldown (ADR-0002).
785                                audit_on_gossip_cooldown.write().await.remove(&peer_id);
786                                // The sticky `commitment_capable` flag is
787                                // preserved orthogonally via
788                                // `ever_capable_peers` — even after this
789                                // removal, a re-joining peer continues to
790                                // be treated as v12-capable rather than
791                                // legacy (§3 shield).
792                            }
793                            _ => {}
794                        }
795                    }
796                }
797            }
798            debug!("Replication message handler shut down");
799        });
800        self.task_handles.push(handle);
801    }
802
803    fn start_neighbor_sync_loop(&mut self) {
804        let p2p = Arc::clone(&self.p2p_node);
805        let storage = Arc::clone(&self.storage);
806        let paid_list = Arc::clone(&self.paid_list);
807        let queues = Arc::clone(&self.queues);
808        let config = Arc::clone(&self.config);
809        let shutdown = self.shutdown.clone();
810        let sync_state = Arc::clone(&self.sync_state);
811        let sync_history = Arc::clone(&self.sync_history);
812        let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch);
813        let repair_proofs = Arc::clone(&self.repair_proofs);
814        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
815        let bootstrap_state = Arc::clone(&self.bootstrap_state);
816        let sync_trigger = Arc::clone(&self.sync_trigger);
817        let commitment_state = Arc::clone(&self.commitment_state);
818        let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer);
819        let ever_capable_peers = Arc::clone(&self.ever_capable_peers);
820        let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts);
821        // ADR-0002: a peer's commitment also arrives on the sync RESPONSE path
822        // (we initiated, they piggybacked theirs). Carry a gossip-audit trigger
823        // here too so a peer that only ever answers — never initiates sync —
824        // is still audited; otherwise it could fully evade auditing.
825        let gossip_audit = GossipAuditTrigger {
826            p2p_node: Arc::clone(&p2p),
827            config: Arc::clone(&config),
828            recent_provers: Arc::clone(&self.recent_provers),
829            sync_state: Arc::clone(&sync_state),
830            audit_timeout_strikes: Arc::clone(&self.audit_timeout_strikes),
831            cooldown: Arc::clone(&self.audit_on_gossip_cooldown),
832        };
833
834        let handle = tokio::spawn(async move {
835            loop {
836                let interval = config.random_neighbor_sync_interval();
837                tokio::select! {
838                    () = shutdown.cancelled() => break,
839                    () = tokio::time::sleep(interval) => {}
840                    () = sync_trigger.notified() => {
841                        debug!("Neighbor sync triggered by topology change");
842                    }
843                }
844                // Wrap the sync round in a select so shutdown cancels
845                // in-progress network operations rather than waiting for
846                // the full round to complete.
847                tokio::select! {
848                    () = shutdown.cancelled() => break,
849                    () = run_neighbor_sync_round(
850                        &p2p,
851                        &storage,
852                        &paid_list,
853                        &queues,
854                        &config,
855                        &sync_state,
856                        &sync_history,
857                        &sync_cycle_epoch,
858                        &repair_proofs,
859                        &is_bootstrapping,
860                        &bootstrap_state,
861                        &commitment_state,
862                        &last_commitment_by_peer,
863                        &ever_capable_peers,
864                        &sig_verify_attempts,
865                        &gossip_audit,
866                    ) => {}
867                }
868            }
869            debug!("Neighbor sync loop shut down");
870        });
871        self.task_handles.push(handle);
872    }
873
874    fn start_self_lookup_loop(&mut self) {
875        let p2p = Arc::clone(&self.p2p_node);
876        let config = Arc::clone(&self.config);
877        let shutdown = self.shutdown.clone();
878
879        let handle = tokio::spawn(async move {
880            loop {
881                let interval = config.random_self_lookup_interval();
882                tokio::select! {
883                    () = shutdown.cancelled() => break,
884                    () = tokio::time::sleep(interval) => {
885                        if let Err(e) = p2p.dht_manager().trigger_self_lookup().await {
886                            debug!("Self-lookup failed: {e}");
887                        }
888                    }
889                }
890            }
891            debug!("Self-lookup loop shut down");
892        });
893        self.task_handles.push(handle);
894    }
895
896    /// Periodic responsible-chunk audit loop (audit #2): every
897    /// [`ReplicationConfig::random_audit_tick_interval`] (~10-20 min), audit one
898    /// eligible close peer for the chunks it *should* be storing (by
899    /// responsibility and prior repair hint), independent of the gossip-triggered
900    /// storage-commitment audit. Waits for bootstrap to drain, then runs one tick
901    /// immediately and periodically thereafter.
902    fn start_audit_loop(&mut self) {
903        let p2p = Arc::clone(&self.p2p_node);
904        let storage = Arc::clone(&self.storage);
905        let config = Arc::clone(&self.config);
906        let shutdown = self.shutdown.clone();
907        let sync_history = Arc::clone(&self.sync_history);
908        let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch);
909        let repair_proofs = Arc::clone(&self.repair_proofs);
910        let bootstrap_state = Arc::clone(&self.bootstrap_state);
911        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
912        let sync_state = Arc::clone(&self.sync_state);
913        // Needed so the responsible-chunk audit routes failures through the same
914        // strike/grace path as the storage-commitment audit (timeouts graced,
915        // not penalised on the first occurrence) and can revoke holder credit on
916        // a confirmed failure.
917        let recent_provers = Arc::clone(&self.recent_provers);
918        let audit_timeout_strikes = Arc::clone(&self.audit_timeout_strikes);
919
920        let handle = tokio::spawn(async move {
921            // Invariant 19: wait for bootstrap to drain before starting audits.
922            loop {
923                tokio::select! {
924                    () = shutdown.cancelled() => return,
925                    () = tokio::time::sleep(
926                        std::time::Duration::from_secs(BOOTSTRAP_DRAIN_CHECK_SECS)
927                    ) => {
928                        if bootstrap_state.read().await.is_drained() {
929                            break;
930                        }
931                    }
932                }
933            }
934
935            // Run one audit tick immediately after bootstrap drain.
936            {
937                let bootstrapping = *is_bootstrapping.read().await;
938                let result = {
939                    let history = sync_history.read().await;
940                    let current_sync_epoch = *sync_cycle_epoch.read().await;
941                    audit::audit_tick_with_repair_proofs(
942                        &p2p,
943                        &storage,
944                        &config,
945                        &history,
946                        &repair_proofs,
947                        current_sync_epoch,
948                        bootstrapping,
949                    )
950                    .await
951                };
952                handle_audit_result(
953                    &result,
954                    &p2p,
955                    &sync_state,
956                    &recent_provers,
957                    &audit_timeout_strikes,
958                    &config,
959                )
960                .await;
961            }
962
963            // Then run periodically.
964            loop {
965                let interval = config.random_audit_tick_interval();
966                tokio::select! {
967                    () = shutdown.cancelled() => break,
968                    () = tokio::time::sleep(interval) => {
969                        let bootstrapping = *is_bootstrapping.read().await;
970                        let result = {
971                            let history = sync_history.read().await;
972                            let current_sync_epoch = *sync_cycle_epoch.read().await;
973                            audit::audit_tick_with_repair_proofs(
974                                &p2p,
975                                &storage,
976                                &config,
977                                &history,
978                                &repair_proofs,
979                                current_sync_epoch,
980                                bootstrapping,
981                            )
982                            .await
983                        };
984                        handle_audit_result(
985                    &result,
986                    &p2p,
987                    &sync_state,
988                    &recent_provers,
989                    &audit_timeout_strikes,
990                    &config,
991                )
992                .await;
993                    }
994                }
995            }
996            debug!("Audit loop shut down");
997        });
998        self.task_handles.push(handle);
999    }
1000
1001    /// Periodically rebuild + sign + rotate the responder's storage
1002    /// commitment.
1003    ///
1004    /// Phase 3 of the v12 storage-bound audit. Once per
1005    /// [`COMMITMENT_ROTATION_INTERVAL_SECS`], the responder reads the
1006    /// current LMDB key set, builds a Merkle tree (for content-addressed
1007    /// chunks `bytes_hash == key`, so no chunk re-read is needed), signs
1008    /// the root with the node's `MlDsaSecretKey`, and rotates the result
1009    /// into `commitment_state`. Old `previous` slot is dropped by the
1010    /// rotate (per `ResponderCommitmentState::rotate`).
1011    ///
1012    /// Skips if the key set is empty (no commitment to make) — the
1013    /// auditor side falls back to the legacy plain-digest path for
1014    /// peers that have never gossiped a commitment.
1015    fn start_commitment_rotation_loop(&mut self) {
1016        let storage = Arc::clone(&self.storage);
1017        let identity = Arc::clone(&self.identity);
1018        let commitment_state = Arc::clone(&self.commitment_state);
1019        let shutdown = self.shutdown.clone();
1020        let p2p = Arc::clone(&self.p2p_node);
1021        let config = Arc::clone(&self.config);
1022        let sync_trigger = Arc::clone(&self.sync_trigger);
1023        let recent_provers = Arc::clone(&self.recent_provers);
1024
1025        let handle = tokio::spawn(async move {
1026            // Build the first commitment immediately on startup so a
1027            // restarted node can answer commitment-bound audits right
1028            // away — otherwise current() stays None for a full rotation
1029            // interval and audits silently fall back to legacy.
1030            //
1031            // After the first build, trigger an immediate neighbor-sync
1032            // round so the new commitment gossips out within seconds.
1033            // Without this, after a restart remote auditors keep pinning
1034            // the pre-restart (rotated-away) hash until their normal
1035            // sync cadence elapses — up to 1 h in the worst case,
1036            // during which time commitment-bound audits hit "unknown
1037            // commitment hash" -> Idle no-ops.
1038            // ML-DSA signatures are randomized so we cannot reproduce
1039            // the pre-restart hash; the only honest path to recovery
1040            // is fast re-gossip.
1041            if let Err(e) =
1042                rebuild_and_rotate_commitment(&storage, &identity, &commitment_state, &p2p, &config)
1043                    .await
1044            {
1045                warn!("Initial commitment build failed: {e}");
1046            } else {
1047                sync_trigger.notify_one();
1048            }
1049            loop {
1050                tokio::select! {
1051                    () = shutdown.cancelled() => break,
1052                    () = tokio::time::sleep(
1053                        std::time::Duration::from_secs(COMMITMENT_ROTATION_INTERVAL_SECS)
1054                    ) => {
1055                        if let Err(e) = rebuild_and_rotate_commitment(
1056                            &storage,
1057                            &identity,
1058                            &commitment_state,
1059                            &p2p,
1060                            &config,
1061                        ).await {
1062                            warn!("Commitment rotation failed: {e}");
1063                        }
1064                        // Piggyback a sweep of expired recent_provers
1065                        // entries on the rotation tick (same cadence,
1066                        // 1 h). is_credited_holder already honours the
1067                        // TTL on read, but the sweep reclaims memory
1068                        // for entries we'll never re-read.
1069                        let dropped = recent_provers.write().await.sweep_expired(
1070                            std::time::Instant::now()
1071                        );
1072                        if dropped > 0 {
1073                            debug!("recent_provers: swept {dropped} expired entries");
1074                        }
1075                    }
1076                }
1077            }
1078            debug!("Commitment rotation loop shut down");
1079        });
1080        self.task_handles.push(handle);
1081    }
1082
1083    #[allow(clippy::too_many_lines, clippy::option_if_let_else)]
1084    fn start_fetch_worker(&mut self) {
1085        let p2p = Arc::clone(&self.p2p_node);
1086        let storage = Arc::clone(&self.storage);
1087        let queues = Arc::clone(&self.queues);
1088        let config = Arc::clone(&self.config);
1089        let shutdown = self.shutdown.clone();
1090        let bootstrap_state = Arc::clone(&self.bootstrap_state);
1091        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
1092        let bootstrap_complete_notify = Arc::clone(&self.bootstrap_complete_notify);
1093        let concurrency = max_parallel_fetch();
1094
1095        info!("Fetch worker concurrency set to {concurrency} (hardware threads)");
1096
1097        let handle = tokio::spawn(async move {
1098            // Each in-flight future yields (key, Option<FetchOutcome>) so we
1099            // always recover the key — even if the inner task panics.
1100            let mut in_flight = FuturesUnordered::<FetchFuture>::new();
1101
1102            loop {
1103                // Fill up to `concurrency` slots from the queue.
1104                {
1105                    let mut q = queues.write().await;
1106                    while in_flight.len() < concurrency {
1107                        let Some(candidate) = q.dequeue_fetch() else {
1108                            break;
1109                        };
1110                        let Some(&source) = candidate.sources.first() else {
1111                            warn!(
1112                                "Fetch candidate {} has no sources — dropping",
1113                                hex::encode(candidate.key)
1114                            );
1115                            continue;
1116                        };
1117                        q.start_fetch(candidate.key, source, candidate.sources.clone());
1118
1119                        let p2p = Arc::clone(&p2p);
1120                        let storage = Arc::clone(&storage);
1121                        let config = Arc::clone(&config);
1122                        let token = shutdown.clone();
1123                        let fetch_key = candidate.key;
1124                        in_flight.push(Box::pin(async move {
1125                            let handle = tokio::spawn(async move {
1126                                // Cancel-aware: abort when the engine shuts down.
1127                                tokio::select! {
1128                                    () = token.cancelled() => FetchOutcome {
1129                                        key: fetch_key,
1130                                        result: FetchResult::SourceFailed,
1131                                    },
1132                                    outcome = execute_single_fetch(
1133                                        p2p, storage, config, fetch_key, source,
1134                                    ) => outcome,
1135                                }
1136                            });
1137                            match handle.await {
1138                                Ok(outcome) => (outcome.key, Some(outcome)),
1139                                Err(e) => {
1140                                    error!(
1141                                        "Fetch task for {} panicked: {e}",
1142                                        hex::encode(fetch_key)
1143                                    );
1144                                    (fetch_key, None)
1145                                }
1146                            }
1147                        }));
1148                    }
1149                } // release queues write lock
1150
1151                if in_flight.is_empty() {
1152                    // No work — wait for new items or shutdown.
1153                    tokio::select! {
1154                        () = shutdown.cancelled() => break,
1155                        () = tokio::time::sleep(
1156                            std::time::Duration::from_millis(FETCH_WORKER_POLL_MS)
1157                        ) => continue,
1158                    }
1159                }
1160
1161                // Wait for the next fetch to complete and process the result.
1162                tokio::select! {
1163                    () = shutdown.cancelled() => break,
1164                    Some((key, maybe_outcome)) = in_flight.next() => {
1165                        let mut q = queues.write().await;
1166                        let terminal = if let Some(outcome) = maybe_outcome {
1167                            match outcome.result {
1168                                FetchResult::Stored => {
1169                                    q.complete_fetch(&key);
1170                                    true
1171                                }
1172                                FetchResult::IntegrityFailed | FetchResult::SourceFailed => {
1173                                    if let Some(next_peer) = q.retry_fetch(&key) {
1174                                        // Spawn a new fetch task for the next source.
1175                                        let p2p = Arc::clone(&p2p);
1176                                        let storage = Arc::clone(&storage);
1177                                        let config = Arc::clone(&config);
1178                                        let token = shutdown.clone();
1179                                        let fetch_key = key;
1180                                        in_flight.push(Box::pin(async move {
1181                                            let handle = tokio::spawn(async move {
1182                                                tokio::select! {
1183                                                    () = token.cancelled() => FetchOutcome {
1184                                                        key: fetch_key,
1185                                                        result: FetchResult::SourceFailed,
1186                                                    },
1187                                                    outcome = execute_single_fetch(
1188                                                        p2p, storage, config, fetch_key, next_peer,
1189                                                    ) => outcome,
1190                                                }
1191                                            });
1192                                            match handle.await {
1193                                                Ok(outcome) => (outcome.key, Some(outcome)),
1194                                                Err(e) => {
1195                                                    error!(
1196                                                        "Fetch task for {} panicked: {e}",
1197                                                        hex::encode(fetch_key)
1198                                                    );
1199                                                    (fetch_key, None)
1200                                                }
1201                                            }
1202                                        }));
1203                                        false
1204                                    } else {
1205                                        q.complete_fetch(&key);
1206                                        true
1207                                    }
1208                                }
1209                            }
1210                        } else {
1211                            // Task panicked — reclaim the in-flight slot.
1212                            q.complete_fetch(&key);
1213                            true
1214                        };
1215
1216                        // Shrink bootstrap pending set on terminal exit.
1217                        if terminal {
1218                            drop(q); // release queues lock before acquiring bootstrap_state
1219                            if !bootstrap_state.read().await.is_drained() {
1220                                bootstrap_state.write().await.remove_key(&key);
1221                                let q = queues.read().await;
1222                                if bootstrap::check_bootstrap_drained(
1223                                    &bootstrap_state,
1224                                    &q,
1225                                )
1226                                .await
1227                                {
1228                                    complete_bootstrap(
1229                                        &is_bootstrapping,
1230                                        &bootstrap_complete_notify,
1231                                    ).await;
1232                                }
1233                            }
1234                        }
1235                    }
1236                }
1237            }
1238
1239            // Cancel and drain remaining in-flight fetches on shutdown.
1240            // The CancellationToken is already cancelled by this point, so
1241            // spawned tasks will see cancellation via their select! branches.
1242            while in_flight.next().await.is_some() {}
1243            debug!("Fetch worker shut down");
1244        });
1245        self.task_handles.push(handle);
1246    }
1247
1248    fn start_verification_worker(&mut self) {
1249        let p2p = Arc::clone(&self.p2p_node);
1250        let storage = Arc::clone(&self.storage);
1251        let queues = Arc::clone(&self.queues);
1252        let paid_list = Arc::clone(&self.paid_list);
1253        let config = Arc::clone(&self.config);
1254        let shutdown = self.shutdown.clone();
1255        let bootstrap_state = Arc::clone(&self.bootstrap_state);
1256        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
1257        let bootstrap_complete_notify = Arc::clone(&self.bootstrap_complete_notify);
1258        let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer);
1259        let ever_capable_peers = Arc::clone(&self.ever_capable_peers);
1260        let recent_provers = Arc::clone(&self.recent_provers);
1261
1262        let handle = tokio::spawn(async move {
1263            loop {
1264                tokio::select! {
1265                    () = shutdown.cancelled() => break,
1266                    () = tokio::time::sleep(
1267                        std::time::Duration::from_millis(VERIFICATION_WORKER_POLL_MS)
1268                    ) => {
1269                        let ctx = VerificationCycleContext {
1270                            p2p_node: &p2p,
1271                            paid_list: &paid_list,
1272                            storage: &storage,
1273                            queues: &queues,
1274                            config: &config,
1275                            bootstrap_state: &bootstrap_state,
1276                            is_bootstrapping: &is_bootstrapping,
1277                            bootstrap_complete_notify: &bootstrap_complete_notify,
1278                            last_commitment_by_peer: &last_commitment_by_peer,
1279                            ever_capable_peers: &ever_capable_peers,
1280                            recent_provers: &recent_provers,
1281                        };
1282                        run_verification_cycle(ctx).await;
1283                    }
1284                }
1285            }
1286            debug!("Verification worker shut down");
1287        });
1288        self.task_handles.push(handle);
1289    }
1290
1291    /// Gap 3: Run a one-shot bootstrap sync on startup.
1292    ///
1293    /// Waits for saorsa-core to emit `DhtNetworkEvent::BootstrapComplete`
1294    /// (indicating the routing table is populated) before snapshotting
1295    /// close neighbors. Falls back after a timeout so bootstrap nodes
1296    /// (which have no peers and therefore never receive the event) still
1297    /// proceed.
1298    ///
1299    /// After the gate, finds close neighbors, syncs with each in
1300    /// round-robin batches, admits returned hints into the verification
1301    /// pipeline, and tracks discovered keys for bootstrap drain detection.
1302    #[allow(clippy::too_many_lines)]
1303    fn start_bootstrap_sync(
1304        &mut self,
1305        dht_events: tokio::sync::broadcast::Receiver<DhtNetworkEvent>,
1306    ) {
1307        let p2p = Arc::clone(&self.p2p_node);
1308        let storage = Arc::clone(&self.storage);
1309        let paid_list = Arc::clone(&self.paid_list);
1310        let queues = Arc::clone(&self.queues);
1311        let config = Arc::clone(&self.config);
1312        let shutdown = self.shutdown.clone();
1313        let is_bootstrapping = Arc::clone(&self.is_bootstrapping);
1314        let bootstrap_state = Arc::clone(&self.bootstrap_state);
1315        let bootstrap_complete_notify = Arc::clone(&self.bootstrap_complete_notify);
1316        let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch);
1317        let repair_proofs = Arc::clone(&self.repair_proofs);
1318        let my_commitment_state = Arc::clone(&self.commitment_state);
1319        let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer);
1320        let ever_capable_peers = Arc::clone(&self.ever_capable_peers);
1321        let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts);
1322
1323        let handle = tokio::spawn(async move {
1324            // Wait for DHT bootstrap to complete before snapshotting
1325            // neighbors. The routing table is empty until saorsa-core
1326            // finishes its FIND_NODE rounds and bucket refreshes.
1327            let gate = bootstrap::wait_for_bootstrap_complete(
1328                dht_events,
1329                config.bootstrap_complete_timeout_secs,
1330                &shutdown,
1331            )
1332            .await;
1333
1334            if gate == bootstrap::BootstrapGateResult::Shutdown {
1335                return;
1336            }
1337
1338            let self_id = *p2p.peer_id();
1339            let neighbors =
1340                neighbor_sync::snapshot_close_neighbors(&p2p, &self_id, config.neighbor_sync_scope)
1341                    .await;
1342
1343            if neighbors.is_empty() {
1344                info!("Bootstrap sync: no close neighbors found, marking drained");
1345                bootstrap::mark_bootstrap_drained(&bootstrap_state).await;
1346                complete_bootstrap(&is_bootstrapping, &bootstrap_complete_notify).await;
1347                return;
1348            }
1349
1350            let neighbor_count = neighbors.len();
1351            info!("Bootstrap sync: syncing with {neighbor_count} close neighbors");
1352
1353            // Process neighbors in batches of NEIGHBOR_SYNC_PEER_COUNT.
1354            for batch in neighbors.chunks(config.neighbor_sync_peer_count) {
1355                if shutdown.is_cancelled() {
1356                    break;
1357                }
1358
1359                let mut hints_by_peer = neighbor_sync::build_sync_hints_for_peers(
1360                    batch,
1361                    &storage,
1362                    &paid_list,
1363                    &p2p,
1364                    config.close_group_size,
1365                    config.paid_list_close_group_size,
1366                )
1367                .await;
1368
1369                for peer in batch {
1370                    if shutdown.is_cancelled() {
1371                        break;
1372                    }
1373
1374                    // Re-read on each iteration so peers see current state.
1375                    let bootstrapping = *is_bootstrapping.read().await;
1376
1377                    bootstrap::increment_pending_requests(&bootstrap_state, 1).await;
1378
1379                    let hints = hints_by_peer.remove(peer).unwrap_or_default();
1380                    let outcome = neighbor_sync::sync_with_peer_with_hints(
1381                        peer,
1382                        &p2p,
1383                        &config,
1384                        bootstrapping,
1385                        hints,
1386                        // Atomically snapshot + mark-gossiped: emitted in the
1387                        // bootstrap-sync request, so we stay answerable for it
1388                        // (ADR-0002). One critical section avoids a TOCTOU where a
1389                        // concurrent retire/rotate drops the slot between read and
1390                        // mark.
1391                        my_commitment_state
1392                            .current_for_gossip()
1393                            .map(|b| b.commitment().clone()),
1394                    )
1395                    .await;
1396
1397                    bootstrap::decrement_pending_requests(&bootstrap_state, 1).await;
1398
1399                    if let Some(outcome) = outcome {
1400                        // Ingest the peer's piggybacked commitment from the
1401                        // response (same verification as the request path).
1402                        // Bootstrap is the FIRST gossip we receive from most
1403                        // peers, so this populates last_commitment_by_peer.
1404                        //
1405                        // We intentionally do NOT trigger a gossip-audit here:
1406                        // during bootstrap this node may itself still be
1407                        // bootstrapping (audits are gated on that), and the
1408                        // close-group/RT view is not yet stable. The peer is
1409                        // audited on the first STEADY-STATE neighbor-sync round
1410                        // after bootstrap drains (request + response paths both
1411                        // trigger), which is within one sync cycle — so caching
1412                        // the commitment here is sufficient and there is no
1413                        // coverage gap (ADR-0002).
1414                        ingest_peer_commitment(
1415                            peer,
1416                            outcome.response.commitment.as_ref(),
1417                            &p2p,
1418                            &last_commitment_by_peer,
1419                            &ever_capable_peers,
1420                            &sig_verify_attempts,
1421                        )
1422                        .await; // sig_verify_attempts in scope from line ~1080
1423
1424                        if !outcome.response.bootstrapping {
1425                            record_sent_replica_hints(
1426                                peer,
1427                                &outcome.sent_replica_hints,
1428                                &repair_proofs,
1429                                &sync_cycle_epoch,
1430                            )
1431                            .await;
1432                            // Admit hints into verification pipeline.
1433                            let outcome = admit_and_queue_hints(
1434                                &self_id,
1435                                peer,
1436                                &outcome.response.replica_hints,
1437                                &outcome.response.paid_hints,
1438                                &p2p,
1439                                &config,
1440                                &storage,
1441                                &paid_list,
1442                                &queues,
1443                            )
1444                            .await;
1445
1446                            // Track discovered keys for drain detection.
1447                            if !outcome.discovered.is_empty() {
1448                                bootstrap::track_discovered_keys(
1449                                    &bootstrap_state,
1450                                    &outcome.discovered,
1451                                )
1452                                .await;
1453                            }
1454
1455                            // Record / retire capacity rejections so the
1456                            // drain check correctly reflects whether each
1457                            // source still owes us re-hinted work after
1458                            // queue overflow.
1459                            if outcome.capacity_rejected_count > 0 {
1460                                bootstrap::note_capacity_rejected(&bootstrap_state, *peer).await;
1461                            } else {
1462                                bootstrap::clear_capacity_rejected(&bootstrap_state, peer).await;
1463                            }
1464                        }
1465                    }
1466                }
1467            }
1468
1469            // Check drain condition.
1470            {
1471                let q = queues.read().await;
1472                if bootstrap::check_bootstrap_drained(&bootstrap_state, &q).await {
1473                    complete_bootstrap(&is_bootstrapping, &bootstrap_complete_notify).await;
1474                }
1475            }
1476
1477            info!("Bootstrap sync completed");
1478        });
1479        self.task_handles.push(handle);
1480    }
1481}
1482
1483// ===========================================================================
1484// Free functions for background tasks
1485// ===========================================================================
1486
1487/// RAII admission for one audit-responder task: holds the GLOBAL permit and,
1488/// on drop, decrements the PER-PEER in-flight count. Moving this into the
1489/// spawned task ties both bounds to the task's exact lifetime — no manual
1490/// decrement to forget on an early return or panic.
1491struct AuditResponderGuard {
1492    _permit: tokio::sync::OwnedSemaphorePermit,
1493    inflight: Arc<RwLock<HashMap<PeerId, u32>>>,
1494    peer: PeerId,
1495}
1496
1497impl Drop for AuditResponderGuard {
1498    fn drop(&mut self) {
1499        // Decrement (and prune to keep the map bounded) without blocking the
1500        // async runtime: a short lock on a tiny map.
1501        //
1502        // Fast path: if the (uncontended, tiny) lock is free, decrement inline
1503        // with no spawn. Otherwise defer to a task — but only if a runtime is
1504        // actually current, so `Drop` during shutdown (no runtime) can never
1505        // panic. A missed decrement at shutdown is harmless: the whole map is
1506        // being dropped with the engine.
1507        let peer = self.peer;
1508        if let Ok(mut map) = self.inflight.try_write() {
1509            if let Some(n) = map.get_mut(&peer) {
1510                *n = n.saturating_sub(1);
1511                if *n == 0 {
1512                    map.remove(&peer);
1513                }
1514            }
1515            return;
1516        }
1517        if let Ok(handle) = tokio::runtime::Handle::try_current() {
1518            let inflight = Arc::clone(&self.inflight);
1519            handle.spawn(async move {
1520                let mut map = inflight.write().await;
1521                if let Some(n) = map.get_mut(&peer) {
1522                    *n = n.saturating_sub(1);
1523                    if *n == 0 {
1524                        map.remove(&peer);
1525                    }
1526                }
1527            });
1528        }
1529    }
1530}
1531
1532/// Try to admit one audit-responder task for `source`: take a global permit AND
1533/// a per-peer slot (both bounded). Returns `None` (caller drops the challenge,
1534/// which the auditor graces as a timeout) if either ceiling is hit, so one
1535/// flooder can neither exhaust the global pool's effect on others nor exceed
1536/// its own per-peer share (codex-r2 A).
1537async fn admit_audit_responder(
1538    semaphore: &Arc<Semaphore>,
1539    inflight: &Arc<RwLock<HashMap<PeerId, u32>>>,
1540    source: &PeerId,
1541) -> Option<AuditResponderGuard> {
1542    // Per-peer cap first (cheap, and the fairness-critical bound), committed
1543    // under the write lock so concurrent challenges from the same peer can't
1544    // both slip past the cap.
1545    {
1546        let mut map = inflight.write().await;
1547        let entry = map.entry(*source).or_insert(0);
1548        if *entry >= MAX_AUDIT_RESPONSES_PER_PEER {
1549            return None;
1550        }
1551        *entry += 1;
1552    }
1553    // Then the global ceiling. If it's exhausted, give back the per-peer slot we
1554    // just claimed so it isn't leaked.
1555    let Ok(permit) = Arc::clone(semaphore).try_acquire_owned() else {
1556        let mut map = inflight.write().await;
1557        if let Some(n) = map.get_mut(source) {
1558            *n = n.saturating_sub(1);
1559            if *n == 0 {
1560                map.remove(source);
1561            }
1562        }
1563        return None;
1564    };
1565    Some(AuditResponderGuard {
1566        _permit: permit,
1567        inflight: Arc::clone(inflight),
1568        peer: *source,
1569    })
1570}
1571
1572/// Handle an incoming replication protocol message.
1573///
1574/// When `rr_message_id` is `Some`, the request arrived via the `/rr/`
1575/// request-response path and the response must be sent via `send_response`
1576/// so saorsa-core can route it back to the waiting `send_request` caller.
1577#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1578async fn handle_replication_message(
1579    source: &PeerId,
1580    data: &[u8],
1581    p2p_node: &Arc<P2PNode>,
1582    storage: &Arc<LmdbStorage>,
1583    paid_list: &Arc<PaidList>,
1584    payment_verifier: &Arc<PaymentVerifier>,
1585    queues: &Arc<RwLock<ReplicationQueues>>,
1586    config: &ReplicationConfig,
1587    is_bootstrapping: &Arc<RwLock<bool>>,
1588    bootstrap_state: &Arc<RwLock<BootstrapState>>,
1589    sync_history: &Arc<RwLock<HashMap<PeerId, PeerSyncRecord>>>,
1590    sync_cycle_epoch: &Arc<RwLock<u64>>,
1591    repair_proofs: &Arc<RwLock<RepairProofs>>,
1592    last_commitment_by_peer: &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
1593    ever_capable_peers: &Arc<RwLock<HashSet<PeerId>>>,
1594    sig_verify_attempts: &Arc<RwLock<HashMap<PeerId, Instant>>>,
1595    my_commitment_state: &Arc<ResponderCommitmentState>,
1596    gossip_audit: &GossipAuditTrigger,
1597    audit_responder_semaphore: &Arc<Semaphore>,
1598    audit_responder_inflight: &Arc<RwLock<HashMap<PeerId, u32>>>,
1599    rr_message_id: Option<&str>,
1600) -> Result<()> {
1601    let msg = ReplicationMessage::decode(data)
1602        .map_err(|e| Error::Protocol(format!("Failed to decode replication message: {e}")))?;
1603
1604    match msg.body {
1605        ReplicationMessageBody::FreshReplicationOffer(ref offer) => {
1606            handle_fresh_offer(
1607                source,
1608                offer,
1609                storage,
1610                paid_list,
1611                payment_verifier,
1612                p2p_node,
1613                config,
1614                msg.request_id,
1615                rr_message_id,
1616            )
1617            .await
1618        }
1619        ReplicationMessageBody::PaidNotify(ref notify) => {
1620            handle_paid_notify(
1621                source,
1622                notify,
1623                paid_list,
1624                payment_verifier,
1625                p2p_node,
1626                config,
1627            )
1628            .await
1629        }
1630        ReplicationMessageBody::NeighborSyncRequest(ref request) => {
1631            let bootstrapping = *is_bootstrapping.read().await;
1632            // Phase-3 storage-bound audit: store the sender's
1633            // commitment for use as `expected_commitment_hash` in
1634            // future audits. Verify signature before storing so a peer
1635            // cannot inject a forged commitment for someone else.
1636            if let Some(target) = ingest_peer_commitment(
1637                source,
1638                request.commitment.as_ref(),
1639                p2p_node,
1640                last_commitment_by_peer,
1641                ever_capable_peers,
1642                sig_verify_attempts,
1643            )
1644            .await
1645            {
1646                maybe_trigger_gossip_audit(gossip_audit, source, target).await;
1647            }
1648            handle_neighbor_sync_request(
1649                source,
1650                request,
1651                p2p_node,
1652                storage,
1653                paid_list,
1654                queues,
1655                config,
1656                bootstrapping,
1657                bootstrap_state,
1658                sync_history,
1659                sync_cycle_epoch,
1660                repair_proofs,
1661                // Atomically snapshot + mark-gossiped: emitted in the sync
1662                // response, so we must stay answerable for it (ADR-0002).
1663                my_commitment_state
1664                    .current_for_gossip()
1665                    .map(|b| b.commitment().clone()),
1666                msg.request_id,
1667                rr_message_id,
1668            )
1669            .await
1670        }
1671        ReplicationMessageBody::VerificationRequest(ref request) => {
1672            handle_verification_request(
1673                source,
1674                request,
1675                storage,
1676                paid_list,
1677                p2p_node,
1678                msg.request_id,
1679                rr_message_id,
1680            )
1681            .await
1682        }
1683        ReplicationMessageBody::FetchRequest(ref request) => {
1684            handle_fetch_request(
1685                source,
1686                request,
1687                storage,
1688                p2p_node,
1689                msg.request_id,
1690                rr_message_id,
1691            )
1692            .await
1693        }
1694        ReplicationMessageBody::AuditChallenge(challenge) => {
1695            // Responsible-chunk audit (audit #2) responder: answer with per-key
1696            // possession digests. This same handler also answers the
1697            // prune-confirmation audit, which sends the same `AuditChallenge`
1698            // wire message.
1699            //
1700            // Answering digests the stored bytes of every challenged key, so —
1701            // like the subtree/byte audits below — run it on a detached task off
1702            // this serial message loop. Handling it inline lets one challenge
1703            // block all other replication traffic until its digests complete
1704            // (head-of-line blocking). The same flood-fair admission applies: a
1705            // global ceiling AND a per-peer cap, dropping the challenge if either
1706            // is hit (an honest auditor graces a non-response as a timeout, while
1707            // a flooder is held to its per-peer share and cannot starve others).
1708            let Some(guard) =
1709                admit_audit_responder(audit_responder_semaphore, audit_responder_inflight, source)
1710                    .await
1711            else {
1712                warn!(
1713                    "Audit challenge reply not sent: kind=responsible response=dropped \
1714                     source={source} (audit-responder capacity reached)"
1715                );
1716                return Ok(());
1717            };
1718            let bootstrapping = *is_bootstrapping.read().await;
1719            let storage = Arc::clone(storage);
1720            let p2p_node = Arc::clone(p2p_node);
1721            let source = *source;
1722            let request_id = msg.request_id;
1723            let rr_message_id = rr_message_id.map(ToOwned::to_owned);
1724            tokio::spawn(async move {
1725                let _guard = guard; // global permit + per-peer slot, held until done
1726                if let Err(e) = handle_audit_challenge_msg(
1727                    &source,
1728                    &challenge,
1729                    &storage,
1730                    &p2p_node,
1731                    bootstrapping,
1732                    request_id,
1733                    rr_message_id.as_deref(),
1734                )
1735                .await
1736                {
1737                    debug!("Audit challenge from {source} error: {e}");
1738                }
1739            });
1740            Ok(())
1741        }
1742        ReplicationMessageBody::SubtreeAuditChallenge(challenge) => {
1743            // Gossip-triggered storage-bound subtree audit (ADR-0002). The
1744            // responder rebuilds the WHOLE nonce-selected subtree, reading every
1745            // leaf's bytes from disk (`get_raw` × ~sqrt(N) leaves). Run it on a
1746            // detached task so this serial message loop is never blocked on disk
1747            // I/O — otherwise one audit stalls all replication traffic (§5).
1748            //
1749            // A bounded, flood-fair admission restores backpressure (codex#1 +
1750            // codex-r2 A): a global ceiling AND a per-peer cap. If either is hit
1751            // we drop this challenge — the auditor graces a non-response as a
1752            // timeout, so an honest auditor is unaffected and only a flooder is
1753            // throttled (and it cannot starve other peers, since its share is
1754            // capped per-peer).
1755            info!(
1756                "Audit challenge received: kind=subtree source={source} request_response={}",
1757                rr_message_id.is_some(),
1758            );
1759            let Some(guard) =
1760                admit_audit_responder(audit_responder_semaphore, audit_responder_inflight, source)
1761                    .await
1762            else {
1763                warn!(
1764                    "Audit challenge reply not sent: kind=subtree response=dropped \
1765                     source={source} (audit-responder capacity reached)"
1766                );
1767                return Ok(());
1768            };
1769            let bootstrapping = *is_bootstrapping.read().await;
1770            let storage = Arc::clone(storage);
1771            let p2p_node = Arc::clone(p2p_node);
1772            let my_commitment_state = Arc::clone(my_commitment_state);
1773            let source = *source;
1774            let request_id = msg.request_id;
1775            let rr_message_id = rr_message_id.map(ToOwned::to_owned);
1776            tokio::spawn(async move {
1777                let _guard = guard; // global permit + per-peer slot, held until done
1778                let response = storage_commitment_audit::handle_subtree_challenge(
1779                    &challenge,
1780                    &storage,
1781                    p2p_node.peer_id(),
1782                    bootstrapping,
1783                    Some(&my_commitment_state),
1784                )
1785                .await;
1786                let response_kind = subtree_audit_response_kind(&response);
1787                let sent = send_replication_response_checked(
1788                    &source,
1789                    &p2p_node,
1790                    request_id,
1791                    ReplicationMessageBody::SubtreeAuditResponse(response),
1792                    rr_message_id.as_deref(),
1793                )
1794                .await;
1795                if sent {
1796                    info!(
1797                        "Audit challenge reply sent: kind=subtree response={response_kind} \
1798                         source={source} request_response={}",
1799                        rr_message_id.is_some(),
1800                    );
1801                } else {
1802                    warn!(
1803                        "Audit challenge reply not sent: kind=subtree response={response_kind} \
1804                         source={source} request_response={}",
1805                        rr_message_id.is_some(),
1806                    );
1807                }
1808            });
1809            Ok(())
1810        }
1811        ReplicationMessageBody::SubtreeByteChallenge(challenge) => {
1812            // Round 2 of the storage audit (ADR-0002): serve the original bytes
1813            // for the auditor's spot-check keys, or signal `Absent` for a
1814            // committed key we can no longer produce. Reads chunk bytes from
1815            // disk, so likewise spawned off the serial loop (§5) under the same
1816            // flood-fair admission (codex#1 + codex-r2 A).
1817            info!(
1818                "Audit challenge received: kind=byte source={source} request_response={}",
1819                rr_message_id.is_some(),
1820            );
1821            let Some(guard) =
1822                admit_audit_responder(audit_responder_semaphore, audit_responder_inflight, source)
1823                    .await
1824            else {
1825                warn!(
1826                    "Audit challenge reply not sent: kind=byte response=dropped \
1827                     source={source} (audit-responder capacity reached)"
1828                );
1829                return Ok(());
1830            };
1831            let bootstrapping = *is_bootstrapping.read().await;
1832            let storage = Arc::clone(storage);
1833            let p2p_node = Arc::clone(p2p_node);
1834            let my_commitment_state = Arc::clone(my_commitment_state);
1835            let source = *source;
1836            let request_id = msg.request_id;
1837            let rr_message_id = rr_message_id.map(ToOwned::to_owned);
1838            tokio::spawn(async move {
1839                let _guard = guard; // global permit + per-peer slot, held until done
1840                let response = storage_commitment_audit::handle_subtree_byte_challenge(
1841                    &challenge,
1842                    &storage,
1843                    p2p_node.peer_id(),
1844                    bootstrapping,
1845                    Some(&my_commitment_state),
1846                )
1847                .await;
1848                let response_kind = subtree_byte_response_kind(&response);
1849                let sent = send_replication_response_checked(
1850                    &source,
1851                    &p2p_node,
1852                    request_id,
1853                    ReplicationMessageBody::SubtreeByteResponse(response),
1854                    rr_message_id.as_deref(),
1855                )
1856                .await;
1857                if sent {
1858                    info!(
1859                        "Audit challenge reply sent: kind=byte response={response_kind} \
1860                         source={source} request_response={}",
1861                        rr_message_id.is_some(),
1862                    );
1863                } else {
1864                    warn!(
1865                        "Audit challenge reply not sent: kind=byte response={response_kind} \
1866                         source={source} request_response={}",
1867                        rr_message_id.is_some(),
1868                    );
1869                }
1870            });
1871            Ok(())
1872        }
1873        // Response messages are handled by their respective request initiators.
1874        ReplicationMessageBody::FreshReplicationResponse(_)
1875        | ReplicationMessageBody::NeighborSyncResponse(_)
1876        | ReplicationMessageBody::VerificationResponse(_)
1877        | ReplicationMessageBody::FetchResponse(_)
1878        | ReplicationMessageBody::AuditResponse(_)
1879        | ReplicationMessageBody::SubtreeAuditResponse(_)
1880        | ReplicationMessageBody::SubtreeByteResponse(_) => Ok(()),
1881    }
1882}
1883
1884// ---------------------------------------------------------------------------
1885// Per-message-type handlers
1886// ---------------------------------------------------------------------------
1887
1888#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
1889async fn handle_fresh_offer(
1890    source: &PeerId,
1891    offer: &protocol::FreshReplicationOffer,
1892    storage: &Arc<LmdbStorage>,
1893    paid_list: &Arc<PaidList>,
1894    payment_verifier: &Arc<PaymentVerifier>,
1895    p2p_node: &Arc<P2PNode>,
1896    config: &ReplicationConfig,
1897    request_id: u64,
1898    rr_message_id: Option<&str>,
1899) -> Result<()> {
1900    let self_id = *p2p_node.peer_id();
1901
1902    // Rule 5: reject if PoP is missing.
1903    if offer.proof_of_payment.is_empty() {
1904        send_replication_response(
1905            source,
1906            p2p_node,
1907            request_id,
1908            ReplicationMessageBody::FreshReplicationResponse(FreshReplicationResponse::Rejected {
1909                key: offer.key,
1910                reason: "Missing proof of payment".to_string(),
1911            }),
1912            rr_message_id,
1913        )
1914        .await;
1915        return Ok(());
1916    }
1917
1918    // Enforce chunk size invariant: the normal PUT path rejects data larger
1919    // than MAX_CHUNK_SIZE; the replication receive path must do the same to
1920    // prevent peers from pushing oversized records through replication.
1921    if offer.data.len() > crate::ant_protocol::MAX_CHUNK_SIZE {
1922        warn!(
1923            "Rejecting fresh offer for key {}: data size {} exceeds MAX_CHUNK_SIZE {}",
1924            hex::encode(offer.key),
1925            offer.data.len(),
1926            crate::ant_protocol::MAX_CHUNK_SIZE,
1927        );
1928        p2p_node
1929            .report_trust_event(
1930                source,
1931                TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
1932            )
1933            .await;
1934        send_replication_response(
1935            source,
1936            p2p_node,
1937            request_id,
1938            ReplicationMessageBody::FreshReplicationResponse(FreshReplicationResponse::Rejected {
1939                key: offer.key,
1940                reason: format!(
1941                    "Data size {} exceeds maximum chunk size {}",
1942                    offer.data.len(),
1943                    crate::ant_protocol::MAX_CHUNK_SIZE,
1944                ),
1945            }),
1946            rr_message_id,
1947        )
1948        .await;
1949        return Ok(());
1950    }
1951
1952    // Mirror the normal PUT path: the advertised key must be the content
1953    // address of the supplied bytes before any expensive payment verification.
1954    let computed_key = crate::client::compute_address(&offer.data);
1955    if computed_key != offer.key {
1956        warn!(
1957            "Rejecting fresh offer for key {}: content address mismatch, computed {}",
1958            hex::encode(offer.key),
1959            hex::encode(computed_key),
1960        );
1961        p2p_node
1962            .report_trust_event(
1963                source,
1964                TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
1965            )
1966            .await;
1967        send_replication_response(
1968            source,
1969            p2p_node,
1970            request_id,
1971            ReplicationMessageBody::FreshReplicationResponse(FreshReplicationResponse::Rejected {
1972                key: offer.key,
1973                reason: format!(
1974                    "Content address mismatch: expected {}, computed {}",
1975                    hex::encode(offer.key),
1976                    hex::encode(computed_key),
1977                ),
1978            }),
1979            rr_message_id,
1980        )
1981        .await;
1982        return Ok(());
1983    }
1984
1985    // Rule 7: check storage admission. Fresh chunk receivers accept the close
1986    // group plus a small margin to absorb local routing-table disagreement.
1987    if !admission::is_responsible(
1988        &self_id,
1989        &offer.key,
1990        p2p_node,
1991        storage_admission_width(config.close_group_size),
1992    )
1993    .await
1994    {
1995        send_replication_response(
1996            source,
1997            p2p_node,
1998            request_id,
1999            ReplicationMessageBody::FreshReplicationResponse(FreshReplicationResponse::Rejected {
2000                key: offer.key,
2001                reason: "Not in storage-admission range for this key".to_string(),
2002            }),
2003            rr_message_id,
2004        )
2005        .await;
2006        return Ok(());
2007    }
2008
2009    // Disk-space pre-check — mirror the PUT handler (V2-411). A full node can
2010    // never store this record, so reject it before the expensive payment
2011    // verification (EVM on-chain query / merkle pool work) rather than verifying
2012    // and only then failing at `storage.put` below. Reuses the cached capacity
2013    // check (passing results only, so freed space is detected promptly), and the
2014    // store path keeps its own check as defence-in-depth.
2015    if let Err(e) = storage.check_capacity() {
2016        info!(
2017            target: "ant_node::storage::disk_precheck",
2018            key = %hex::encode(offer.key),
2019            "Rejecting fresh replication offer before payment verification: {e}"
2020        );
2021        send_replication_response(
2022            source,
2023            p2p_node,
2024            request_id,
2025            ReplicationMessageBody::FreshReplicationResponse(FreshReplicationResponse::Rejected {
2026                key: offer.key,
2027                reason: e.to_string(),
2028            }),
2029            rr_message_id,
2030        )
2031        .await;
2032        return Ok(());
2033    }
2034
2035    // Gap 1: Validate PoP via PaymentVerifier. Fresh replication is still
2036    // part of the immediate write fan-out: this receiver is about to store the
2037    // record as if the client had PUT it here directly. Storage admission
2038    // was checked above before proof work. ClientPut verification applies
2039    // store-strength cache semantics, paid-quote issuer K-closeness and local
2040    // price floor checks for single-node proofs, and merkle candidate
2041    // closeness for merkle proofs.
2042    match payment_verifier
2043        .verify_payment(
2044            &offer.key,
2045            Some(&offer.proof_of_payment),
2046            fresh_offer_payment_context(),
2047        )
2048        .await
2049    {
2050        Ok(status) if status.can_store() => {
2051            debug!(
2052                "PoP validated for fresh offer key {}",
2053                hex::encode(offer.key)
2054            );
2055        }
2056        Ok(_) => {
2057            send_replication_response(
2058                source,
2059                p2p_node,
2060                request_id,
2061                ReplicationMessageBody::FreshReplicationResponse(
2062                    FreshReplicationResponse::Rejected {
2063                        key: offer.key,
2064                        reason: "Payment verification failed: payment required".to_string(),
2065                    },
2066                ),
2067                rr_message_id,
2068            )
2069            .await;
2070            return Ok(());
2071        }
2072        Err(e) => {
2073            warn!(
2074                "PoP verification error for key {}: {e}",
2075                hex::encode(offer.key)
2076            );
2077            send_replication_response(
2078                source,
2079                p2p_node,
2080                request_id,
2081                ReplicationMessageBody::FreshReplicationResponse(
2082                    FreshReplicationResponse::Rejected {
2083                        key: offer.key,
2084                        reason: format!("Payment verification error: {e}"),
2085                    },
2086                ),
2087                rr_message_id,
2088            )
2089            .await;
2090            return Ok(());
2091        }
2092    }
2093
2094    // Rule 6: add to PaidForList.
2095    if let Err(e) = paid_list.insert(&offer.key).await {
2096        warn!("Failed to add key to PaidForList: {e}");
2097    }
2098
2099    // Store the record.
2100    match storage.put(&offer.key, &offer.data).await {
2101        Ok(_) => {
2102            send_replication_response(
2103                source,
2104                p2p_node,
2105                request_id,
2106                ReplicationMessageBody::FreshReplicationResponse(
2107                    FreshReplicationResponse::Accepted { key: offer.key },
2108                ),
2109                rr_message_id,
2110            )
2111            .await;
2112        }
2113        Err(e) => {
2114            send_replication_response(
2115                source,
2116                p2p_node,
2117                request_id,
2118                ReplicationMessageBody::FreshReplicationResponse(
2119                    FreshReplicationResponse::Rejected {
2120                        key: offer.key,
2121                        reason: e.to_string(),
2122                    },
2123                ),
2124                rr_message_id,
2125            )
2126            .await;
2127        }
2128    }
2129
2130    Ok(())
2131}
2132
2133async fn handle_paid_notify(
2134    _source: &PeerId,
2135    notify: &protocol::PaidNotify,
2136    paid_list: &Arc<PaidList>,
2137    payment_verifier: &Arc<PaymentVerifier>,
2138    p2p_node: &Arc<P2PNode>,
2139    config: &ReplicationConfig,
2140) -> Result<()> {
2141    let self_id = *p2p_node.peer_id();
2142
2143    // Rule 3: validate PoP presence before adding.
2144    if notify.proof_of_payment.is_empty() {
2145        return Ok(());
2146    }
2147
2148    // Check if we're in PaidCloseGroup for this key.
2149    if !admission::is_in_paid_close_group(
2150        &self_id,
2151        &notify.key,
2152        p2p_node,
2153        config.paid_list_close_group_size,
2154    )
2155    .await
2156    {
2157        return Ok(());
2158    }
2159
2160    // Gap 1: Validate PoP via PaymentVerifier. PaidNotify admits fresh
2161    // paid-list metadata, so local paid-list close-group membership was checked
2162    // above before proof work. The verifier then runs the same payment proof
2163    // checks as ClientPut while writing a paid-list-strength cache entry.
2164    match payment_verifier
2165        .verify_payment(
2166            &notify.key,
2167            Some(&notify.proof_of_payment),
2168            paid_notify_payment_context(),
2169        )
2170        .await
2171    {
2172        Ok(status) if status.can_store() => {
2173            debug!(
2174                "PoP validated for paid notify key {}",
2175                hex::encode(notify.key)
2176            );
2177        }
2178        Ok(_) => {
2179            warn!(
2180                "Paid notify rejected: payment required for key {}",
2181                hex::encode(notify.key)
2182            );
2183            return Ok(());
2184        }
2185        Err(e) => {
2186            warn!(
2187                "PoP verification error for paid notify key {}: {e}",
2188                hex::encode(notify.key)
2189            );
2190            return Ok(());
2191        }
2192    }
2193
2194    if let Err(e) = paid_list.insert(&notify.key).await {
2195        warn!("Failed to add paid notify key to PaidForList: {e}");
2196    }
2197
2198    Ok(())
2199}
2200
2201#[allow(clippy::too_many_arguments)]
2202async fn handle_neighbor_sync_request(
2203    source: &PeerId,
2204    request: &protocol::NeighborSyncRequest,
2205    p2p_node: &Arc<P2PNode>,
2206    storage: &Arc<LmdbStorage>,
2207    paid_list: &Arc<PaidList>,
2208    queues: &Arc<RwLock<ReplicationQueues>>,
2209    config: &ReplicationConfig,
2210    is_bootstrapping: bool,
2211    bootstrap_state: &Arc<RwLock<BootstrapState>>,
2212    sync_history: &Arc<RwLock<HashMap<PeerId, PeerSyncRecord>>>,
2213    sync_cycle_epoch: &Arc<RwLock<u64>>,
2214    repair_proofs: &Arc<RwLock<RepairProofs>>,
2215    my_commitment: Option<StorageCommitment>,
2216    request_id: u64,
2217    rr_message_id: Option<&str>,
2218) -> Result<()> {
2219    let self_id = *p2p_node.peer_id();
2220
2221    // No per-request hint count limit: the wire message size limit
2222    // (MAX_REPLICATION_MESSAGE_SIZE) already caps the payload. Unlike audit
2223    // challenges, sync hints don't drive expensive computation — they just
2224    // enter the verification queue. A per-request limit here would break
2225    // bootstrap replication for newly-joined nodes with 0 stored chunks.
2226
2227    // Build response (outbound hints).
2228    let (response, sent_replica_hints, sender_in_rt) =
2229        neighbor_sync::handle_sync_request_with_proofs(
2230            source,
2231            request,
2232            p2p_node,
2233            storage,
2234            paid_list,
2235            config,
2236            is_bootstrapping,
2237            my_commitment.clone(),
2238        )
2239        .await;
2240
2241    // Send response.
2242    let response_sent = send_replication_response_checked(
2243        source,
2244        p2p_node,
2245        request_id,
2246        ReplicationMessageBody::NeighborSyncResponse(response),
2247        rr_message_id,
2248    )
2249    .await;
2250
2251    // Process inbound hints only if sender is in LocalRT (Rule 4-6).
2252    if !sender_in_rt {
2253        return Ok(());
2254    }
2255
2256    // Update sync history for this peer before recording repair proofs so a
2257    // same-tick audit cannot combine a fresh key proof with stale peer maturity.
2258    {
2259        let mut history = sync_history.write().await;
2260        let record = history.entry(*source).or_insert(PeerSyncRecord {
2261            last_sync: None,
2262            cycles_since_sync: 0,
2263        });
2264        record.last_sync = Some(Instant::now());
2265        record.cycles_since_sync = 0;
2266    }
2267
2268    if response_sent && !request.bootstrapping {
2269        record_sent_replica_hints(source, &sent_replica_hints, repair_proofs, sync_cycle_epoch)
2270            .await;
2271    }
2272
2273    // Admit inbound hints and queue for verification.
2274    let outcome = admit_and_queue_hints(
2275        &self_id,
2276        source,
2277        &request.replica_hints,
2278        &request.paid_hints,
2279        p2p_node,
2280        config,
2281        storage,
2282        paid_list,
2283        queues,
2284    )
2285    .await;
2286
2287    // Track discovered keys for bootstrap drain detection so that hints
2288    // admitted via inbound sync requests are not missed. Capacity-rejected
2289    // hints keep this source on the "not yet drained" list until its next
2290    // sync re-admits them; a clean cycle clears the source.
2291    if is_bootstrapping {
2292        if !outcome.discovered.is_empty() {
2293            bootstrap::track_discovered_keys(bootstrap_state, &outcome.discovered).await;
2294        }
2295        if outcome.capacity_rejected_count > 0 {
2296            bootstrap::note_capacity_rejected(bootstrap_state, *source).await;
2297        } else {
2298            bootstrap::clear_capacity_rejected(bootstrap_state, source).await;
2299        }
2300    }
2301
2302    Ok(())
2303}
2304
2305async fn handle_verification_request(
2306    source: &PeerId,
2307    request: &protocol::VerificationRequest,
2308    storage: &Arc<LmdbStorage>,
2309    paid_list: &Arc<PaidList>,
2310    p2p_node: &Arc<P2PNode>,
2311    request_id: u64,
2312    rr_message_id: Option<&str>,
2313) -> Result<()> {
2314    // No per-request key count limit: the wire message size limit
2315    // (MAX_REPLICATION_MESSAGE_SIZE) already caps the payload. Verification
2316    // does cheap storage lookups per key, not expensive computation like
2317    // audit digest generation.
2318
2319    #[allow(clippy::cast_possible_truncation)]
2320    let keys_len = request.keys.len() as u32;
2321    let paid_check_set: HashSet<u32> = request
2322        .paid_list_check_indices
2323        .iter()
2324        .copied()
2325        .filter(|&idx| {
2326            if idx >= keys_len {
2327                warn!(
2328                    "Verification request from {source}: paid_list_check_index {idx} out of bounds (keys.len() = {})",
2329                    request.keys.len(),
2330                );
2331                false
2332            } else {
2333                true
2334            }
2335        })
2336        .collect();
2337
2338    let mut results = Vec::with_capacity(request.keys.len());
2339    for (i, key) in request.keys.iter().enumerate() {
2340        let present = storage.exists(key).unwrap_or(false);
2341        let paid = if paid_check_set.contains(&u32::try_from(i).unwrap_or(u32::MAX)) {
2342            Some(paid_list.contains(key).unwrap_or(false))
2343        } else {
2344            None
2345        };
2346        results.push(protocol::KeyVerificationResult {
2347            key: *key,
2348            present,
2349            paid,
2350        });
2351    }
2352
2353    send_replication_response(
2354        source,
2355        p2p_node,
2356        request_id,
2357        ReplicationMessageBody::VerificationResponse(VerificationResponse { results }),
2358        rr_message_id,
2359    )
2360    .await;
2361
2362    Ok(())
2363}
2364
2365async fn handle_fetch_request(
2366    source: &PeerId,
2367    request: &protocol::FetchRequest,
2368    storage: &Arc<LmdbStorage>,
2369    p2p_node: &Arc<P2PNode>,
2370    request_id: u64,
2371    rr_message_id: Option<&str>,
2372) -> Result<()> {
2373    let response = match storage.get(&request.key).await {
2374        Ok(Some(data)) => protocol::FetchResponse::Success {
2375            key: request.key,
2376            data,
2377        },
2378        Ok(None) => protocol::FetchResponse::NotFound { key: request.key },
2379        Err(e) => protocol::FetchResponse::Error {
2380            key: request.key,
2381            reason: format!("{e}"),
2382        },
2383    };
2384
2385    send_replication_response(
2386        source,
2387        p2p_node,
2388        request_id,
2389        ReplicationMessageBody::FetchResponse(response),
2390        rr_message_id,
2391    )
2392    .await;
2393
2394    Ok(())
2395}
2396
2397/// Responder for an incoming `AuditChallenge` (responsible-chunk audit #2, and
2398/// the prune-confirmation audit, which reuses the same wire message): reply with
2399/// per-key possession digests.
2400async fn handle_audit_challenge_msg(
2401    source: &PeerId,
2402    challenge: &protocol::AuditChallenge,
2403    storage: &Arc<LmdbStorage>,
2404    p2p_node: &Arc<P2PNode>,
2405    is_bootstrapping: bool,
2406    request_id: u64,
2407    rr_message_id: Option<&str>,
2408) -> Result<()> {
2409    #[allow(clippy::cast_possible_truncation)]
2410    let stored_chunks = storage.current_chunks().map_or(0, |c| c as usize);
2411    info!(
2412        "Audit challenge received: kind=responsible keys={} bootstrapping={} request_response={}",
2413        challenge.keys.len(),
2414        is_bootstrapping,
2415        rr_message_id.is_some(),
2416    );
2417
2418    let response = audit::handle_audit_challenge(
2419        challenge,
2420        storage,
2421        p2p_node.peer_id(),
2422        is_bootstrapping,
2423        stored_chunks,
2424    )
2425    .await;
2426    let response_kind = audit_response_kind(&response);
2427
2428    let sent = send_replication_response_checked(
2429        source,
2430        p2p_node,
2431        request_id,
2432        ReplicationMessageBody::AuditResponse(response),
2433        rr_message_id,
2434    )
2435    .await;
2436    if sent {
2437        info!(
2438            "Audit challenge reply sent: kind=responsible response={} keys={} request_response={}",
2439            response_kind,
2440            challenge.keys.len(),
2441            rr_message_id.is_some(),
2442        );
2443    } else {
2444        warn!(
2445            "Audit challenge reply not sent: kind=responsible response={} keys={} request_response={}",
2446            response_kind,
2447            challenge.keys.len(),
2448            rr_message_id.is_some(),
2449        );
2450    }
2451
2452    Ok(())
2453}
2454
2455fn audit_response_kind(response: &protocol::AuditResponse) -> &'static str {
2456    match response {
2457        protocol::AuditResponse::Digests { .. } => "digests",
2458        protocol::AuditResponse::Bootstrapping { .. } => "bootstrapping",
2459        protocol::AuditResponse::Rejected { .. } => "rejected",
2460    }
2461}
2462
2463fn subtree_audit_response_kind(response: &protocol::SubtreeAuditResponse) -> &'static str {
2464    match response {
2465        protocol::SubtreeAuditResponse::Proof { .. } => "proof",
2466        protocol::SubtreeAuditResponse::Bootstrapping { .. } => "bootstrapping",
2467        protocol::SubtreeAuditResponse::Rejected { .. } => "rejected",
2468    }
2469}
2470
2471fn subtree_byte_response_kind(response: &protocol::SubtreeByteResponse) -> &'static str {
2472    match response {
2473        protocol::SubtreeByteResponse::Items { .. } => "items",
2474        protocol::SubtreeByteResponse::Bootstrapping { .. } => "bootstrapping",
2475        protocol::SubtreeByteResponse::Rejected { .. } => "rejected",
2476    }
2477}
2478
2479// ---------------------------------------------------------------------------
2480// Message sending helper
2481// ---------------------------------------------------------------------------
2482
2483/// Send a replication response message as a best-effort reply.
2484///
2485/// Encode and send failures are logged by the checked helper. Most response
2486/// paths do not need to branch on send success, so this wrapper keeps those
2487/// call sites explicit about their best-effort behavior.
2488async fn send_replication_response(
2489    peer: &PeerId,
2490    p2p_node: &Arc<P2PNode>,
2491    request_id: u64,
2492    body: ReplicationMessageBody,
2493    rr_message_id: Option<&str>,
2494) {
2495    let _ =
2496        send_replication_response_checked(peer, p2p_node, request_id, body, rr_message_id).await;
2497}
2498
2499/// Send a replication response message and report whether it was accepted.
2500///
2501/// Returns `true` after the message is encoded and accepted by the P2P send
2502/// path. Returns `false` after logging an encode or send failure. Repair-proof
2503/// recording uses this to avoid trusting hints that were not actually sent.
2504///
2505/// When `rr_message_id` is `Some`, the response is sent via the `/rr/`
2506/// request-response path so saorsa-core can route it back to the caller's
2507/// `send_request` future. Otherwise it is sent as a plain message.
2508async fn send_replication_response_checked(
2509    peer: &PeerId,
2510    p2p_node: &Arc<P2PNode>,
2511    request_id: u64,
2512    body: ReplicationMessageBody,
2513    rr_message_id: Option<&str>,
2514) -> bool {
2515    let msg = ReplicationMessage { request_id, body };
2516    let encoded = match msg.encode() {
2517        Ok(data) => data,
2518        Err(e) => {
2519            warn!("Failed to encode replication response: {e}");
2520            return false;
2521        }
2522    };
2523    let result = if let Some(msg_id) = rr_message_id {
2524        p2p_node
2525            .send_response(peer, REPLICATION_PROTOCOL_ID, msg_id, encoded)
2526            .await
2527    } else {
2528        p2p_node
2529            .send_message(peer, REPLICATION_PROTOCOL_ID, encoded, &[])
2530            .await
2531    };
2532    if let Err(e) = result {
2533        debug!("Failed to send replication response to {peer}: {e}");
2534        return false;
2535    }
2536    true
2537}
2538
2539async fn record_sent_replica_hints(
2540    peer: &PeerId,
2541    hints: &[neighbor_sync::SentReplicaHint],
2542    repair_proofs: &Arc<RwLock<RepairProofs>>,
2543    sync_cycle_epoch: &Arc<RwLock<u64>>,
2544) {
2545    if hints.is_empty() {
2546        return;
2547    }
2548
2549    let hinted_at_epoch = *sync_cycle_epoch.read().await;
2550    let mut proofs = repair_proofs.write().await;
2551    for hint in hints {
2552        if proofs.record_replica_hint_sent(*peer, hint.key, &hint.close_peers, hinted_at_epoch) {
2553            debug!(
2554                "Recorded repair hint proof for peer {peer} and key {}",
2555                hex::encode(hint.key)
2556            );
2557        }
2558    }
2559}
2560
2561// ---------------------------------------------------------------------------
2562// Neighbor sync round
2563// ---------------------------------------------------------------------------
2564
2565/// Run one neighbor sync round.
2566#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
2567async fn run_neighbor_sync_round(
2568    p2p_node: &Arc<P2PNode>,
2569    storage: &Arc<LmdbStorage>,
2570    paid_list: &Arc<PaidList>,
2571    queues: &Arc<RwLock<ReplicationQueues>>,
2572    config: &ReplicationConfig,
2573    sync_state: &Arc<RwLock<NeighborSyncState>>,
2574    sync_history: &Arc<RwLock<HashMap<PeerId, PeerSyncRecord>>>,
2575    sync_cycle_epoch: &Arc<RwLock<u64>>,
2576    repair_proofs: &Arc<RwLock<RepairProofs>>,
2577    is_bootstrapping: &Arc<RwLock<bool>>,
2578    bootstrap_state: &Arc<RwLock<BootstrapState>>,
2579    commitment_state: &Arc<ResponderCommitmentState>,
2580    last_commitment_by_peer: &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
2581    ever_capable_peers: &Arc<RwLock<HashSet<PeerId>>>,
2582    sig_verify_attempts: &Arc<RwLock<HashMap<PeerId, Instant>>>,
2583    gossip_audit: &GossipAuditTrigger,
2584) {
2585    let self_id = *p2p_node.peer_id();
2586    let bootstrapping = *is_bootstrapping.read().await;
2587
2588    // Check if cycle is complete; start new one if needed.
2589    // We check under a read lock, then release it before the expensive
2590    // prune pass and DHT snapshot so other tasks are not starved.
2591    let cycle_complete = sync_state.read().await.is_cycle_complete();
2592    if cycle_complete {
2593        // A completed local neighbor-sync cycle advances the epoch component
2594        // of repair-proof maturity. The per-key wall-clock minimum age is
2595        // checked when audits are selected.
2596        {
2597            let mut history = sync_history.write().await;
2598            for record in history.values_mut() {
2599                record.cycles_since_sync = record.cycles_since_sync.saturating_add(1);
2600            }
2601        }
2602        let current_sync_epoch = {
2603            let mut epoch = sync_cycle_epoch.write().await;
2604            *epoch = epoch.saturating_add(1);
2605            *epoch
2606        };
2607
2608        // Post-cycle pruning (Section 11) — runs without holding sync_state.
2609        // Remote prune-confirmation audits are storage-proof audits and only
2610        // run after bootstrap has drained.
2611        let allow_remote_prune_audits = !bootstrapping && bootstrap_state.read().await.is_drained();
2612        pruning::run_prune_pass_with_context(pruning::PrunePassContext {
2613            self_id: &self_id,
2614            storage,
2615            paid_list,
2616            p2p_node,
2617            config,
2618            sync_state,
2619            repair_proofs,
2620            current_sync_epoch,
2621            #[cfg(any(test, feature = "test-utils"))]
2622            repair_proof_now: None,
2623            allow_remote_prune_audits,
2624            commitment_state: Some(commitment_state),
2625        })
2626        .await;
2627
2628        // Take fresh close-neighbor snapshot (DHT query, no lock held).
2629        let neighbors =
2630            neighbor_sync::snapshot_close_neighbors(p2p_node, &self_id, config.neighbor_sync_scope)
2631                .await;
2632
2633        // Now re-acquire write lock and re-check before swapping cycle.
2634        let mut state = sync_state.write().await;
2635        if state.is_cycle_complete() {
2636            // Preserve cooldown and bootstrap-claim tracking across cycles.
2637            // Claims have a 24h lifecycle vs 10-20 min cycles — dropping them
2638            // would reset the abuse detection timer every cycle.
2639            let old_sync_times = std::mem::take(&mut state.last_sync_times);
2640            let old_bootstrap_claims = std::mem::take(&mut state.bootstrap_claims);
2641            let old_bootstrap_claim_history = std::mem::take(&mut state.bootstrap_claim_history);
2642            let old_prune_cursor = state.prune_cursor;
2643            *state = NeighborSyncState::new_cycle(neighbors);
2644            state.last_sync_times = old_sync_times;
2645            state.bootstrap_claims = old_bootstrap_claims;
2646            state.bootstrap_claim_history = old_bootstrap_claim_history;
2647            state.prune_cursor = old_prune_cursor;
2648        }
2649    }
2650
2651    // Select batch of peers.
2652    let batch = {
2653        let mut state = sync_state.write().await;
2654        neighbor_sync::select_sync_batch(
2655            &mut state,
2656            config.neighbor_sync_peer_count,
2657            config.neighbor_sync_cooldown,
2658        )
2659    };
2660
2661    if batch.is_empty() {
2662        return;
2663    }
2664
2665    debug!("Neighbor sync: syncing with {} peers", batch.len());
2666
2667    // Snapshot our current commitment once per round so all peers in
2668    // this batch see the same thing (gossip is the responder's attestation;
2669    // same value across the batch is fine and reduces RwLock churn). Atomically
2670    // snapshot + mark-gossiped so we stay answerable for exactly what we emit
2671    // (ADR-0002 retention), with no TOCTOU vs a concurrent retire/rotate.
2672    let my_commitment = commitment_state
2673        .current_for_gossip()
2674        .map(|b| b.commitment().clone());
2675
2676    let mut hints_by_peer = neighbor_sync::build_sync_hints_for_peers(
2677        &batch,
2678        storage,
2679        paid_list,
2680        p2p_node,
2681        config.close_group_size,
2682        config.paid_list_close_group_size,
2683    )
2684    .await;
2685
2686    // Sync with each peer in the batch.
2687    for peer in &batch {
2688        let hints = hints_by_peer.remove(peer).unwrap_or_default();
2689        let outcome = neighbor_sync::sync_with_peer_with_hints(
2690            peer,
2691            p2p_node,
2692            config,
2693            bootstrapping,
2694            hints,
2695            my_commitment.clone(),
2696        )
2697        .await;
2698
2699        if let Some(outcome) = outcome {
2700            handle_sync_response(
2701                &self_id,
2702                peer,
2703                &outcome.response,
2704                &outcome.sent_replica_hints,
2705                p2p_node,
2706                config,
2707                bootstrapping,
2708                bootstrap_state,
2709                storage,
2710                paid_list,
2711                queues,
2712                sync_state,
2713                sync_history,
2714                sync_cycle_epoch,
2715                repair_proofs,
2716                last_commitment_by_peer,
2717                ever_capable_peers,
2718                sig_verify_attempts,
2719                gossip_audit,
2720            )
2721            .await;
2722        } else {
2723            // Sync failed -- remove peer and try to fill slot.
2724            let replacement = {
2725                let mut state = sync_state.write().await;
2726                neighbor_sync::handle_sync_failure(&mut state, peer, config.neighbor_sync_cooldown)
2727            };
2728
2729            // Attempt sync with the replacement peer (if one was found).
2730            if let Some(replacement_peer) = replacement {
2731                let mut replacement_hints = neighbor_sync::build_sync_hints_for_peers(
2732                    std::slice::from_ref(&replacement_peer),
2733                    storage,
2734                    paid_list,
2735                    p2p_node,
2736                    config.close_group_size,
2737                    config.paid_list_close_group_size,
2738                )
2739                .await;
2740                let hints = replacement_hints
2741                    .remove(&replacement_peer)
2742                    .unwrap_or_default();
2743                let replacement_outcome = neighbor_sync::sync_with_peer_with_hints(
2744                    &replacement_peer,
2745                    p2p_node,
2746                    config,
2747                    bootstrapping,
2748                    hints,
2749                    my_commitment.clone(),
2750                )
2751                .await;
2752
2753                if let Some(outcome) = replacement_outcome {
2754                    handle_sync_response(
2755                        &self_id,
2756                        &replacement_peer,
2757                        &outcome.response,
2758                        &outcome.sent_replica_hints,
2759                        p2p_node,
2760                        config,
2761                        bootstrapping,
2762                        bootstrap_state,
2763                        storage,
2764                        paid_list,
2765                        queues,
2766                        sync_state,
2767                        sync_history,
2768                        sync_cycle_epoch,
2769                        repair_proofs,
2770                        last_commitment_by_peer,
2771                        ever_capable_peers,
2772                        sig_verify_attempts,
2773                        gossip_audit,
2774                    )
2775                    .await;
2776                }
2777            }
2778        }
2779    }
2780}
2781
2782/// Process a successful neighbor sync response: record the sync, check for
2783/// bootstrap claim abuse, and admit inbound hints.
2784#[allow(clippy::too_many_arguments)]
2785async fn handle_sync_response(
2786    self_id: &PeerId,
2787    peer: &PeerId,
2788    resp: &NeighborSyncResponse,
2789    sent_replica_hints: &[neighbor_sync::SentReplicaHint],
2790    p2p_node: &Arc<P2PNode>,
2791    config: &ReplicationConfig,
2792    bootstrapping: bool,
2793    bootstrap_state: &Arc<RwLock<BootstrapState>>,
2794    storage: &Arc<LmdbStorage>,
2795    paid_list: &Arc<PaidList>,
2796    queues: &Arc<RwLock<ReplicationQueues>>,
2797    sync_state: &Arc<RwLock<NeighborSyncState>>,
2798    sync_history: &Arc<RwLock<HashMap<PeerId, PeerSyncRecord>>>,
2799    sync_cycle_epoch: &Arc<RwLock<u64>>,
2800    repair_proofs: &Arc<RwLock<RepairProofs>>,
2801    last_commitment_by_peer: &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
2802    ever_capable_peers: &Arc<RwLock<HashSet<PeerId>>>,
2803    sig_verify_attempts: &Arc<RwLock<HashMap<PeerId, Instant>>>,
2804    gossip_audit: &GossipAuditTrigger,
2805) {
2806    // Ingest the peer's commitment if they piggybacked one on the response.
2807    // Same verification as the request path (peer-id binding + signature);
2808    // forged commitments are dropped at the edge. A *changed* commitment here
2809    // is a gossip-audit trigger just like on the request path — so a peer that
2810    // only ever answers sync (never initiates) is still audited (ADR-0002).
2811    if let Some(target) = ingest_peer_commitment(
2812        peer,
2813        resp.commitment.as_ref(),
2814        p2p_node,
2815        last_commitment_by_peer,
2816        ever_capable_peers,
2817        sig_verify_attempts,
2818    )
2819    .await
2820    {
2821        maybe_trigger_gossip_audit(gossip_audit, peer, target).await;
2822    }
2823
2824    // Record successful sync.
2825    {
2826        let mut state = sync_state.write().await;
2827        neighbor_sync::record_successful_sync(&mut state, peer);
2828    }
2829    {
2830        let mut history = sync_history.write().await;
2831        let record = history.entry(*peer).or_insert(PeerSyncRecord {
2832            last_sync: None,
2833            cycles_since_sync: 0,
2834        });
2835        record.last_sync = Some(Instant::now());
2836        record.cycles_since_sync = 0;
2837    }
2838
2839    // Process inbound hints from response (skip if peer is bootstrapping).
2840    if resp.bootstrapping {
2841        // Gap 6: BootstrapClaimAbuse grace period enforcement.
2842        // Separate state mutation from network I/O to avoid holding the
2843        // write lock across report_trust_event.
2844        let should_report = {
2845            let now = Instant::now();
2846            let mut state = sync_state.write().await;
2847            match state.observe_bootstrap_claim(*peer, now, config.bootstrap_claim_grace_period) {
2848                BootstrapClaimObservation::WithinGrace { .. } => false,
2849                BootstrapClaimObservation::PastGrace { first_seen } => {
2850                    warn!(
2851                        "Peer {peer} has been claiming bootstrap for {:?}, \
2852                         exceeding grace period of {:?} — reporting abuse",
2853                        now.duration_since(first_seen),
2854                        config.bootstrap_claim_grace_period,
2855                    );
2856                    true
2857                }
2858                BootstrapClaimObservation::Repeated { first_seen } => {
2859                    warn!(
2860                        "Peer {peer} repeated bootstrap claim after previously stopping; \
2861                         first claim was {:?} ago — reporting abuse",
2862                        now.duration_since(first_seen),
2863                    );
2864                    true
2865                }
2866            }
2867        };
2868        if should_report {
2869            p2p_node
2870                .report_trust_event(
2871                    peer,
2872                    TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
2873                )
2874                .await;
2875        }
2876    } else {
2877        // Peer is not claiming bootstrap; clear active claim while retaining
2878        // history so the peer cannot start a second grace window later.
2879        {
2880            let mut state = sync_state.write().await;
2881            state.clear_active_bootstrap_claim(peer);
2882        }
2883        record_sent_replica_hints(peer, sent_replica_hints, repair_proofs, sync_cycle_epoch).await;
2884        let outcome = admit_and_queue_hints(
2885            self_id,
2886            peer,
2887            &resp.replica_hints,
2888            &resp.paid_hints,
2889            p2p_node,
2890            config,
2891            storage,
2892            paid_list,
2893            queues,
2894        )
2895        .await;
2896
2897        // Track discovered keys for bootstrap drain detection so that hints
2898        // admitted via regular neighbor sync are not missed. Capacity-
2899        // rejected hints keep this source on the "not yet drained" list
2900        // until its next sync replays them; a clean cycle clears it.
2901        if bootstrapping {
2902            if !outcome.discovered.is_empty() {
2903                bootstrap::track_discovered_keys(bootstrap_state, &outcome.discovered).await;
2904            }
2905            if outcome.capacity_rejected_count > 0 {
2906                bootstrap::note_capacity_rejected(bootstrap_state, *peer).await;
2907            } else {
2908                bootstrap::clear_capacity_rejected(bootstrap_state, peer).await;
2909            }
2910        }
2911    }
2912}
2913
2914/// Admit hints and queue them for verification, returning newly-discovered keys.
2915///
2916/// Shared by neighbor-sync request handling, response handling, and bootstrap
2917/// sync so that admission + queueing logic lives in one place.
2918#[allow(clippy::too_many_arguments)]
2919/// Outcome of [`admit_and_queue_hints`].
2920///
2921/// `capacity_rejected_count` is non-zero when one or more legitimately
2922/// admissible hints were dropped because `pending_verify`'s global or
2923/// per-source bound was hit. Callers that care about completeness
2924/// (bootstrap drain accounting) MUST NOT treat their work as complete while
2925/// this is > 0 — the source will need to re-hint after capacity frees up.
2926struct AdmissionOutcome {
2927    discovered: HashSet<XorName>,
2928    capacity_rejected_count: usize,
2929}
2930
2931#[allow(clippy::too_many_arguments)]
2932async fn admit_and_queue_hints(
2933    self_id: &PeerId,
2934    source_peer: &PeerId,
2935    replica_hints: &[XorName],
2936    paid_hints: &[XorName],
2937    p2p_node: &Arc<P2PNode>,
2938    config: &ReplicationConfig,
2939    storage: &Arc<LmdbStorage>,
2940    paid_list: &Arc<PaidList>,
2941    queues: &Arc<RwLock<ReplicationQueues>>,
2942) -> AdmissionOutcome {
2943    let pending_keys: HashSet<XorName> = {
2944        let q = queues.read().await;
2945        q.pending_keys().into_iter().collect()
2946    };
2947
2948    let admitted = admission::admit_hints(
2949        self_id,
2950        replica_hints,
2951        paid_hints,
2952        p2p_node,
2953        config,
2954        storage,
2955        paid_list,
2956        &pending_keys,
2957    )
2958    .await;
2959
2960    let mut discovered = HashSet::new();
2961    let mut capacity_rejected_count: usize = 0;
2962    let mut q = queues.write().await;
2963    let now = Instant::now();
2964
2965    for key in admitted.replica_keys {
2966        if !storage.exists(&key).unwrap_or(false) {
2967            let result = q.add_pending_verify(
2968                key,
2969                VerificationEntry {
2970                    state: VerificationState::PendingVerify,
2971                    pipeline: HintPipeline::Replica,
2972                    verified_sources: Vec::new(),
2973                    tried_sources: HashSet::new(),
2974                    created_at: now,
2975                    hint_sender: *source_peer,
2976                },
2977            );
2978            match result {
2979                crate::replication::scheduling::AdmissionResult::Admitted => {
2980                    discovered.insert(key);
2981                }
2982                crate::replication::scheduling::AdmissionResult::AlreadyPresent => {}
2983                crate::replication::scheduling::AdmissionResult::CapacityRejected => {
2984                    capacity_rejected_count += 1;
2985                }
2986            }
2987        }
2988    }
2989
2990    for key in admitted.paid_only_keys {
2991        let result = q.add_pending_verify(
2992            key,
2993            VerificationEntry {
2994                state: VerificationState::PendingVerify,
2995                pipeline: HintPipeline::PaidOnly,
2996                verified_sources: Vec::new(),
2997                tried_sources: HashSet::new(),
2998                created_at: now,
2999                hint_sender: *source_peer,
3000            },
3001        );
3002        match result {
3003            crate::replication::scheduling::AdmissionResult::Admitted => {
3004                discovered.insert(key);
3005            }
3006            crate::replication::scheduling::AdmissionResult::AlreadyPresent => {}
3007            crate::replication::scheduling::AdmissionResult::CapacityRejected => {
3008                capacity_rejected_count += 1;
3009            }
3010        }
3011    }
3012
3013    if capacity_rejected_count > 0 {
3014        debug!(
3015            "admit_and_queue_hints from {source_peer}: {capacity_rejected_count} hints \
3016             rejected at queue capacity; source will need to re-hint after pending_verify drains"
3017        );
3018    }
3019
3020    AdmissionOutcome {
3021        discovered,
3022        capacity_rejected_count,
3023    }
3024}
3025
3026// ---------------------------------------------------------------------------
3027// Verification cycle
3028// ---------------------------------------------------------------------------
3029
3030/// Run one verification cycle: process pending keys through quorum checks.
3031#[allow(clippy::too_many_lines)]
3032async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) {
3033    let cycle_started = Instant::now();
3034    let VerificationCycleContext {
3035        p2p_node,
3036        paid_list,
3037        storage,
3038        queues,
3039        config,
3040        bootstrap_state,
3041        is_bootstrapping,
3042        bootstrap_complete_notify,
3043        last_commitment_by_peer,
3044        ever_capable_peers,
3045        recent_provers,
3046    } = ctx;
3047
3048    // Evict stale entries that have been pending too long (e.g. unreachable
3049    // verification targets during a network partition).
3050    {
3051        let mut q = queues.write().await;
3052        q.evict_stale(config::PENDING_VERIFY_MAX_AGE);
3053    }
3054
3055    let pending_keys = {
3056        let q = queues.read().await;
3057        q.pending_keys()
3058    };
3059
3060    if pending_keys.is_empty() {
3061        return;
3062    }
3063    let initial_pending_count = pending_keys.len();
3064
3065    let self_id = *p2p_node.peer_id();
3066
3067    // Step 1: Check local PaidForList for fast-path authorization (Section 9,
3068    // step 4).
3069    let mut local_paid_presence_probe_keys = Vec::new();
3070    let mut local_paid_paid_only_keys = Vec::new();
3071    let mut keys_needing_network = Vec::new();
3072    let mut terminal_keys: Vec<XorName> = Vec::new();
3073    {
3074        let mut q = queues.write().await;
3075        for key in &pending_keys {
3076            if paid_list.contains(key).unwrap_or(false) {
3077                if let Some(pipeline) =
3078                    q.set_pending_state(key, VerificationState::PaidListVerified)
3079                {
3080                    match pipeline {
3081                        HintPipeline::PaidOnly => {
3082                            // Paid-only + local paid state needs one more
3083                            // storage-admission check outside this lock: if we
3084                            // are also in the close group plus storage margin,
3085                            // the hint can repair a missing replica.
3086                            local_paid_paid_only_keys.push(*key);
3087                        }
3088                        HintPipeline::Replica => {
3089                            // Local paid-list membership authorizes the key.
3090                            // We still need a presence probe to discover fetch
3091                            // sources, but we must not require remote paid
3092                            // majority or presence quorum.
3093                            local_paid_presence_probe_keys.push(*key);
3094                        }
3095                    }
3096                }
3097            } else {
3098                keys_needing_network.push(*key);
3099            }
3100        }
3101    }
3102
3103    if !local_paid_paid_only_keys.is_empty() {
3104        let mut terminal_paid_only = Vec::new();
3105        for key in local_paid_paid_only_keys {
3106            if storage.exists(&key).unwrap_or(false) {
3107                terminal_paid_only.push(key);
3108            } else if admission::is_responsible(
3109                &self_id,
3110                &key,
3111                p2p_node,
3112                storage_admission_width(config.close_group_size),
3113            )
3114            .await
3115            {
3116                local_paid_presence_probe_keys.push(key);
3117            } else {
3118                terminal_paid_only.push(key);
3119            }
3120        }
3121
3122        if !terminal_paid_only.is_empty() {
3123            let mut q = queues.write().await;
3124            for key in terminal_paid_only {
3125                q.remove_pending(&key);
3126                terminal_keys.push(key);
3127            }
3128        }
3129    }
3130
3131    let local_paid_probe_count = local_paid_presence_probe_keys.len();
3132    let keys_needing_network_count = keys_needing_network.len();
3133
3134    // Step 1b: Local paid-list hit for fetch-eligible keys. Per Section 9
3135    // step 4, authorization succeeds immediately; run a presence-only probe
3136    // to find any holder we can fetch from.
3137    if !local_paid_presence_probe_keys.is_empty() {
3138        let targets = quorum::compute_presence_targets(
3139            &local_paid_presence_probe_keys,
3140            p2p_node,
3141            config,
3142            &self_id,
3143        )
3144        .await;
3145        let evidence = quorum::run_verification_round(
3146            &local_paid_presence_probe_keys,
3147            &targets,
3148            p2p_node,
3149            config,
3150        )
3151        .await;
3152
3153        let mut q = queues.write().await;
3154        for key in local_paid_presence_probe_keys {
3155            if storage.exists(&key).unwrap_or(false) {
3156                q.remove_pending(&key);
3157                terminal_keys.push(key);
3158                continue;
3159            }
3160            let sources = evidence.get(&key).map_or_else(Vec::new, |ev| {
3161                quorum::present_sources_for_key(&key, ev, &targets)
3162            });
3163            if sources.is_empty() {
3164                // Terminal failure: remove pending and report. No fetch path.
3165                q.remove_pending(&key);
3166                warn!(
3167                    "Locally paid key {} has no responding holders (possible data loss)",
3168                    hex::encode(key)
3169                );
3170                terminal_keys.push(key);
3171            } else {
3172                let distance = crate::client::xor_distance(&key, p2p_node.peer_id().as_bytes());
3173                // Atomic remove+enqueue: if fetch_queue is at capacity, the
3174                // pending entry is preserved and retried next cycle (no
3175                // silent drop of verified replica-repair work).
3176                let _ = q.promote_pending_to_fetch(key, distance, sources);
3177            }
3178        }
3179    }
3180
3181    // Steps 2-5: Network verification (skipped if all keys resolved locally).
3182    if !keys_needing_network.is_empty() {
3183        // Step 2: Compute targets and run network verification round.
3184        let targets =
3185            quorum::compute_verification_targets(&keys_needing_network, p2p_node, config, &self_id)
3186                .await;
3187
3188        let evidence =
3189            quorum::run_verification_round(&keys_needing_network, &targets, p2p_node, config).await;
3190
3191        // Step 3: Evaluate results — collect outcomes without holding the write
3192        // lock across paid-list I/O.
3193        //
3194        // v12 §6 holder-eligibility: snapshot the per-peer last-commitment
3195        // table and recent_provers cache up front so the synchronous
3196        // evaluate_key_evidence_with_holder_check predicate can consult
3197        // them without awaiting. The predicate downgrades a Present
3198        // claim to Unresolved unless the peer is credited for that key.
3199        // Snapshot per-peer commitment data. We need two views:
3200        //   - `commitment_by_peer_snapshot`: peers that currently have
3201        //     a verified commitment record on file (used to look up
3202        //     their current hash).
3203        //   - `capable_peer_snapshot`: the sticky "ever v12-capable"
3204        //     set. Sourced from a separate set rather than the
3205        //     commitment map so eviction (PeerRemoved cleanup, sybil
3206        //     cap at `MAX_LAST_COMMITMENT_BY_PEER`) does NOT downgrade
3207        //     a previously-v12 peer to "legacy" credit-unconditionally.
3208        //     Legacy / pre-v12 peers that have never sent a commitment
3209        //     remain absent from the set and are credited via the
3210        //     legacy path so mixed-version networks stay live.
3211        let commitment_by_peer_snapshot: HashMap<PeerId, [u8; 32]> = {
3212            let map = last_commitment_by_peer.read().await;
3213            map.iter()
3214                // Read the CACHED hash (§13) — no per-cycle re-serialize/re-hash
3215                // of every peer's ~5 KiB commitment.
3216                .filter_map(|(p, rec)| rec.commitment_hash().map(|h| (*p, h)))
3217                .collect()
3218        };
3219        let capable_peer_snapshot: HashSet<PeerId> = ever_capable_peers.read().await.clone();
3220        // Take a full snapshot of recent_provers under the read lock,
3221        // then release. The cache is bounded (16/key × keys), so the
3222        // clone is cheap.
3223        let provers_snapshot = recent_provers.read().await.clone();
3224        // For the replica-fetch path, we need to know whether THIS
3225        // node already holds the key being verified. The v12 §6
3226        // holder-credit gate is meant to prevent uncredited Present
3227        // claims from contributing to paid-list / reward quorum for
3228        // keys we DO hold (and could audit ourselves). For keys we
3229        // are trying to FETCH (i.e. not in local storage), there is
3230        // no possible local audit credit, and gating the presence
3231        // quorum on credit would deadlock replica-repair in a
3232        // fully v12-capable close group.
3233        let mut locally_held: HashSet<XorName> = HashSet::new();
3234        for key in &keys_needing_network {
3235            if storage.exists(key).unwrap_or(false) {
3236                locally_held.insert(*key);
3237            }
3238        }
3239        let holder_credit = |peer: &PeerId, key: &XorName| -> bool {
3240            if !locally_held.contains(key) {
3241                // Replica-fetch path: we don't hold this key, so we
3242                // cannot have collected audit credit for it. Trust
3243                // Present claims to drive fetch-source promotion;
3244                // chunk-PUT payment_verifier is the security backstop
3245                // when the bytes actually arrive.
3246                return true;
3247            }
3248            if !capable_peer_snapshot.contains(peer) {
3249                // Pre-v12 / legacy peer that has never gossiped a
3250                // commitment. The v12 §6 holder-eligibility check
3251                // doesn't apply: their Present evidence comes through
3252                // the legacy path and we credit it unconditionally
3253                // so a mixed-version network stays live during
3254                // transition.
3255                return true;
3256            }
3257            let Some(hash) = commitment_by_peer_snapshot.get(peer) else {
3258                // Peer is commitment_capable (sticky) but currently
3259                // has no live commitment record on file (e.g. their
3260                // last gossip was evicted from the LRU cache, or it
3261                // failed verification). Withhold credit until they
3262                // re-prove storage under a fresh commitment.
3263                return false;
3264            };
3265            provers_snapshot.is_credited_holder(key, peer, hash)
3266        };
3267
3268        let mut evaluated: Vec<(XorName, KeyVerificationOutcome, HintPipeline)> = Vec::new();
3269        {
3270            let q = queues.read().await;
3271            for key in &keys_needing_network {
3272                let Some(ev) = evidence.get(key) else {
3273                    continue;
3274                };
3275                let Some(entry) = q.get_pending(key) else {
3276                    continue;
3277                };
3278                let outcome = quorum::evaluate_key_evidence_with_holder_check(
3279                    key,
3280                    ev,
3281                    &targets,
3282                    config,
3283                    holder_credit,
3284                );
3285                evaluated.push((*key, outcome, entry.pipeline));
3286            }
3287        } // read lock released
3288
3289        // Step 4: Insert verified keys into PaidForList (no lock held).
3290        let mut paid_insert_keys: Vec<XorName> = Vec::new();
3291        for (key, outcome, _) in &evaluated {
3292            if matches!(
3293                outcome,
3294                KeyVerificationOutcome::QuorumVerified { .. }
3295                    | KeyVerificationOutcome::PaidListVerified { .. }
3296            ) {
3297                paid_insert_keys.push(*key);
3298            }
3299        }
3300        for key in &paid_insert_keys {
3301            if let Err(e) = paid_list.insert(key).await {
3302                warn!("Failed to add verified key to PaidForList: {e}");
3303            }
3304        }
3305
3306        // Paid-only hints normally update PaidForList only. If this node is
3307        // also within the storage-admission group for the key, a verified
3308        // paid-only hint can safely repair a missing replica using sources
3309        // from the same verification round.
3310        let mut paid_only_fetch_keys: HashSet<XorName> = HashSet::new();
3311        for (key, outcome, pipeline) in &evaluated {
3312            if *pipeline == HintPipeline::PaidOnly
3313                && matches!(
3314                    outcome,
3315                    KeyVerificationOutcome::QuorumVerified { .. }
3316                        | KeyVerificationOutcome::PaidListVerified { .. }
3317                )
3318                && !storage.exists(key).unwrap_or(false)
3319                && admission::is_responsible(
3320                    &self_id,
3321                    key,
3322                    p2p_node,
3323                    storage_admission_width(config.close_group_size),
3324                )
3325                .await
3326            {
3327                paid_only_fetch_keys.insert(*key);
3328            }
3329        }
3330
3331        // Step 5: Update queues with the evaluated outcomes.
3332        let mut q = queues.write().await;
3333        for (key, outcome, pipeline) in evaluated {
3334            match outcome {
3335                KeyVerificationOutcome::QuorumVerified { sources }
3336                | KeyVerificationOutcome::PaidListVerified { sources } => {
3337                    let fetch_eligible =
3338                        pipeline == HintPipeline::Replica || paid_only_fetch_keys.contains(&key);
3339                    if fetch_eligible && !sources.is_empty() {
3340                        let distance =
3341                            crate::client::xor_distance(&key, p2p_node.peer_id().as_bytes());
3342                        // Atomic remove+enqueue: on fetch_queue capacity miss
3343                        // the pending entry is preserved so this verified key
3344                        // is retried on the next cycle (no silent drop).
3345                        let _ = q.promote_pending_to_fetch(key, distance, sources);
3346                        // Not terminal — either moved to fetch queue, or
3347                        // retained as pending until queue drains.
3348                    } else if fetch_eligible && sources.is_empty() {
3349                        warn!(
3350                            "Verified storage-admitted key {} has no holders (possible data loss)",
3351                            hex::encode(key)
3352                        );
3353                        q.remove_pending(&key);
3354                        terminal_keys.push(key);
3355                    } else {
3356                        q.remove_pending(&key);
3357                        terminal_keys.push(key);
3358                    }
3359                }
3360                KeyVerificationOutcome::QuorumFailed
3361                | KeyVerificationOutcome::QuorumInconclusive => {
3362                    q.remove_pending(&key);
3363                    terminal_keys.push(key);
3364                }
3365            }
3366        }
3367    }
3368
3369    // Step 6: Remove terminal keys from bootstrap pending set and re-check
3370    // the drain condition.
3371    update_bootstrap_after_verification(
3372        &terminal_keys,
3373        bootstrap_state,
3374        queues,
3375        is_bootstrapping,
3376        bootstrap_complete_notify,
3377    )
3378    .await;
3379
3380    let (pending_after, fetch_after, in_flight_after) = {
3381        let q = queues.read().await;
3382        (
3383            q.pending_count(),
3384            q.fetch_queue_count(),
3385            q.in_flight_count(),
3386        )
3387    };
3388    let terminal_key_count = terminal_keys.len();
3389    let elapsed_ms = cycle_started.elapsed().as_millis();
3390
3391    if elapsed_ms >= VERIFICATION_CYCLE_SLOW_LOG_MS {
3392        info!(
3393            target: "ant_node::replication::verification",
3394            "Slow replication verification cycle: pending_start={initial_pending_count}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
3395        );
3396    } else {
3397        debug!(
3398            target: "ant_node::replication::verification",
3399            "Replication verification cycle: pending_start={initial_pending_count}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
3400        );
3401    }
3402}
3403
3404/// Post-verification bootstrap bookkeeping: remove terminal keys from the
3405/// bootstrap pending set and transition out of bootstrapping when drained.
3406async fn update_bootstrap_after_verification(
3407    terminal_keys: &[XorName],
3408    bootstrap_state: &Arc<RwLock<BootstrapState>>,
3409    queues: &Arc<RwLock<ReplicationQueues>>,
3410    is_bootstrapping: &Arc<RwLock<bool>>,
3411    bootstrap_complete_notify: &Arc<Notify>,
3412) {
3413    if terminal_keys.is_empty() || bootstrap_state.read().await.is_drained() {
3414        return;
3415    }
3416    {
3417        let mut bs = bootstrap_state.write().await;
3418        for key in terminal_keys {
3419            bs.remove_key(key);
3420        }
3421    }
3422    let q = queues.read().await;
3423    if bootstrap::check_bootstrap_drained(bootstrap_state, &q).await {
3424        complete_bootstrap(is_bootstrapping, bootstrap_complete_notify).await;
3425    }
3426}
3427
3428/// Set `is_bootstrapping` to `false` and wake all waiters.
3429async fn complete_bootstrap(
3430    is_bootstrapping: &Arc<RwLock<bool>>,
3431    bootstrap_complete_notify: &Arc<Notify>,
3432) {
3433    *is_bootstrapping.write().await = false;
3434    bootstrap_complete_notify.notify_waiters();
3435    info!("Replication bootstrap complete");
3436}
3437
3438// ---------------------------------------------------------------------------
3439// Fetch types and single-fetch executor
3440// ---------------------------------------------------------------------------
3441
3442/// Result classification for a single fetch attempt.
3443enum FetchResult {
3444    /// Data fetched, integrity-checked, and stored successfully.
3445    Stored,
3446    /// Content-address integrity check failed — do not retry.
3447    IntegrityFailed,
3448    /// Source failed (network error or non-success response) — retryable.
3449    SourceFailed,
3450}
3451
3452/// Outcome produced by [`execute_single_fetch`] and consumed by the fetch
3453/// worker loop to update queue state.
3454struct FetchOutcome {
3455    key: XorName,
3456    result: FetchResult,
3457}
3458
3459#[allow(clippy::too_many_lines)]
3460/// Execute a single fetch request against `source` for `key`.
3461///
3462/// Handles encoding, network I/O, integrity checking, storage, and trust
3463/// event reporting.  Returns a [`FetchOutcome`] so the caller can update
3464/// queue state without holding any locks during the network round-trip.
3465async fn execute_single_fetch(
3466    p2p_node: Arc<P2PNode>,
3467    storage: Arc<LmdbStorage>,
3468    config: Arc<ReplicationConfig>,
3469    key: XorName,
3470    source: PeerId,
3471) -> FetchOutcome {
3472    let request = protocol::FetchRequest { key };
3473    let msg = ReplicationMessage {
3474        request_id: rand::thread_rng().gen::<u64>(),
3475        body: ReplicationMessageBody::FetchRequest(request),
3476    };
3477
3478    let encoded = match msg.encode() {
3479        Ok(data) => data,
3480        Err(e) => {
3481            warn!("Failed to encode fetch request: {e}");
3482            return FetchOutcome {
3483                key,
3484                result: FetchResult::SourceFailed,
3485            };
3486        }
3487    };
3488
3489    let result = p2p_node
3490        .send_request(
3491            &source,
3492            REPLICATION_PROTOCOL_ID,
3493            encoded,
3494            config.fetch_request_timeout,
3495        )
3496        .await;
3497
3498    match result {
3499        Ok(response) => {
3500            let Ok(resp_msg) = ReplicationMessage::decode(&response.data) else {
3501                p2p_node
3502                    .report_trust_event(
3503                        &source,
3504                        TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3505                    )
3506                    .await;
3507                return FetchOutcome {
3508                    key,
3509                    result: FetchResult::SourceFailed,
3510                };
3511            };
3512
3513            match resp_msg.body {
3514                ReplicationMessageBody::FetchResponse(protocol::FetchResponse::Success {
3515                    key: resp_key,
3516                    data,
3517                }) => {
3518                    // Validate the response key matches the requested key.
3519                    // A malicious peer could serve valid data for a different
3520                    // key, passing integrity checks while the requested key
3521                    // is falsely marked as fetched.
3522                    if resp_key != key {
3523                        warn!(
3524                            "Fetch response key mismatch: requested {}, got {}",
3525                            hex::encode(key),
3526                            hex::encode(resp_key)
3527                        );
3528                        p2p_node
3529                            .report_trust_event(
3530                                &source,
3531                                TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3532                            )
3533                            .await;
3534                        return FetchOutcome {
3535                            key,
3536                            result: FetchResult::IntegrityFailed,
3537                        };
3538                    }
3539
3540                    // Enforce chunk size invariant on fetched data.
3541                    // Checked before the content-address hash to avoid
3542                    // hashing up to 10 MiB of oversized junk data.
3543                    if data.len() > crate::ant_protocol::MAX_CHUNK_SIZE {
3544                        warn!(
3545                            "Fetched record {} exceeds MAX_CHUNK_SIZE ({} > {})",
3546                            hex::encode(resp_key),
3547                            data.len(),
3548                            crate::ant_protocol::MAX_CHUNK_SIZE,
3549                        );
3550                        p2p_node
3551                            .report_trust_event(
3552                                &source,
3553                                TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3554                            )
3555                            .await;
3556                        return FetchOutcome {
3557                            key,
3558                            result: FetchResult::IntegrityFailed,
3559                        };
3560                    }
3561
3562                    // Content-address integrity check.
3563                    let computed = crate::client::compute_address(&data);
3564                    if computed != resp_key {
3565                        warn!(
3566                            "Fetched record integrity check failed: expected {}, got {}",
3567                            hex::encode(resp_key),
3568                            hex::encode(computed)
3569                        );
3570                        p2p_node
3571                            .report_trust_event(
3572                                &source,
3573                                TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3574                            )
3575                            .await;
3576                        return FetchOutcome {
3577                            key,
3578                            result: FetchResult::IntegrityFailed,
3579                        };
3580                    }
3581
3582                    if let Err(e) = storage.put(&resp_key, &data).await {
3583                        warn!(
3584                            "Failed to store fetched record {}: {e}",
3585                            hex::encode(resp_key)
3586                        );
3587                        return FetchOutcome {
3588                            key,
3589                            result: FetchResult::SourceFailed,
3590                        };
3591                    }
3592
3593                    FetchOutcome {
3594                        key,
3595                        result: FetchResult::Stored,
3596                    }
3597                }
3598                ReplicationMessageBody::FetchResponse(protocol::FetchResponse::NotFound {
3599                    ..
3600                }) => {
3601                    // This peer was selected as a fetch source because it
3602                    // recently answered `Present` during verification. A
3603                    // subsequent NotFound is evidence of a stale/false claim
3604                    // or chunk wiping, so penalize lightly and try another
3605                    // verified source.
3606                    warn!(
3607                        "Fetch: verified source {source} returned NotFound for {}",
3608                        hex::encode(key)
3609                    );
3610                    p2p_node
3611                        .report_trust_event(
3612                            &source,
3613                            TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3614                        )
3615                        .await;
3616                    FetchOutcome {
3617                        key,
3618                        result: FetchResult::SourceFailed,
3619                    }
3620                }
3621                ReplicationMessageBody::FetchResponse(protocol::FetchResponse::Error {
3622                    reason,
3623                    ..
3624                }) => {
3625                    warn!(
3626                        "Fetch: peer {source} returned error for {}: {reason}",
3627                        hex::encode(key)
3628                    );
3629                    p2p_node
3630                        .report_trust_event(
3631                            &source,
3632                            TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3633                        )
3634                        .await;
3635                    FetchOutcome {
3636                        key,
3637                        result: FetchResult::SourceFailed,
3638                    }
3639                }
3640                _ => {
3641                    // Unexpected message type — treat as malformed.
3642                    p2p_node
3643                        .report_trust_event(
3644                            &source,
3645                            TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3646                        )
3647                        .await;
3648                    FetchOutcome {
3649                        key,
3650                        result: FetchResult::SourceFailed,
3651                    }
3652                }
3653            }
3654        }
3655        Err(e) => {
3656            debug!("Fetch request to {source} failed: {e}");
3657            // No ApplicationFailure here — P2PNode::send_request() already
3658            // reports ConnectionTimeout / ConnectionFailed to the TrustEngine.
3659            FetchOutcome {
3660                key,
3661                result: FetchResult::SourceFailed,
3662            }
3663        }
3664    }
3665}
3666
3667// ---------------------------------------------------------------------------
3668// Audit result handler
3669// ---------------------------------------------------------------------------
3670
3671/// Format the first confirmed-failed key as a 16-hex-char label.
3672///
3673/// Pairs with `challenged_peer` to form a stable cross-host correlation
3674/// handle in the audit-failure log line, e.g.
3675///
3676/// ```text
3677/// Audit failure for <peer>: …, `first_failed_key=0x18878f1d2d9e0612`
3678/// ```
3679///
3680/// Falls back to `"0x"` when the list is empty so the log line never
3681/// contains a misleading default.
3682fn first_failed_key_label(confirmed_failed_keys: &[XorName]) -> String {
3683    confirmed_failed_keys.first().map_or_else(
3684        || "0x".to_string(),
3685        |k| format!("0x{}", hex::encode(&k[..8])),
3686    )
3687}
3688
3689/// Execute the side effects for a confirmed storage-commitment audit failure.
3690///
3691/// [`plan_failed_audit`] is the pure decision INCLUDING the strike selection
3692/// (record-a-strike-for-`Timeout` vs leave-untouched for confirmed failures),
3693/// extracted so the whole glue — not just the verdict — is testable without a
3694/// live `P2PNode`. This function is only the resulting I/O. Timeouts are graced
3695/// and rollout-gated (TIMEOUT-EVICTION-DISABLED); confirmed failures penalize on
3696/// the first occurrence and revoke holder credit.
3697async fn handle_failed_audit(
3698    challenged_peer: &PeerId,
3699    confirmed_failed_key_count: usize,
3700    reason: &AuditFailureReason,
3701    p2p_node: &Arc<P2PNode>,
3702    sync_state: &Arc<RwLock<NeighborSyncState>>,
3703    recent_provers: &Arc<RwLock<RecentProvers>>,
3704    audit_timeout_strikes: &Arc<RwLock<HashMap<PeerId, u32>>>,
3705) {
3706    let action = {
3707        let mut strikes = audit_timeout_strikes.write().await;
3708        plan_failed_audit(reason, &mut strikes, challenged_peer)
3709    };
3710    match action {
3711        AuditFailureAction::TimeoutGrace => {
3712            // Honest transient slowness: no penalty, no credit loss, retain the
3713            // bootstrap claim. Only *sustained* timeouts (a peer that always
3714            // has to refetch) survive to the threshold — the per-challenge
3715            // window is never widened.
3716            debug!(
3717                "Audit timeout for {challenged_peer} (under the {}-strike threshold); \
3718                 within grace, retaining bootstrap claim, no penalty",
3719                config::AUDIT_TIMEOUT_STRIKE_THRESHOLD
3720            );
3721        }
3722        AuditFailureAction::TimeoutPenalize => {
3723            // Strikes are tracked/logged so the mechanism stays observable; the
3724            // trust report that drives eviction is gated behind
3725            // `TIMEOUT_EVICTION_ENABLED` (off this release — see its doc for the
3726            // rollout-death-spiral rationale). Confirmed storage-integrity
3727            // failures (ConfirmedPenalize below) are unaffected.
3728            warn!(
3729                "Audit timeout for {challenged_peer}: reached the {}-strike threshold of \
3730                 consecutive timeouts ({})",
3731                config::AUDIT_TIMEOUT_STRIKE_THRESHOLD,
3732                if config::TIMEOUT_EVICTION_ENABLED {
3733                    "penalizing"
3734                } else {
3735                    "eviction disabled this release — not penalizing"
3736                }
3737            );
3738            if config::TIMEOUT_EVICTION_ENABLED {
3739                p2p_node
3740                    .report_trust_event(
3741                        challenged_peer,
3742                        TrustEvent::ApplicationFailure(config::AUDIT_FAILURE_TRUST_WEIGHT),
3743                    )
3744                    .await;
3745            }
3746        }
3747        AuditFailureAction::ConfirmedPenalize => {
3748            // The caller (handle_subtree_audit_result) already logged the rich
3749            // failure line with reason + per-category summary; avoid a redundant
3750            // second error log here. `confirmed_failed_key_count` is retained in
3751            // the signature for callers/tests that assert on it.
3752            let _ = confirmed_failed_key_count;
3753            // Peer returned a non-bootstrap response — clear the active claim
3754            // while retaining claim history.
3755            {
3756                let mut state = sync_state.write().await;
3757                state.clear_active_bootstrap_claim(challenged_peer);
3758            }
3759            // Revoke holder credit on a CONFIRMED failure (DigestMismatch /
3760            // KeyAbsent / Rejected / MalformedResponse): the peer no longer
3761            // provably holds what it committed to, so it must not keep §6
3762            // holder credit for the proof TTL. The §5 `forget_commitment` path
3763            // only fires on an "unknown commitment hash" reply; genuine byte
3764            // loss surfaces here.
3765            {
3766                let mut provers_guard = recent_provers.write().await;
3767                apply_audit_failure_credit_revocation(&mut provers_guard, challenged_peer, reason);
3768            }
3769            p2p_node
3770                .report_trust_event(
3771                    challenged_peer,
3772                    TrustEvent::ApplicationFailure(config::AUDIT_FAILURE_TRUST_WEIGHT),
3773                )
3774                .await;
3775        }
3776    }
3777}
3778
3779/// Handle audit result: log findings and emit trust events.
3780async fn handle_subtree_audit_result(
3781    result: &AuditTickResult,
3782    p2p_node: &Arc<P2PNode>,
3783    sync_state: &Arc<RwLock<NeighborSyncState>>,
3784    recent_provers: &Arc<RwLock<RecentProvers>>,
3785    audit_timeout_strikes: &Arc<RwLock<HashMap<PeerId, u32>>>,
3786    config: &ReplicationConfig,
3787) {
3788    match result {
3789        AuditTickResult::Passed {
3790            challenged_peer,
3791            keys_checked,
3792        } => {
3793            debug!("Audit passed for {challenged_peer} ({keys_checked} keys)");
3794            // Peer responded normally — clear the active bootstrap claim while
3795            // retaining history so a later claim is treated as repeated abuse.
3796            {
3797                let mut state = sync_state.write().await;
3798                state.clear_active_bootstrap_claim(challenged_peer);
3799            }
3800            // A normal response proves the slowness (if any) was transient, so
3801            // reset the timeout-strike counter. Only *sustained* timeouts (a
3802            // peer that must refetch on every audit) survive this reset to
3803            // accumulate toward the penalty threshold.
3804            {
3805                let mut strikes = audit_timeout_strikes.write().await;
3806                strikes.remove(challenged_peer);
3807            }
3808            p2p_node
3809                .report_trust_event(
3810                    challenged_peer,
3811                    TrustEvent::ApplicationSuccess(REPLICATION_TRUST_WEIGHT),
3812                )
3813                .await;
3814        }
3815        AuditTickResult::Failed { evidence } => {
3816            if let FailureEvidence::AuditFailure {
3817                challenged_peer,
3818                confirmed_failed_keys,
3819                summary,
3820                reason,
3821                ..
3822            } = evidence
3823            {
3824                // Rich diagnostics (from main's audit-failure logging) + the
3825                // first-failed-key correlation handle.
3826                let first_failed_key = first_failed_key_label(confirmed_failed_keys);
3827                error!(
3828                    "Audit failure for {challenged_peer}: reason={reason:?}, confirmed_failed_keys={}, challenged_keys={}, absent_keys={}, digest_mismatch_keys={}, first_failed_key={first_failed_key}",
3829                    confirmed_failed_keys.len(),
3830                    summary.challenged_keys,
3831                    summary.absent_keys,
3832                    summary.digest_mismatch_keys,
3833                );
3834                // Route the side effects through the strike-grace path: timeouts
3835                // are graced (and rollout-gated by TIMEOUT-EVICTION-DISABLED),
3836                // deterministic failures penalize on the first occurrence and
3837                // revoke holder credit. Do NOT report ApplicationFailure inline
3838                // here — that would evict honest not-yet-upgraded peers on a
3839                // single timeout during the breaking rollout.
3840                handle_failed_audit(
3841                    challenged_peer,
3842                    confirmed_failed_keys.len(),
3843                    reason,
3844                    p2p_node,
3845                    sync_state,
3846                    recent_provers,
3847                    audit_timeout_strikes,
3848                )
3849                .await;
3850            }
3851        }
3852        AuditTickResult::BootstrapClaim { peer } => {
3853            // Gap 6: BootstrapClaimAbuse grace period in audit path.
3854            // Separate state mutation from network I/O to avoid holding the
3855            // write lock across report_trust_event.
3856            let should_report = {
3857                let now = Instant::now();
3858                let mut state = sync_state.write().await;
3859                match state.observe_bootstrap_claim(*peer, now, config.bootstrap_claim_grace_period)
3860                {
3861                    BootstrapClaimObservation::WithinGrace { .. } => {
3862                        debug!("Audit: peer {peer} claims bootstrapping (within grace period)");
3863                        false
3864                    }
3865                    BootstrapClaimObservation::PastGrace { first_seen } => {
3866                        warn!(
3867                            "Audit: peer {peer} claiming bootstrap past grace period \
3868                             ({:?} > {:?}), reporting abuse",
3869                            now.duration_since(first_seen),
3870                            config.bootstrap_claim_grace_period,
3871                        );
3872                        true
3873                    }
3874                    BootstrapClaimObservation::Repeated { first_seen } => {
3875                        warn!(
3876                            "Audit: peer {peer} repeated bootstrap claim after previously \
3877                             stopping; first claim was {:?} ago, reporting abuse",
3878                            now.duration_since(first_seen),
3879                        );
3880                        true
3881                    }
3882                }
3883            };
3884            if should_report {
3885                p2p_node
3886                    .report_trust_event(
3887                        peer,
3888                        TrustEvent::ApplicationFailure(REPLICATION_TRUST_WEIGHT),
3889                    )
3890                    .await;
3891            }
3892        }
3893        AuditTickResult::Idle | AuditTickResult::InsufficientKeys => {}
3894    }
3895}
3896
3897/// Whether a confirmed audit failure with this reason clears the peer's active
3898/// bootstrap claim. A `Timeout` does not (the peer may still be legitimately
3899/// bootstrapping); every confirmed storage-integrity reason does.
3900///
3901/// Both audits now funnel through [`handle_failed_audit`], which derives the
3902/// clear-vs-retain decision from [`decide_audit_failure_action`]; this predicate
3903/// is retained as the readable single-source-of-truth that those tests assert
3904/// against (it is the exact `reason != Timeout` rule the action planner uses).
3905#[cfg(test)]
3906fn audit_failure_clears_bootstrap_claim(reason: &AuditFailureReason) -> bool {
3907    !matches!(reason, AuditFailureReason::Timeout)
3908}
3909
3910/// Handle the result of a responsible-chunk audit tick (audit #2): emit trust
3911/// events and manage bootstrap-claim state.
3912///
3913/// Delegates to [`handle_subtree_audit_result`] so BOTH audits share one
3914/// failure path: timeouts go through the strike/grace logic (graced under the
3915/// threshold, eviction gated off this release via `TIMEOUT-EVICTION-DISABLED`)
3916/// and only confirmed storage-integrity failures penalise on the first
3917/// occurrence and revoke holder credit. Previously this handler reported
3918/// `ApplicationFailure` inline for EVERY failure including `Timeout`, which —
3919/// with the breaking v2 wire change — would false-penalise honest
3920/// not-yet-upgraded peers on a single audit. (Audit #2 cannot credit holders,
3921/// so the shared handler's strike-reset/credit-revocation is a superset of what
3922/// it needs; the responsible-chunk audit never produces `Passed { .. }` with
3923/// holder credit, so nothing is over-credited.)
3924async fn handle_audit_result(
3925    result: &AuditTickResult,
3926    p2p_node: &Arc<P2PNode>,
3927    sync_state: &Arc<RwLock<NeighborSyncState>>,
3928    recent_provers: &Arc<RwLock<RecentProvers>>,
3929    audit_timeout_strikes: &Arc<RwLock<HashMap<PeerId, u32>>>,
3930    config: &ReplicationConfig,
3931) {
3932    handle_subtree_audit_result(
3933        result,
3934        p2p_node,
3935        sync_state,
3936        recent_provers,
3937        audit_timeout_strikes,
3938        config,
3939    )
3940    .await;
3941}
3942
3943/// What the audit-failure handler should do for a given failure, given the
3944/// peer's post-increment timeout-strike count. Pure (no I/O) so the whole
3945/// decision can be exercised end-to-end without a live `P2PNode`.
3946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3947enum AuditFailureAction {
3948    /// Timeout under the strike threshold: no trust penalty, no credit
3949    /// revocation, retain the bootstrap claim (honest transient slowness).
3950    TimeoutGrace,
3951    /// Timeout at/over the threshold: report `ApplicationFailure`. Bootstrap
3952    /// claim retained; holder credit NOT revoked (the peer never admitted byte
3953    /// loss). The non-storing-peer case.
3954    TimeoutPenalize,
3955    /// Confirmed storage-integrity failure: penalize immediately, clear the
3956    /// active bootstrap claim, and revoke holder credit.
3957    ConfirmedPenalize,
3958}
3959
3960/// Upper bound on a peer's consecutive-timeout strike count. Must exceed the
3961/// largest reachable adaptive threshold (base + `MAX_ADAPTIVE_TIMEOUT_GRACE`) so
3962/// a genuinely non-responsive peer's count can always catch up to and cross an
3963/// inflated threshold — otherwise capping at the base would make timeout
3964/// penalties unreachable once the adaptive threshold rose.
3965const AUDIT_TIMEOUT_STRIKE_MAX: u32 = 64;
3966
3967/// Maximum extra grace the adaptive mechanism may add on top of the base
3968/// threshold. Bounds how far a (possibly stale) set of timing-out peers can
3969/// widen the window, so a small persistent failing cohort cannot push the
3970/// threshold arbitrarily high and shield a bad node indefinitely.
3971const MAX_ADAPTIVE_TIMEOUT_GRACE: u32 = 2 * config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
3972
3973/// Record an audit timeout for `peer` and return its new consecutive-timeout
3974/// strike count, saturating at [`AUDIT_TIMEOUT_STRIKE_MAX`] (well above any
3975/// reachable adaptive threshold). A successful audit removes the peer's entry
3976/// (the `Passed` arm of [`handle_subtree_audit_result`]), so only *consecutive*
3977/// timeouts accumulate here.
3978fn record_audit_timeout_strike(strikes: &mut HashMap<PeerId, u32>, peer: &PeerId) -> u32 {
3979    let count = strikes.entry(*peer).or_insert(0);
3980    *count = count.saturating_add(1).min(AUDIT_TIMEOUT_STRIKE_MAX);
3981    *count
3982}
3983
3984/// The adaptive timeout-strike threshold for judging `peer` (ADR-0002 "Network
3985/// Resilience"): `min(median of the OTHER timing-out peers' counts,
3986/// MAX_ADAPTIVE_TIMEOUT_GRACE) + base threshold`.
3987///
3988/// In a healthy network almost no peer carries timeout strikes, so the median
3989/// is 0 and the threshold is the base [`config::AUDIT_TIMEOUT_STRIKE_THRESHOLD`].
3990/// During genuine disruption many *honest* peers time out together, lifting the
3991/// median and widening the grace so the audit system does not pile onto a
3992/// struggling network — but the widening is capped at `MAX_ADAPTIVE_TIMEOUT_GRACE`
3993/// so a stale failing cohort cannot inflate it without bound.
3994///
3995/// `peer` is EXCLUDED from the median so a lone timing-out peer cannot raise its
3996/// own grace bar. Combined with the map being fed ONLY by timeouts (deterministic
3997/// failures never touch it), this closes self-inflation and bounds
3998/// attacker-inflation of the grace window.
3999fn adaptive_timeout_threshold(strikes: &HashMap<PeerId, u32>, peer: &PeerId) -> u32 {
4000    let grace = median_timeout_strikes_excluding(strikes, peer).min(MAX_ADAPTIVE_TIMEOUT_GRACE);
4001    grace.saturating_add(config::AUDIT_TIMEOUT_STRIKE_THRESHOLD)
4002}
4003
4004/// Lower median of the current per-peer consecutive-timeout counts, excluding
4005/// `peer`. No other peers → 0.
4006fn median_timeout_strikes_excluding(strikes: &HashMap<PeerId, u32>, peer: &PeerId) -> u32 {
4007    let mut counts: Vec<u32> = strikes
4008        .iter()
4009        .filter(|(p, _)| *p != peer)
4010        .map(|(_, c)| *c)
4011        .collect();
4012    if counts.is_empty() {
4013        return 0;
4014    }
4015    counts.sort_unstable();
4016    // Lower median: for even-sized inputs take the lower of the two middle
4017    // values ((len-1)/2), so the grace is conservative rather than inflated.
4018    counts.get((counts.len() - 1) / 2).copied().unwrap_or(0)
4019}
4020
4021/// Whether a peer's consecutive-timeout strike count reaches the (adaptive)
4022/// threshold for emitting an `ApplicationFailure` trust event.
4023fn timeout_strike_reaches_threshold(strikes: u32, threshold: u32) -> bool {
4024    strikes >= threshold
4025}
4026
4027/// Decide what to do about a confirmed audit failure. `timeout_strikes_after`
4028/// is the peer's strike count after recording this event and `timeout_threshold`
4029/// the adaptive threshold to compare against (both only meaningful when
4030/// `reason == Timeout`). Pure, so the integration-level decision can be asserted
4031/// in tests with no networking.
4032fn decide_audit_failure_action(
4033    reason: &AuditFailureReason,
4034    timeout_strikes_after: u32,
4035    timeout_threshold: u32,
4036) -> AuditFailureAction {
4037    if matches!(reason, AuditFailureReason::Timeout) {
4038        if timeout_strike_reaches_threshold(timeout_strikes_after, timeout_threshold) {
4039            AuditFailureAction::TimeoutPenalize
4040        } else {
4041            AuditFailureAction::TimeoutGrace
4042        }
4043    } else {
4044        AuditFailureAction::ConfirmedPenalize
4045    }
4046}
4047
4048/// Plan the response to a confirmed audit failure, performing the
4049/// strike-selection glue in-process: a `Timeout` records a strike against
4050/// `peer` (so consecutive timeouts accumulate) and is judged against the
4051/// adaptive threshold; every other reason is a confirmed failure that does NOT
4052/// touch the strike map. The caller owns the lock and performs the resulting I/O.
4053fn plan_failed_audit(
4054    reason: &AuditFailureReason,
4055    strikes: &mut HashMap<PeerId, u32>,
4056    peer: &PeerId,
4057) -> AuditFailureAction {
4058    // Snapshot the adaptive threshold from the *other* peers' counts (excluding
4059    // this peer), so a single peer's own timeouts cannot raise its own grace bar.
4060    let threshold = adaptive_timeout_threshold(strikes, peer);
4061    let strikes_after = if matches!(reason, AuditFailureReason::Timeout) {
4062        record_audit_timeout_strike(strikes, peer)
4063    } else {
4064        0
4065    };
4066    decide_audit_failure_action(reason, strikes_after, threshold)
4067}
4068
4069/// Whether a confirmed audit failure with this reason should revoke the
4070/// peer's `recent_provers` holder credit immediately (v12 §6).
4071///
4072/// `true` for any reason where the peer actually answered (or admitted
4073/// it cannot): `DigestMismatch`, `KeyAbsent`, `Rejected` ("missing
4074/// bytes for committed key"), `MalformedResponse` — these prove the
4075/// peer no longer holds what it committed to, so it must not keep
4076/// holder credit for the proof TTL. `false` for `Timeout`: a single
4077/// dropped packet must not strip an honest peer; the 40-min TTL is the
4078/// deliberate liveness cushion there.
4079fn audit_failure_revokes_holder_credit(reason: &AuditFailureReason) -> bool {
4080    !matches!(reason, AuditFailureReason::Timeout)
4081}
4082
4083/// Apply the holder-credit revocation decision for a confirmed audit
4084/// failure. Pure over `RecentProvers` so the handler wiring is unit-
4085/// testable without a live `P2PNode`: the production `Failed` arm of
4086/// `handle_subtree_audit_result` calls exactly this.
4087fn apply_audit_failure_credit_revocation(
4088    provers: &mut RecentProvers,
4089    challenged_peer: &PeerId,
4090    reason: &AuditFailureReason,
4091) {
4092    if audit_failure_revokes_holder_credit(reason) {
4093        provers.forget_peer(challenged_peer);
4094    }
4095}
4096
4097// `admit_bootstrap_hints` was consolidated into `admit_and_queue_hints`.
4098
4099// ---------------------------------------------------------------------------
4100// Storage-bound audit (ADR-0002) — gossip trigger + auditor-side ingestion
4101// ---------------------------------------------------------------------------
4102
4103/// State the gossip-audit trigger needs to spawn an audit. Bundled so the
4104/// message handler passes one value instead of a long argument list; all
4105/// fields are cheap `Arc` clones.
4106#[derive(Clone)]
4107struct GossipAuditTrigger {
4108    p2p_node: Arc<P2PNode>,
4109    config: Arc<ReplicationConfig>,
4110    recent_provers: Arc<RwLock<RecentProvers>>,
4111    sync_state: Arc<RwLock<NeighborSyncState>>,
4112    audit_timeout_strikes: Arc<RwLock<HashMap<PeerId, u32>>>,
4113    cooldown: Arc<RwLock<HashMap<PeerId, Instant>>>,
4114}
4115
4116/// What a gossip ingest yields for the audit trigger: the commitment hash to
4117/// pin and the `key_count` needed to size the response deadline from the actual
4118/// `ceil(sqrt(N))` subtree (ADR-0002). Returned on every VALID gossip (changed
4119/// or not) so a stable-keyset node stays auditable — not just on its first
4120/// commitment.
4121#[derive(Debug, Clone, Copy)]
4122struct AuditTarget {
4123    pin_hash: [u8; 32],
4124    key_count: u32,
4125}
4126
4127/// Per-peer audit cooldown check-and-stamp (ADR-0002 "occasional surprise
4128/// exams, keeps load low"). Returns `true` if `peer` may be audited now (and
4129/// stamps `now`), `false` if it was audited within
4130/// `AUDIT_ON_GOSSIP_COOLDOWN_SECS`. Bounds the map under a flood of distinct
4131/// peers. Pure over the passed map so the flood/cooldown behaviour is testable
4132/// without a live node: a burst of gossips from one peer yields at most one
4133/// `true` per cooldown window.
4134fn cooldown_allows_audit(map: &mut HashMap<PeerId, Instant>, peer: &PeerId, now: Instant) -> bool {
4135    let cooldown = Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS);
4136    let known = match map.get(peer) {
4137        Some(&last) => {
4138            if now.saturating_duration_since(last) < cooldown {
4139                return false;
4140            }
4141            true
4142        }
4143        None => false,
4144    };
4145    // Bound the map under churn like its siblings (drop the oldest stamp) before
4146    // admitting a brand-new peer.
4147    if !known && map.len() >= MAX_LAST_COMMITMENT_BY_PEER {
4148        if let Some(victim) = map.iter().min_by_key(|(_, &ts)| ts).map(|(p, _)| *p) {
4149            map.remove(&victim);
4150        }
4151    }
4152    map.insert(*peer, now);
4153    true
4154}
4155
4156/// The gossip-audit launch decision in ONE place so the ordering is shared
4157/// between production and its test (ADR-0002 "occasional surprise exams").
4158///
4159/// Order matters and is the security-relevant property: the per-peer cooldown is
4160/// checked-and-stamped FIRST, THEN the probability lottery (`lottery_wins`) is
4161/// applied. If the lottery were sampled first, a gossip flood would re-roll it on
4162/// every message until one won, multiplying audits. Because the cooldown is
4163/// stamped before the lottery is consulted, a LOSING ticket still consumes the
4164/// window — so each peer gets at most one audit lottery per cooldown window
4165/// regardless of how often it gossips. Production calls this with
4166/// `lottery_wins = gen_bool(AUDIT_ON_GOSSIP_PROBABILITY)`; the test calls it with
4167/// a deterministic `lottery_wins`, so a reorder regression here fails the test.
4168fn audit_launch_decision(
4169    map: &mut HashMap<PeerId, Instant>,
4170    peer: &PeerId,
4171    now: Instant,
4172    lottery_wins: bool,
4173) -> bool {
4174    // Gate 1: cooldown check-and-stamp (consumes the window even on a loss).
4175    if !cooldown_allows_audit(map, peer, now) {
4176        return false;
4177    }
4178    // Gate 2: the probability lottery.
4179    lottery_wins
4180}
4181
4182/// On a peer's *changed* gossiped commitment, maybe launch a subtree audit
4183/// (ADR-0002): fire with probability `AUDIT_ON_GOSSIP_PROBABILITY`, subject to a
4184/// per-peer cooldown, pinned to the just-ingested root. Detached so gossip
4185/// handling is never blocked on a network round-trip.
4186async fn maybe_trigger_gossip_audit(
4187    trigger: &GossipAuditTrigger,
4188    peer: &PeerId,
4189    target: AuditTarget,
4190) {
4191    // The launch decision (cooldown-then-lottery ordering) lives in the pure
4192    // `audit_launch_decision` so the ordering is shared with its test. Sample
4193    // the lottery here, then let the helper apply it AFTER the cooldown stamp.
4194    let now = Instant::now();
4195    let lottery_wins = rand::thread_rng().gen_bool(config::AUDIT_ON_GOSSIP_PROBABILITY);
4196    {
4197        let mut map = trigger.cooldown.write().await;
4198        if !audit_launch_decision(&mut map, peer, now, lottery_wins) {
4199            return;
4200        }
4201    }
4202
4203    let trigger = trigger.clone();
4204    let peer = *peer;
4205    tokio::spawn(async move {
4206        let credit = storage_commitment_audit::AuditCredit {
4207            recent_provers: &trigger.recent_provers,
4208        };
4209        let result = storage_commitment_audit::run_subtree_audit(
4210            &trigger.p2p_node,
4211            &trigger.config,
4212            &peer,
4213            target.pin_hash,
4214            target.key_count,
4215            Some(&credit),
4216        )
4217        .await;
4218        handle_subtree_audit_result(
4219            &result,
4220            &trigger.p2p_node,
4221            &trigger.sync_state,
4222            &trigger.recent_provers,
4223            &trigger.audit_timeout_strikes,
4224            &trigger.config,
4225        )
4226        .await;
4227    });
4228}
4229
4230/// Atomic check-and-stamp of the per-peer commitment sig-verify rate limit.
4231///
4232/// Returns `true` if a signature verify is allowed now (and stamps the attempt
4233/// time), `false` if the peer is within [`COMMITMENT_SIG_VERIFY_MIN_INTERVAL`]
4234/// of its last attempt. Holds one write lock across the decision so two
4235/// concurrent ingests from the same peer cannot both pass. Stamps BEFORE the
4236/// caller's expensive verify so a slow/failed verify still rate-limits the next
4237/// message. Bounds the map under a flood of distinct peer ids.
4238async fn sig_verify_rate_limit_ok(
4239    sig_verify_attempts: &Arc<RwLock<HashMap<PeerId, Instant>>>,
4240    source: &PeerId,
4241    now: Instant,
4242) -> bool {
4243    let mut attempts = sig_verify_attempts.write().await;
4244    if let Some(&last) = attempts.get(source) {
4245        if now.saturating_duration_since(last) < COMMITMENT_SIG_VERIFY_MIN_INTERVAL {
4246            return false;
4247        }
4248    }
4249    if attempts.len() >= MAX_LAST_COMMITMENT_BY_PEER && !attempts.contains_key(source) {
4250        if let Some(victim) = attempts.iter().min_by_key(|(_, &ts)| ts).map(|(p, _)| *p) {
4251            attempts.remove(&victim);
4252        }
4253    }
4254    attempts.insert(*source, now);
4255    true
4256}
4257
4258/// Verify + store an inbound commitment from a gossip peer.
4259///
4260/// Called from the inbound `NeighborSyncRequest`/`Response` handlers and
4261/// the bootstrap-sync loop. Drops the commitment unless all five gates
4262/// pass:
4263///   1. `source` is in our DHT routing table (sybil/churn cap).
4264///   2. `commitment.sender_peer_id == source.as_bytes()` (peer-id
4265///      binding to the authenticated transport peer).
4266///   3. `BLAKE3(commitment.sender_public_key) == commitment.sender_peer_id`
4267///      (the embedded pubkey actually belongs to the claimed identity —
4268///      saorsa-core derives `PeerId = BLAKE3(pubkey)`).
4269///   4. `verify_commitment_signature(commitment)` succeeds against the
4270///      embedded public key. The signed payload binds the pubkey, so an
4271///      adversary cannot swap the key while keeping the body.
4272///   5. The cache has room or this is an update for an existing entry
4273///      (sybil cap, `MAX_LAST_COMMITMENT_BY_PEER`).
4274///
4275/// On all-pass, the commitment is stored as the auditor's per-peer
4276/// "last known commitment" for use as `expected_commitment_hash` in
4277/// future audits.
4278///
4279/// Failures (no commitment / mismatched peer id / bad signature) are
4280/// silent drops — gossip is best-effort and a malformed commitment from
4281/// one peer should not affect anything else.
4282///
4283/// Returns `Some(AuditTarget)` whenever a VALID commitment was stored (whether
4284/// or not its root changed), so the caller can run a probabilistic,
4285/// cooldown-gated subtree audit. Returning on *every* valid gossip — not only
4286/// changed ones — is deliberate (ADR-0002): a node with a stable key set keeps
4287/// being auditable, so it cannot pass one audit and then delete data while
4288/// re-gossiping the same root forever. The cooldown + probability bound the
4289/// audit frequency. Returns `None` only if the commitment was dropped (failed a
4290/// gate) or there is nothing to pin.
4291///
4292/// Handle a capable peer gossiping `None` (a commitment downgrade).
4293///
4294/// A capable peer that previously gossiped a commitment but now gossips `None`
4295/// is trying to drop off the audit path. Within the answerability window we keep
4296/// the cached commitment pinned AND return it as an audit target so this gossip
4297/// still schedules a subtree audit against the peer's last known commitment — if
4298/// it genuinely dropped the data, the audit fails (there is no periodic tick, so
4299/// the trigger MUST fire here or the downgrade is never re-challenged).
4300///
4301/// But this only holds within the SAME `GOSSIP_ANSWERABILITY_TTL` the responder
4302/// honours for its own retired commitment: once that elapses since we last
4303/// received the peer's commitment, an honest peer has legitimately retired that
4304/// root (its responder side `retire_current`s and lets it age out) and can no
4305/// longer answer a pin on it. Auditing it past the TTL would manufacture a false
4306/// failure, so we then forget the cached commitment (keeping the sticky
4307/// `commitment_capable` bit) and stop pinning it.
4308async fn handle_commitment_downgrade(
4309    source: &PeerId,
4310    last_commitment_by_peer: &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
4311) -> Option<AuditTarget> {
4312    let now = Instant::now();
4313    let cached = {
4314        let map = last_commitment_by_peer.read().await;
4315        map.get(source).and_then(|rec| {
4316            if !rec.commitment_capable {
4317                return None;
4318            }
4319            let last = rec.last_commitment()?;
4320            let pin = rec.commitment_hash()?;
4321            let fresh = now.saturating_duration_since(rec.received_at)
4322                < crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL;
4323            Some((pin, last.key_count, fresh))
4324        })
4325    };
4326    match cached {
4327        Some((pin, key_count, true)) => {
4328            warn!(
4329                "ingest_peer_commitment: commitment-capable peer {source} sent None \
4330                 (downgrade attempt); auditing against its last cached commitment"
4331            );
4332            Some(AuditTarget {
4333                pin_hash: pin,
4334                key_count,
4335            })
4336        }
4337        Some((_, _, false)) => {
4338            // Cached commitment has aged past the answerability window — forget
4339            // it so we stop pinning a root the peer is no longer obliged to
4340            // answer. Keep `commitment_capable` (sticky). Re-check freshness
4341            // UNDER the write lock (compare-and-clear): a concurrent valid gossip
4342            // from this peer may have refreshed `received_at` in the gap between
4343            // our read and write locks; if so, leave its fresh commitment intact.
4344            if let Some(rec) = last_commitment_by_peer.write().await.get_mut(source) {
4345                let still_stale = now.saturating_duration_since(rec.received_at)
4346                    >= crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL;
4347                if still_stale {
4348                    rec.clear_commitment();
4349                    debug!(
4350                        "ingest_peer_commitment: capable peer {source} sent None and its cached \
4351                         commitment aged past the answerability TTL; forgetting it"
4352                    );
4353                }
4354            }
4355            None
4356        }
4357        None => None,
4358    }
4359}
4360
4361async fn ingest_peer_commitment(
4362    source: &PeerId,
4363    commitment: Option<&StorageCommitment>,
4364    p2p_node: &Arc<P2PNode>,
4365    last_commitment_by_peer: &Arc<RwLock<HashMap<PeerId, PeerCommitmentRecord>>>,
4366    ever_capable_peers: &Arc<RwLock<HashSet<PeerId>>>,
4367    sig_verify_attempts: &Arc<RwLock<HashMap<PeerId, Instant>>>,
4368) -> Option<AuditTarget> {
4369    let Some(c) = commitment else {
4370        return handle_commitment_downgrade(source, last_commitment_by_peer).await;
4371    };
4372    // RT-membership gate: only accept commitments from peers in our
4373    // routing table. Off-RT senders (sybils, drive-by relays) cannot
4374    // populate the cache, which closes the hole where a flood of
4375    // off-RT identities could fill the cap and evict honest
4376    // peers. The neighbor-sync request handler applies the same gate
4377    // before admitting inbound replication hints (see neighbor_sync.rs
4378    // `sender_in_rt`); we mirror that policy here for the commitment
4379    // piggyback.
4380    if !p2p_node.dht_manager().is_in_routing_table(source).await {
4381        debug!("ingest_peer_commitment: source {source} not in routing table (dropped)");
4382        return None;
4383    }
4384    // Peer-id binding: the commitment's claimed sender must match the
4385    // authenticated transport peer (`source`). Defeats relay/replay
4386    // and also pins which embedded public key we are about to verify
4387    // against — the verify itself trusts the embedded key, so the
4388    // peer-id binding is the link to a real identity.
4389    if &c.sender_peer_id != source.as_bytes() {
4390        warn!(
4391            "ingest_peer_commitment: sender_peer_id mismatch from {source} \
4392             (dropped, possible relay attempt)"
4393        );
4394        return None;
4395    }
4396    // Peer-id to embedded-pubkey binding: saorsa-core derives PeerId as
4397    // BLAKE3(pubkey_bytes). Without this check, a responder could sign
4398    // with a throwaway key they own and lie about which identity it
4399    // belongs to (the embedded-key signature would verify trivially).
4400    let derived_peer_id = *blake3::hash(&c.sender_public_key).as_bytes();
4401    if derived_peer_id != c.sender_peer_id {
4402        warn!(
4403            "ingest_peer_commitment: embedded pubkey does not hash to claimed peer_id for \
4404             {source} (dropped, throwaway-key attack)"
4405        );
4406        return None;
4407    }
4408    // §2 step 3 + §11 DoS: rate-limit per-peer to at most one ML-DSA
4409    // signature verify per `COMMITMENT_SIG_VERIFY_MIN_INTERVAL`. A
4410    // sybil/RT-membership-bypassing peer that flooded valid-looking
4411    // gossip would otherwise burn CPU on every message. The rate
4412    // limit is checked AFTER cheap structural gates (RT, peer-id
4413    // binding, pubkey-binding) and BEFORE the expensive sig verify.
4414    //
4415    // Tracked in `sig_verify_attempts` (separate from
4416    // last_commitment_by_peer) so EVERY attempt — successful or not —
4417    // bumps the rate-limit clock. Reading only from PeerCommitmentRecord
4418    // would skip the cap for peers we've never successfully verified,
4419    // letting a flood of invalid-but-structurally-plausible gossips
4420    // burn CPU.
4421    let now = Instant::now();
4422    if !sig_verify_rate_limit_ok(sig_verify_attempts, source, now).await {
4423        debug!(
4424            "ingest_peer_commitment: rate-limited sig verify from {source} \
4425             (< {COMMITMENT_SIG_VERIFY_MIN_INTERVAL:?} since last attempt); dropped"
4426        );
4427        return None;
4428    }
4429    // Signature verify, using the public key embedded in the commitment
4430    // itself. The pubkey is bound by the signature payload (see
4431    // commitment_signed_payload) so an adversary cannot keep the body
4432    // and swap the key to one they hold the secret for.
4433    if !crate::replication::commitment::verify_commitment_signature(c) {
4434        warn!(
4435            "ingest_peer_commitment: signature did not verify under embedded key for {source} \
4436             (dropped, forged commitment)"
4437        );
4438        return None;
4439    }
4440    // The new commitment's hash, used to store and to pin for the audit target.
4441    let new_hash = commitment_hash(c);
4442    let mut map = last_commitment_by_peer.write().await;
4443    // Sybil/churn cap: if we're at the hard cap AND this is a new peer,
4444    // evict an arbitrary existing entry to make room. Updates for peers
4445    // already in the map are always accepted (they replace, not grow).
4446    if map.len() >= MAX_LAST_COMMITMENT_BY_PEER && !map.contains_key(source) {
4447        // Drop one arbitrary entry. HashMap iter order is random which
4448        // is fine — over time PeerRemoved cleanup keeps the working set
4449        // anchored on the real RT membership; this cap only fires under
4450        // active flooding attempts.
4451        if let Some(victim) = map.keys().next().copied() {
4452            map.remove(&victim);
4453            warn!(
4454                "ingest_peer_commitment: cache full ({MAX_LAST_COMMITMENT_BY_PEER}); \
4455                 evicted {victim} to admit {source}"
4456            );
4457        }
4458    }
4459    // Preserve sticky commitment_capable across updates — once true,
4460    // always true. New entries start with capable = true (we just
4461    // verified a valid commitment from this peer).
4462    map.entry(*source)
4463        .and_modify(|r| {
4464            // set_commitment refreshes the cached hash (§13) alongside the
4465            // commitment + received_at so they never drift.
4466            r.set_commitment(c.clone(), now);
4467            r.last_sig_verify_at = now;
4468            r.commitment_capable = true; // sticky-redundant but explicit
4469        })
4470        .or_insert_with(|| PeerCommitmentRecord::from_verified(c.clone(), now));
4471    drop(map);
4472    // Record the sticky "ever v12-capable" bit in a set independent of
4473    // `last_commitment_by_peer` (whose entries can be evicted by
4474    // `PeerRemoved` and the sybil cap). This is what the §3 audit
4475    // shield and the §6 holder-eligibility closure consult to decide
4476    // whether the peer is expected to speak v12.
4477    //
4478    // Capped at `MAX_EVER_CAPABLE_PEERS` to bound memory under
4479    // identity-rotation attacks: once full, new entries are refused.
4480    // Refusal degrades over-cap peers to the behaviour before this set
4481    // existed (treated as legacy on rejoin), which is not a security
4482    // regression and preserves the historic set stable.
4483    {
4484        let mut set = ever_capable_peers.write().await;
4485        if set.contains(source) || set.len() < MAX_EVER_CAPABLE_PEERS {
4486            set.insert(*source);
4487        } else {
4488            warn!(
4489                "ingest_peer_commitment: ever_capable_peers at cap \
4490                 ({MAX_EVER_CAPABLE_PEERS}); refusing to record {source} as sticky-capable"
4491            );
4492        }
4493    }
4494    // Return an audit target for EVERY valid stored commitment (changed or
4495    // not), so the caller's cooldown+probability-gated trigger keeps a
4496    // stable-keyset peer auditable over time (ADR-0002). Only a serialization
4497    // failure (new_hash == None, unreachable for a real commitment) yields None.
4498    new_hash.map(|pin_hash| AuditTarget {
4499        pin_hash,
4500        key_count: c.key_count,
4501    })
4502}
4503
4504// ---------------------------------------------------------------------------
4505// Storage-bound audit (v12) — responder commitment rotation
4506// ---------------------------------------------------------------------------
4507
4508/// Read the current LMDB key set, build + sign a fresh
4509/// `StorageCommitment`, and rotate it into `state` as the new `current`.
4510/// The prior `current` is demoted to `previous`; the prior `previous` is
4511/// dropped (per `ResponderCommitmentState::rotate`).
4512///
4513/// For content-addressed chunks (Autonomi's chunk store), `address ==
4514/// BLAKE3(content)`, so `bytes_hash := key` and we don't have to
4515/// re-read each chunk's bytes to compute the leaf hash.
4516///
4517/// Skips (returns `Ok(())`) if the key set is empty — no commitment to
4518/// rotate. The auditor side handles "no commitment for this peer" by
4519/// falling back to the legacy plain-digest audit path.
4520async fn rebuild_and_rotate_commitment(
4521    storage: &Arc<LmdbStorage>,
4522    identity: &Arc<NodeIdentity>,
4523    state: &Arc<ResponderCommitmentState>,
4524    p2p: &Arc<P2PNode>,
4525    config: &Arc<ReplicationConfig>,
4526) -> Result<()> {
4527    use saorsa_pqc::api::sig::{MlDsaSecretKey, MlDsaVariant};
4528
4529    let stored_keys = storage
4530        .all_keys()
4531        .await
4532        .map_err(|e| Error::Storage(format!("commitment build: read keys: {e}")))?;
4533
4534    // Commit only to keys we are still RESPONSIBLE for ("want-to-hold"), not
4535    // everything currently on disk ("hold"). This is the half of the retention
4536    // contract that lets out-of-range chunks age out: a key that has left our
4537    // close group is excluded from the NEXT commitment, so within at most
4538    // RETAINED_GOSSIPED_COMMITMENTS gossip rotations it falls out of the
4539    // last-2-gossiped window, `ResponderCommitmentState::is_held` goes false,
4540    // and the pruner (which until then vetoes its deletion) reclaims it. Without
4541    // this filter the pruner's reprieve would keep re-committing stale keys
4542    // forever (the rebuild reads all_keys, so a retained-on-disk key would be
4543    // re-committed and re-gossiped every rotation — a permanent pin).
4544    let storage_empty = stored_keys.is_empty();
4545    let self_id = *p2p.peer_id();
4546    let mut keys = Vec::with_capacity(stored_keys.len());
4547    for k in stored_keys {
4548        if admission::is_responsible(&self_id, &k, p2p, config.close_group_size).await {
4549            keys.push(k);
4550        }
4551    }
4552
4553    if keys.is_empty() {
4554        if storage_empty {
4555            // Storage is genuinely empty — there is nothing to answer for, so
4556            // drop the previously advertised commitment immediately. Keeping it
4557            // would leave remote auditors pinning a hash we can never satisfy
4558            // again (the bytes are gone).
4559            if state.retained_slot_count() > 0 {
4560                debug!("Commitment rotation: storage empty, clearing retained slots");
4561                state.clear_all();
4562            }
4563            return Ok(());
4564        }
4565        // Bytes are still on disk but no key is currently in range. We must NOT
4566        // clear retention here: a peer may still be pinning a root we gossiped
4567        // moments ago and could demand its bytes in a round-2 challenge, which
4568        // we can still answer (the bytes are present). But we must STOP
4569        // advertising the stale commitment: retire it so `current()` returns
4570        // `None` and the gossip-emit sites stop re-emitting and re-stamping it.
4571        // The retired slot then ages out by its gossip-answerability TTL while
4572        // remaining answerable for in-flight pins until then. Once it ages out,
4573        // `is_held` flips false and the pruner reclaims the now-uncommitted,
4574        // out-of-range chunks. (Calling `age_out` alone would leave `current()`
4575        // pointing at the stale root, which the gossip loop would keep
4576        // re-stamping — pinning its keys forever.)
4577        debug!(
4578            "Commitment rotation: no responsible keys to commit to; retiring current commitment \
4579             (stays answerable until its gossip TTL lapses, bytes still on disk)"
4580        );
4581        state.retire_current();
4582        return Ok(());
4583    }
4584
4585    // Cap to MAX_COMMITMENT_KEY_COUNT for v12 (responder must not commit
4586    // to more than the protocol limit; auditor would reject the
4587    // commitment otherwise).
4588    let cap = commitment::MAX_COMMITMENT_KEY_COUNT as usize;
4589    if keys.len() > cap {
4590        warn!(
4591            "Commitment rotation: key set ({}) exceeds MAX_COMMITMENT_KEY_COUNT ({}); \
4592             truncating — investigate as this likely means a misconfiguration",
4593            keys.len(),
4594            cap
4595        );
4596    }
4597
4598    // INVARIANT: this module is only used with CONTENT-ADDRESSED chunks,
4599    // where `key == BLAKE3(content)`, so `bytes_hash := key` and we skip a
4600    // full chunk re-read per rotation.
4601    //
4602    // Consequence to be precise about: because the leaf is `(key, key)`,
4603    // the Merkle root commits to the SET OF KEYS, not to the bytes. The
4604    // commitment therefore binds "which keys I claim to hold"; it does NOT
4605    // by itself prove byte possession. Byte possession is enforced by the
4606    // audit-verify path, which recomputes `bytes_hash == BLAKE3(local_bytes)`
4607    // and the per-key digest against the AUDITOR'S OWN local copy of the
4608    // bytes — so a responder that holds the key list but dropped the bytes
4609    // still fails (`missing bytes for committed key` / digest mismatch).
4610    // This is sound ONLY while keys are content addresses. If this module
4611    // is ever reused for non-content-addressed records (`bytes_hash != key`),
4612    // the `(k, k)` shortcut would let a byte-less node forge a valid root and
4613    // MUST be replaced with `(key, BLAKE3(bytes))` computed from real bytes.
4614    let entries: Vec<_> = keys.into_iter().take(cap).map(|k| (k, k)).collect();
4615
4616    // No-op-rotation guard: compute just the Merkle root from `entries`
4617    // and compare against the currently-advertised commitment's root.
4618    // If they match, the key set is unchanged and a new rotation would
4619    // only swap a randomized ML-DSA signature for a fresh one — same
4620    // content, different commitment_hash. That invalidates every
4621    // outstanding `recent_provers` credit on this node across the
4622    // close group with no security benefit, breaking steady-state
4623    // quorum liveness on large nodes that can't re-audit every key
4624    // every rotation interval. Skip the rotation entirely when the
4625    // tree is unchanged.
4626    // Build the tree ONCE here (moving `entries`): it serves both the no-op
4627    // root check below and, if we proceed, the signed commitment via
4628    // `build_from_tree` (§11 — previously the tree was built here and AGAIN
4629    // inside `BuiltCommitment::build`).
4630    let candidate_tree = commitment::MerkleTree::build(entries)
4631        .map_err(|e| Error::Crypto(format!("commitment tree build: {e}")))?;
4632    let candidate_root = candidate_tree.root();
4633    if let Some(current) = state.current() {
4634        if current.commitment().root == candidate_root {
4635            debug!(
4636                "Commitment rotation: key set unchanged (root={}); skipping no-op re-sign",
4637                hex::encode(candidate_root)
4638            );
4639            // Even though we skip re-signing (to avoid invalidating holder
4640            // credit), retention must still advance on the wall clock: a
4641            // previously-gossiped commitment that holds a now-out-of-range key
4642            // must be able to age out of the answerability window even when the
4643            // committed key set is frozen here for many rotations. Without this,
4644            // the no-op guard would pin a stale slot — and its key — forever.
4645            state.age_out();
4646            return Ok(());
4647        }
4648    }
4649
4650    let sk_bytes = identity.secret_key_bytes().to_vec();
4651    let sk = MlDsaSecretKey::from_bytes(MlDsaVariant::MlDsa65, &sk_bytes)
4652        .map_err(|e| Error::Crypto(format!("commitment build: load sk: {e}")))?;
4653    let pk_bytes = identity.public_key().as_bytes().to_vec();
4654    let peer_id_bytes = *p2p.peer_id().as_bytes();
4655
4656    let built = commitment_state::BuiltCommitment::build_from_tree(
4657        candidate_tree,
4658        &peer_id_bytes,
4659        &sk,
4660        &pk_bytes,
4661    )
4662    .map_err(|e| Error::Crypto(format!("commitment build: {e}")))?;
4663
4664    let hash = hex::encode(built.hash());
4665    let key_count = built.commitment().key_count;
4666    state.rotate(built);
4667    info!("Storage commitment rotated: hash={hash} key_count={key_count}");
4668    Ok(())
4669}
4670
4671#[cfg(test)]
4672#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4673mod tests {
4674    use super::{
4675        adaptive_timeout_threshold, apply_audit_failure_credit_revocation,
4676        audit_failure_clears_bootstrap_claim, audit_failure_revokes_holder_credit,
4677        audit_launch_decision, config, cooldown_allows_audit, decide_audit_failure_action,
4678        first_failed_key_label, fresh_offer_payment_context, median_timeout_strikes_excluding,
4679        paid_notify_payment_context, plan_failed_audit, record_audit_timeout_strike,
4680        timeout_strike_reaches_threshold, AuditFailureAction, AUDIT_TIMEOUT_STRIKE_MAX,
4681    };
4682    use crate::payment::VerificationContext;
4683    use crate::replication::recent_provers::RecentProvers;
4684    use crate::replication::types::AuditFailureReason;
4685    use saorsa_core::identity::PeerId;
4686    use std::collections::HashMap;
4687    use std::time::Duration;
4688    use std::time::Instant;
4689
4690    fn test_peer(b: u8) -> PeerId {
4691        let mut bytes = [0u8; 32];
4692        bytes[0] = b;
4693        PeerId::from_bytes(bytes)
4694    }
4695
4696    fn test_key(b: u8) -> crate::ant_protocol::XorName {
4697        let mut k = [0u8; 32];
4698        k[0] = b;
4699        k
4700    }
4701
4702    #[test]
4703    fn fresh_offer_runs_client_put_payment_checks() {
4704        assert_eq!(
4705            fresh_offer_payment_context(),
4706            VerificationContext::ClientPut
4707        );
4708    }
4709
4710    #[test]
4711    fn paid_notify_uses_paid_list_admission_payment_checks() {
4712        assert_eq!(
4713            paid_notify_payment_context(),
4714            VerificationContext::PaidListAdmission
4715        );
4716    }
4717
4718    #[test]
4719    fn audit_timeout_preserves_active_bootstrap_claim() {
4720        assert!(!audit_failure_clears_bootstrap_claim(
4721            &AuditFailureReason::Timeout
4722        ));
4723    }
4724
4725    fn strike_peer(b: u8) -> PeerId {
4726        let mut bytes = [0u8; 32];
4727        bytes[0] = b;
4728        PeerId::from_bytes(bytes)
4729    }
4730
4731    // HELPER-LEVEL: counter arithmetic + threshold predicate. The reset is
4732    // simulated by an in-test `strikes.remove`; the real reset path (the
4733    // `Passed` arm) is covered at the glue level below.
4734    #[test]
4735    fn single_timeout_then_success_emits_no_failure_and_resets() {
4736        let peer = strike_peer(1);
4737        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4738        let base = config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
4739        let after_one = record_audit_timeout_strike(&mut strikes, &peer);
4740        assert_eq!(after_one, 1);
4741        assert!(!timeout_strike_reaches_threshold(after_one, base));
4742        strikes.remove(&peer);
4743        assert!(!strikes.contains_key(&peer));
4744    }
4745
4746    #[test]
4747    fn consecutive_timeouts_cross_threshold_at_n() {
4748        let peer = strike_peer(2);
4749        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4750        let n = config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
4751        let mut last = 0;
4752        for i in 1..=n {
4753            last = record_audit_timeout_strike(&mut strikes, &peer);
4754            if i < n {
4755                assert!(!timeout_strike_reaches_threshold(last, n));
4756            }
4757        }
4758        assert!(timeout_strike_reaches_threshold(last, n));
4759        // The count keeps climbing past the base threshold (so it can also
4760        // cross a higher *adaptive* threshold), but is bounded by the strike
4761        // cap — no unbounded growth.
4762        let mut c = last;
4763        for _ in 0..200 {
4764            c = record_audit_timeout_strike(&mut strikes, &peer);
4765        }
4766        assert_eq!(
4767            c,
4768            super::AUDIT_TIMEOUT_STRIKE_MAX,
4769            "count saturates at the max cap"
4770        );
4771        assert!(c > n, "count must be able to exceed the base threshold");
4772    }
4773
4774    // ADR-0002 Network Resilience: adaptive timeout threshold.
4775
4776    #[test]
4777    fn median_timeout_strikes_basics() {
4778        let target = strike_peer(99);
4779        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4780        // No other peers → 0 (healthy network, threshold == base).
4781        assert_eq!(median_timeout_strikes_excluding(&strikes, &target), 0);
4782        strikes.insert(strike_peer(1), 1);
4783        strikes.insert(strike_peer(2), 3);
4784        strikes.insert(strike_peer(3), 5);
4785        // Sorted [1,3,5], lower-median index 1 → 3.
4786        assert_eq!(median_timeout_strikes_excluding(&strikes, &target), 3);
4787    }
4788
4789    // ADVERSARIAL (ADR point e + sybil-inflation bound). Two invariants the
4790    // existing suite leaves unpinned:
4791    //  1. EVEN-count inputs must take the LOWER of the two middle values. The
4792    //     existing basics test only feeds an odd-length cohort, so an
4793    //     implementation that used `len/2` (upper median) would still pass it.
4794    //     Here [1,4] -> lower median 1 (not 4) and [2,4,6,8] -> 4 (not 6).
4795    //  2. A sybil cohort pinned at the *strike cap* (the most an attacker could
4796    //     ever drive fabricated peers to) STILL cannot push the grace past
4797    //     MAX_ADAPTIVE_TIMEOUT_GRACE: the threshold saturates at base + max
4798    //     grace regardless of how high or how numerous the cohort is.
4799    // FLIPS IF: median switches to the upper element on even input, or the
4800    // grace clamp (`.min(MAX_ADAPTIVE_TIMEOUT_GRACE)`) is removed.
4801    #[test]
4802    fn even_count_takes_lower_median_and_sybil_cohort_cannot_exceed_grace_bound() {
4803        let target = strike_peer(150);
4804
4805        // Even count == 2: lower of [1, 4] is 1.
4806        let mut two: HashMap<PeerId, u32> = HashMap::new();
4807        two.insert(strike_peer(1), 1);
4808        two.insert(strike_peer(2), 4);
4809        assert_eq!(
4810            median_timeout_strikes_excluding(&two, &target),
4811            1,
4812            "even-count median must take the LOWER middle value (1), not the upper (4)"
4813        );
4814
4815        // Even count == 4: sorted [2,4,6,8], lower median index (4-1)/2 = 1 → 4.
4816        let mut four: HashMap<PeerId, u32> = HashMap::new();
4817        for (i, v) in (10u8..).zip([2u32, 4, 6, 8]) {
4818            four.insert(strike_peer(i), v);
4819        }
4820        assert_eq!(
4821            median_timeout_strikes_excluding(&four, &target),
4822            4,
4823            "even-count median must be the lower middle (4), not the upper (6)"
4824        );
4825
4826        // Sybil cohort pinned at the strike CAP — the strongest inflation an
4827        // attacker could mount — must not lift the threshold past base + max
4828        // grace. Try several cohort sizes (odd and even) to be sure.
4829        for cohort in [2u8, 5, 8, 20] {
4830            let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4831            for i in 0..cohort {
4832                strikes.insert(strike_peer(50 + i), super::AUDIT_TIMEOUT_STRIKE_MAX);
4833            }
4834            let threshold = adaptive_timeout_threshold(&strikes, &target);
4835            assert_eq!(
4836                threshold,
4837                config::AUDIT_TIMEOUT_STRIKE_THRESHOLD + super::MAX_ADAPTIVE_TIMEOUT_GRACE,
4838                "a sybil cohort at the strike cap (size {cohort}) must saturate the grace at \
4839                 the bound, never exceed it"
4840            );
4841        }
4842
4843        // And even at the bounded-but-inflated threshold, a genuinely
4844        // non-responsive target can still cross it (cap > max reachable
4845        // threshold), so the bound never shields a bad node forever.
4846        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4847        for i in 0..8u8 {
4848            strikes.insert(strike_peer(80 + i), super::AUDIT_TIMEOUT_STRIKE_MAX);
4849        }
4850        let threshold = adaptive_timeout_threshold(&strikes, &target);
4851        let mut c = 0;
4852        for _ in 0..(threshold + 5) {
4853            c = record_audit_timeout_strike(&mut strikes, &target);
4854        }
4855        assert!(
4856            timeout_strike_reaches_threshold(c, threshold),
4857            "target must still cross the bounded inflated threshold ({c} vs {threshold})"
4858        );
4859    }
4860
4861    #[test]
4862    fn lone_timing_out_peer_does_not_inflate_its_own_grace() {
4863        // The peer under judgement is excluded from the median, so a single bad
4864        // peer (the common case) is judged against the base threshold and caught
4865        // — it cannot raise its own bar as its strike count climbs.
4866        let bad = strike_peer(7);
4867        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4868        strikes.insert(bad, 5); // its own large count must not count
4869        assert_eq!(
4870            adaptive_timeout_threshold(&strikes, &bad),
4871            config::AUDIT_TIMEOUT_STRIKE_THRESHOLD
4872        );
4873    }
4874
4875    #[test]
4876    fn widespread_timeouts_widen_the_grace() {
4877        // Genuine disruption: many OTHER honest peers carry timeout strikes. The
4878        // median rises, so the threshold for any given peer widens beyond the
4879        // base — the audit system does not pile onto a struggling network.
4880        let target = strike_peer(100);
4881        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4882        for i in 0..9u8 {
4883            strikes.insert(strike_peer(i), 4);
4884        }
4885        assert_eq!(
4886            adaptive_timeout_threshold(&strikes, &target),
4887            4 + config::AUDIT_TIMEOUT_STRIKE_THRESHOLD
4888        );
4889        assert!(
4890            adaptive_timeout_threshold(&strikes, &target) > config::AUDIT_TIMEOUT_STRIKE_THRESHOLD
4891        );
4892    }
4893
4894    #[test]
4895    fn adaptive_grace_only_responds_to_timeouts_not_deterministic_failures() {
4896        // The strike map is fed ONLY by timeouts (plan_failed_audit records a
4897        // strike for Timeout and never for confirmed failures). So a flood of
4898        // deterministic failures cannot inflate the median to buy grace.
4899        let target = strike_peer(101);
4900        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
4901        // Many confirmed (non-timeout) failures: these must NOT touch the map.
4902        for i in 0..9u8 {
4903            let action = plan_failed_audit(
4904                &AuditFailureReason::DigestMismatch,
4905                &mut strikes,
4906                &strike_peer(i),
4907            );
4908            assert_eq!(action, AuditFailureAction::ConfirmedPenalize);
4909        }
4910        assert!(
4911            strikes.is_empty(),
4912            "deterministic failures must not record strikes"
4913        );
4914        // Threshold stays at the base — an attacker cannot widen grace by
4915        // failing audits on purpose.
4916        assert_eq!(
4917            adaptive_timeout_threshold(&strikes, &target),
4918            config::AUDIT_TIMEOUT_STRIKE_THRESHOLD
4919        );
4920    }
4921
4922    // ADR-0002: "occasional surprise exams, keeps load low" — the per-peer
4923    // cooldown must collapse a gossip flood into at most one audit per window.
4924
4925    #[test]
4926    fn gossip_flood_yields_at_most_one_audit_per_cooldown_window() {
4927        let peer = strike_peer(1);
4928        let mut map: HashMap<PeerId, Instant> = HashMap::new();
4929        let t0 = Instant::now();
4930        // First gossip in the window passes; a burst of further gossips at the
4931        // same instant are all suppressed.
4932        assert!(cooldown_allows_audit(&mut map, &peer, t0));
4933        let mut passed = 1;
4934        for _ in 0..100 {
4935            if cooldown_allows_audit(&mut map, &peer, t0) {
4936                passed += 1;
4937            }
4938        }
4939        assert_eq!(
4940            passed, 1,
4941            "a flood at one instant must trigger exactly one audit"
4942        );
4943    }
4944
4945    // ADR-0002 ordering invariant: `maybe_trigger_gossip_audit` stamps the
4946    // per-peer cooldown BEFORE the probability lottery, so a LOSING ticket still
4947    // consumes the window. This is the property the isolated cooldown tests above
4948    // cannot see: they never sample the lottery, so a regression that reordered
4949    // the gates (sample probability first, only stamp the cooldown on a win)
4950    // would still pass them while breaking flood-resistance: a flood would then
4951    // re-roll the lottery on EVERY message until one won, multiplying audits.
4952    //
4953    // We model the exact production gate order (cooldown-then-lottery) with a
4954    // lottery driven by a fixed outcome instead of `gen_bool(..)`. The first
4955    // message LOSES the lottery; the remaining flood messages all WIN. With the
4956    // production order, the losing first ticket burns the window and every later
4957    // winner in the same window is blocked, so there are 0 audits this window. If
4958    // the gates were flipped, the second message's winning ticket would slip
4959    // through. The window only reopens after the cooldown elapses.
4960    //
4961    // FLIPS IF: the lottery is sampled before `cooldown_allows_audit` (a losing
4962    // ticket no longer consumes the window), re-enabling a flood-amplified audit
4963    // storm.
4964    #[test]
4965    fn losing_lottery_still_consumes_cooldown_window() {
4966        // Faithful re-implementation of the two gates in
4967        // `maybe_trigger_gossip_audit`, with the lottery outcome made
4968        // deterministic instead of `rand::thread_rng().gen_bool(..)`.
4969        // Calls the SHIPPED `audit_launch_decision` (the same function
4970        // `maybe_trigger_gossip_audit` uses), so a reorder of the two gates in
4971        // production fails this test — not a local reimplementation.
4972        let peer = strike_peer(3);
4973        let mut map: HashMap<PeerId, Instant> = HashMap::new();
4974        let t0 = Instant::now();
4975
4976        // First flooded message at t0 LOSES the lottery, but the cooldown is
4977        // stamped BEFORE the lottery is consulted, so the window is now consumed.
4978        assert!(
4979            !audit_launch_decision(&mut map, &peer, t0, false),
4980            "a losing ticket launches no audit"
4981        );
4982
4983        // 99 more flooded messages at the same instant would all WIN the lottery,
4984        // yet every one must be blocked by the cooldown the loser already stamped.
4985        // (If production sampled the lottery FIRST, these would each get a fresh
4986        // roll and audits would multiply — this assertion catches that reorder.)
4987        let mut audits = 0;
4988        for _ in 0..99 {
4989            if audit_launch_decision(&mut map, &peer, t0, true) {
4990                audits += 1;
4991            }
4992        }
4993        assert_eq!(
4994            audits, 0,
4995            "a losing first ticket must consume the window so no later flooded \
4996             message in the same window can audit"
4997        );
4998
4999        // The window only reopens after the cooldown elapses; the next winning
5000        // ticket then launches exactly one audit.
5001        let after = t0 + Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS + 1);
5002        assert!(
5003            audit_launch_decision(&mut map, &peer, after, true),
5004            "after the cooldown a winning ticket audits again"
5005        );
5006    }
5007
5008    #[test]
5009    fn cooldown_lets_audit_through_after_the_window() {
5010        let peer = strike_peer(2);
5011        let mut map: HashMap<PeerId, Instant> = HashMap::new();
5012        let t0 = Instant::now();
5013        assert!(cooldown_allows_audit(&mut map, &peer, t0));
5014        // Within the window: suppressed.
5015        let within = t0 + Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS - 1);
5016        assert!(!cooldown_allows_audit(&mut map, &peer, within));
5017        // Past the window: allowed again.
5018        let after = t0 + Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS + 1);
5019        assert!(cooldown_allows_audit(&mut map, &peer, after));
5020    }
5021
5022    #[test]
5023    fn cooldown_is_per_peer_independent() {
5024        let mut map: HashMap<PeerId, Instant> = HashMap::new();
5025        let t0 = Instant::now();
5026        // Different peers each get their own first-audit pass at the same instant.
5027        for i in 0..20u8 {
5028            assert!(
5029                cooldown_allows_audit(&mut map, &strike_peer(i), t0),
5030                "peer {i} should be auditable independently"
5031            );
5032        }
5033    }
5034
5035    #[test]
5036    fn inflated_adaptive_threshold_is_still_reachable_and_bounded() {
5037        // When the median lifts the threshold above the base, a genuinely
5038        // non-responsive peer's strike count must still be able to
5039        // reach it (the count is no longer capped at the base). And the grace
5040        // widening itself is bounded so it can't shield a bad node forever.
5041        let target = strike_peer(200);
5042        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5043        // A cohort of other peers each at a high strike count.
5044        for i in 0..9u8 {
5045            strikes.insert(strike_peer(i), 10);
5046        }
5047        let threshold = adaptive_timeout_threshold(&strikes, &target);
5048        // Grace is capped, so the threshold cannot exceed base + max grace.
5049        assert!(
5050            threshold <= config::AUDIT_TIMEOUT_STRIKE_THRESHOLD + super::MAX_ADAPTIVE_TIMEOUT_GRACE
5051        );
5052        assert!(threshold > config::AUDIT_TIMEOUT_STRIKE_THRESHOLD);
5053        // The target peer can accumulate strikes past that inflated threshold.
5054        let mut c = 0;
5055        for _ in 0..threshold + 5 {
5056            c = record_audit_timeout_strike(&mut strikes, &target);
5057        }
5058        assert!(
5059            timeout_strike_reaches_threshold(c, threshold),
5060            "a persistent peer must be able to cross the inflated threshold ({c} vs {threshold})"
5061        );
5062    }
5063
5064    #[test]
5065    fn audit_on_gossip_constants_match_adr() {
5066        // Tripwire on the ADR-locked tunables. The spot-check count sits at the
5067        // top of the auditor's 3..=5 band (the auditor clamps to that band, so
5068        // values above 5 would silently never be requested).
5069        assert_eq!(config::AUDIT_SPOTCHECK_COUNT, 5);
5070        assert!((config::AUDIT_ON_GOSSIP_PROBABILITY - 0.2).abs() < f64::EPSILON);
5071        assert_eq!(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS, 30 * 60);
5072    }
5073
5074    // (d) A confirmed storage-integrity failure penalizes immediately and
5075    // revokes credit; it is not a timeout.
5076    #[test]
5077    fn digest_mismatch_is_not_a_timeout_and_penalizes_immediately() {
5078        assert!(audit_failure_clears_bootstrap_claim(
5079            &AuditFailureReason::DigestMismatch
5080        ));
5081        assert!(audit_failure_revokes_holder_credit(
5082            &AuditFailureReason::DigestMismatch
5083        ));
5084    }
5085
5086    // E2E (pure decision): an honest peer that times out once, recovers,
5087    // repeatedly, never reaches a penalty because each success resets strikes.
5088    // FLIPS IF: the strike threshold is removed or success stops resetting.
5089    #[test]
5090    fn e2e_honest_intermittent_timeouts_never_penalized() {
5091        let peer = strike_peer(10);
5092        let base = config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
5093        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5094        for _ in 0..10 {
5095            let after = record_audit_timeout_strike(&mut strikes, &peer);
5096            assert_eq!(
5097                decide_audit_failure_action(&AuditFailureReason::Timeout, after, base),
5098                AuditFailureAction::TimeoutGrace
5099            );
5100            strikes.remove(&peer);
5101        }
5102        assert!(!strikes.contains_key(&peer));
5103    }
5104
5105    // E2E: a peer that times out on EVERY audit (never reset) crosses the
5106    // threshold and is penalized — the deterrent against non-storing peers.
5107    // FLIPS IF: per-challenge window widened so it answers in time, or strikes
5108    // reset without a success.
5109    #[test]
5110    fn e2e_persistent_timeouts_get_penalized() {
5111        let peer = strike_peer(11);
5112        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5113        let threshold = config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
5114        let mut penalized_at = None;
5115        for tick in 1..=(threshold + 2) {
5116            let after = record_audit_timeout_strike(&mut strikes, &peer);
5117            if decide_audit_failure_action(&AuditFailureReason::Timeout, after, threshold)
5118                == AuditFailureAction::TimeoutPenalize
5119                && penalized_at.is_none()
5120            {
5121                penalized_at = Some(tick);
5122            }
5123        }
5124        assert_eq!(penalized_at, Some(threshold));
5125    }
5126
5127    // Glue: a Timeout through the real plan_failed_audit MUST record a strike on
5128    // the map AND penalize once enough accumulate.
5129    // FLIPS IF: the handler stops feeding Timeout through the strike counter
5130    // (e.g. strikes_after hard-coded to 0). (Mutation-verified.)
5131    #[test]
5132    fn e2e_glue_timeout_records_strike_and_penalizes_at_threshold() {
5133        let peer = strike_peer(20);
5134        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5135        let threshold = config::AUDIT_TIMEOUT_STRIKE_THRESHOLD;
5136        let mut action = AuditFailureAction::TimeoutGrace;
5137        for tick in 1..=threshold {
5138            action = plan_failed_audit(&AuditFailureReason::Timeout, &mut strikes, &peer);
5139            assert_eq!(strikes.get(&peer).copied(), Some(tick));
5140        }
5141        assert_eq!(action, AuditFailureAction::TimeoutPenalize);
5142    }
5143
5144    // Glue: a confirmed failure through plan_failed_audit must NOT touch the
5145    // strike map and must return ConfirmedPenalize.
5146    #[test]
5147    fn e2e_glue_confirmed_failure_leaves_strike_map_untouched() {
5148        let peer = strike_peer(21);
5149        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5150        for reason in [
5151            AuditFailureReason::DigestMismatch,
5152            AuditFailureReason::KeyAbsent,
5153            AuditFailureReason::Rejected,
5154            AuditFailureReason::MalformedResponse,
5155        ] {
5156            assert_eq!(
5157                plan_failed_audit(&reason, &mut strikes, &peer),
5158                AuditFailureAction::ConfirmedPenalize
5159            );
5160        }
5161        assert!(strikes.is_empty());
5162    }
5163
5164    // ADR-0002 "Accounting and False Positives", adversarial: a DETERMINISTIC
5165    // failure is acted on the FIRST time it occurs, "regardless of network
5166    // conditions". Here the strike map is pre-loaded with many *other* peers
5167    // timing out, which inflates the adaptive timeout grace to its cap — the
5168    // most forgiving the network ever gets. Under that maximally-relaxed
5169    // window:
5170    //   - a brand-new peer's FIRST deterministic failure (DigestMismatch /
5171    //     Rejected / MalformedResponse) STILL returns ConfirmedPenalize, never
5172    //     a grace lane, and never touches the strike map; while
5173    //   - that same peer's FIRST timeout is only TimeoutGrace.
5174    // This proves the inflated grace is the timeout-only lane and can NEVER be
5175    // weaponized to buy a deterministic failure even one round of delay.
5176    // FLIPS IF: deterministic failures start consulting the strike threshold,
5177    // or ConfirmedPenalize is collapsed into a timeout action.
5178    #[test]
5179    fn deterministic_failure_penalizes_first_time_under_inflated_grace() {
5180        let mut strikes: HashMap<PeerId, u32> = HashMap::new();
5181        // Saturate the adaptive grace: many other peers each carrying a high
5182        // consecutive-timeout count, so the median (and thus the grace) is
5183        // pushed to its MAX cap for any newly-judged peer.
5184        for b in 100..150u8 {
5185            let other = strike_peer(b);
5186            for _ in 0..AUDIT_TIMEOUT_STRIKE_MAX {
5187                record_audit_timeout_strike(&mut strikes, &other);
5188            }
5189        }
5190        let victim = strike_peer(7);
5191        // Sanity: the grace seen by the victim is genuinely inflated above base.
5192        let inflated = adaptive_timeout_threshold(&strikes, &victim);
5193        assert!(
5194            inflated > config::AUDIT_TIMEOUT_STRIKE_THRESHOLD,
5195            "test precondition: grace must be inflated, got {inflated}"
5196        );
5197
5198        // First deterministic failure of each kind -> ConfirmedPenalize on
5199        // occurrence #1, and the victim is never inserted into the strike map.
5200        for reason in [
5201            AuditFailureReason::DigestMismatch,
5202            AuditFailureReason::Rejected,
5203            AuditFailureReason::MalformedResponse,
5204        ] {
5205            let action = plan_failed_audit(&reason, &mut strikes, &victim);
5206            assert_eq!(
5207                action,
5208                AuditFailureAction::ConfirmedPenalize,
5209                "{reason:?} must penalize on the first occurrence regardless of grace"
5210            );
5211            assert_ne!(
5212                action,
5213                AuditFailureAction::TimeoutPenalize,
5214                "a deterministic failure must NOT be routed through the (eviction-gated) \
5215                 timeout-penalize lane"
5216            );
5217            assert!(
5218                !strikes.contains_key(&victim),
5219                "deterministic failure must not touch the timeout strike map"
5220            );
5221            // And it always revokes holder credit / clears the claim.
5222            assert!(audit_failure_revokes_holder_credit(&reason));
5223            assert!(audit_failure_clears_bootstrap_claim(&reason));
5224        }
5225
5226        // The SAME victim's first timeout, under the same inflated grace, is
5227        // only TimeoutGrace (no penalty, no revocation, claim retained).
5228        let timeout_action = plan_failed_audit(&AuditFailureReason::Timeout, &mut strikes, &victim);
5229        assert_eq!(timeout_action, AuditFailureAction::TimeoutGrace);
5230        assert_eq!(strikes.get(&victim).copied(), Some(1));
5231        assert!(!audit_failure_revokes_holder_credit(
5232            &AuditFailureReason::Timeout
5233        ));
5234        assert!(!audit_failure_clears_bootstrap_claim(
5235            &AuditFailureReason::Timeout
5236        ));
5237    }
5238
5239    /// The exact decision the `Failed` arm of `handle_subtree_audit_result`
5240    /// uses: confirmed failures revoke credit, `Timeout` does not.
5241    #[test]
5242    fn confirmed_failures_revoke_credit_timeout_does_not() {
5243        for reason in [
5244            AuditFailureReason::MalformedResponse,
5245            AuditFailureReason::DigestMismatch,
5246            AuditFailureReason::KeyAbsent,
5247            AuditFailureReason::Rejected,
5248        ] {
5249            assert!(
5250                audit_failure_revokes_holder_credit(&reason),
5251                "confirmed failure {reason:?} must revoke holder credit"
5252            );
5253        }
5254        assert!(
5255            !audit_failure_revokes_holder_credit(&AuditFailureReason::Timeout),
5256            "Timeout must NOT revoke credit (single dropped packet != storage loss)"
5257        );
5258    }
5259
5260    /// Wiring test for the security fix: the helper the handler calls
5261    /// actually strips a credited peer on a confirmed failure
5262    /// (`DigestMismatch`), and actually RETAINS credit on `Timeout`.
5263    /// Records genuine credit first so neither assertion is vacuous;
5264    /// this fails if `forget_peer` stops being called, or if the
5265    /// `Timeout` exclusion is dropped (both verified by mutation).
5266    #[test]
5267    fn apply_revocation_strips_on_digest_mismatch_retains_on_timeout() {
5268        let peer = test_peer(0xAB);
5269        let key = test_key(1);
5270        let hash = [0xCD; 32];
5271
5272        // Confirmed failure -> credit revoked.
5273        let mut provers = RecentProvers::new();
5274        provers.record_proof(key, peer, hash, Instant::now());
5275        assert!(
5276            provers.is_credited_holder(&key, &peer, &hash),
5277            "precondition: peer credited before failure"
5278        );
5279        apply_audit_failure_credit_revocation(
5280            &mut provers,
5281            &peer,
5282            &AuditFailureReason::DigestMismatch,
5283        );
5284        assert!(
5285            !provers.is_credited_holder(&key, &peer, &hash),
5286            "DigestMismatch must strip the peer's holder credit"
5287        );
5288
5289        // Timeout -> credit retained.
5290        let mut provers_timeout = RecentProvers::new();
5291        provers_timeout.record_proof(key, peer, hash, Instant::now());
5292        apply_audit_failure_credit_revocation(
5293            &mut provers_timeout,
5294            &peer,
5295            &AuditFailureReason::Timeout,
5296        );
5297        assert!(
5298            provers_timeout.is_credited_holder(&key, &peer, &hash),
5299            "Timeout must retain holder credit (deliberate liveness cushion)"
5300        );
5301    }
5302
5303    #[test]
5304    fn decoded_audit_failures_clear_active_bootstrap_claim() {
5305        for reason in [
5306            AuditFailureReason::MalformedResponse,
5307            AuditFailureReason::DigestMismatch,
5308            AuditFailureReason::KeyAbsent,
5309            AuditFailureReason::Rejected,
5310        ] {
5311            assert!(
5312                audit_failure_clears_bootstrap_claim(&reason),
5313                "decoded non-bootstrap failure {reason:?} should clear active claim"
5314            );
5315        }
5316    }
5317
5318    #[test]
5319    fn first_failed_key_label_truncates_to_16_hex_chars() {
5320        // The high-order 8 bytes of the XorName determine the label so an
5321        // operator can group audit-failures on the same chunk prefix.
5322        let mut key = [0u8; 32];
5323        key[0] = 0x18;
5324        key[7] = 0xff;
5325        // Low-order bytes (positions 8..32) are deliberately set to 0xAA
5326        // to verify they are NOT included in the label.
5327        for byte in &mut key[8..] {
5328            *byte = 0xAA;
5329        }
5330        let label = first_failed_key_label(&[key]);
5331        // Only the first 8 bytes are encoded, low-order bytes are dropped.
5332        assert_eq!(label, "0x18000000000000ff");
5333        assert_eq!(label.len(), "0x".len() + 16);
5334    }
5335
5336    #[test]
5337    fn first_failed_key_label_falls_back_when_empty() {
5338        // Should never happen in production (handle_audit_failure rejects
5339        // empty sets), but the formatter must still produce a valid label
5340        // so the log line doesn't contain a misleading default.
5341        assert_eq!(first_failed_key_label(&[]), "0x");
5342    }
5343
5344    #[test]
5345    fn first_failed_key_label_uses_first_key_only() {
5346        let first = [0x11u8; 32];
5347        let second = [0x22u8; 32];
5348        assert_eq!(
5349            first_failed_key_label(&[first, second]),
5350            format!("0x{}", hex::encode(&first[..8]))
5351        );
5352    }
5353}