Skip to main content

fsqlite_core/
decode_proofs.rs

1//! §3.5.8 Decode Proofs (Auditable Repair) + §3.5.9 Deterministic Encoding.
2//!
3//! Every decode that repairs corruption MUST produce a proof artifact
4//! (a mathematical witness that the fix is correct). In replication,
5//! a replica MAY demand proof artifacts for suspicious objects.
6//!
7//! This module provides the FrankenSQLite-side `EcsDecodeProof` type that
8//! wraps asupersync's `DecodeProof` with ECS-specific metadata.
9
10use std::fmt;
11
12use fsqlite_types::ObjectId;
13use tracing::{debug, info, warn};
14use xxhash_rust::xxh3::xxh3_64;
15
16// ---------------------------------------------------------------------------
17// ECS Decode Proof (§3.5.8)
18// ---------------------------------------------------------------------------
19
20/// Stable schema version for `EcsDecodeProof`.
21pub const DECODE_PROOF_SCHEMA_VERSION_V1: u16 = 1;
22
23/// Default policy identifier for deterministic decode proof emission.
24pub const DEFAULT_DECODE_PROOF_POLICY_ID: u32 = 1;
25/// Default slack requirement used when verifying successful decode proofs.
26pub const DEFAULT_DECODE_PROOF_SLACK: u32 = 2;
27
28/// Why a symbol was rejected before decode.
29#[derive(
30    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
31)]
32pub enum SymbolRejectionReason {
33    HashMismatch,
34    InvalidAuthTag,
35    DuplicateEsi,
36    FormatViolation,
37}
38
39impl fmt::Display for SymbolRejectionReason {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::HashMismatch => write!(f, "hash_mismatch"),
43            Self::InvalidAuthTag => write!(f, "invalid_auth_tag"),
44            Self::DuplicateEsi => write!(f, "duplicate_esi"),
45            Self::FormatViolation => write!(f, "format_violation"),
46        }
47    }
48}
49
50/// Rejected-symbol evidence item.
51#[derive(
52    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
53)]
54pub struct RejectedSymbol {
55    pub esi: u32,
56    pub reason: SymbolRejectionReason,
57}
58
59/// Reason for decode failure when `decode_success == false`.
60#[derive(
61    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
62)]
63pub enum DecodeFailureReason {
64    InsufficientSymbols,
65    RankDeficiency,
66    IntegrityMismatch,
67    Unknown,
68}
69
70impl fmt::Display for DecodeFailureReason {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::InsufficientSymbols => write!(f, "insufficient_symbols"),
74            Self::RankDeficiency => write!(f, "rank_deficiency"),
75            Self::IntegrityMismatch => write!(f, "integrity_mismatch"),
76            Self::Unknown => write!(f, "unknown"),
77        }
78    }
79}
80
81/// Redaction policy for proof payload material.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub enum DecodeProofPayloadMode {
84    /// Only metadata + hashes are persisted.
85    HashesOnly,
86    /// Lab/debug mode may include raw payload bytes.
87    IncludeBytesLabOnly,
88}
89
90/// Hash of an accepted symbol input (replay-verification artifact).
91#[derive(
92    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
93)]
94pub struct SymbolDigest {
95    pub esi: u32,
96    pub digest_xxh3: u64,
97}
98
99/// Deterministic digest set for replay verification.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
101pub struct ProofInputHashes {
102    pub metadata_xxh3: u64,
103    pub source_esis_xxh3: u64,
104    pub repair_esis_xxh3: u64,
105    pub rejected_symbols_xxh3: u64,
106    pub symbol_digests_xxh3: u64,
107}
108
109/// A decode proof recording the outcome and metadata of an ECS decode
110/// operation (§3.5.8).
111///
112/// This is the FrankenSQLite-side proof artifact. It captures the fields
113/// specified by the spec (`object_id`, `k_source`, received ESIs, source
114/// vs repair partitions, success/failure, intermediate rank, timing, seed).
115#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116pub struct EcsDecodeProof {
117    /// Stable schema version (load-bearing for replay tooling).
118    pub schema_version: u16,
119    /// Policy identifier used when this proof was emitted.
120    pub policy_id: u32,
121    /// The object being decoded.
122    pub object_id: ObjectId,
123    /// Optional changeset identifier (replication path).
124    pub changeset_id: Option<[u8; 16]>,
125    /// Number of source symbols (K).
126    pub k_source: u32,
127    /// Number of repair symbols configured for the decode budget (R).
128    pub repair_count: u32,
129    /// Symbol size (T) in bytes (0 when unavailable in this layer).
130    pub symbol_size: u32,
131    /// RaptorQ OTI/codec metadata hash (if available).
132    pub oti: Option<u64>,
133    /// ESIs of all symbols fed to the decoder.
134    pub symbols_received: Vec<u32>,
135    /// Subset of received ESIs that were source symbols.
136    pub source_esis: Vec<u32>,
137    /// Subset of received ESIs that were repair symbols.
138    pub repair_esis: Vec<u32>,
139    /// Rejected symbols and reasons (integrity/auth/format/dup).
140    pub rejected_symbols: Vec<RejectedSymbol>,
141    /// Hashes of accepted symbols for replay verification.
142    pub symbol_digests: Vec<SymbolDigest>,
143    /// Whether the decode succeeded.
144    pub decode_success: bool,
145    /// Failure reason when decode did not succeed.
146    pub failure_reason: Option<DecodeFailureReason>,
147    /// Decoder matrix rank at success/failure (if available).
148    pub intermediate_rank: Option<u32>,
149    /// Timing: wall-clock nanoseconds or virtual time under `LabRuntime`.
150    pub timing_ns: u64,
151    /// RaptorQ seed used for encoding.
152    pub seed: u64,
153    /// Redaction mode for payload material in this proof.
154    pub payload_mode: DecodeProofPayloadMode,
155    /// Optional symbol payload bytes (lab/debug only).
156    pub debug_symbol_payloads: Option<Vec<Vec<u8>>>,
157    /// Deterministic digest summary for replay verification.
158    pub input_hashes: ProofInputHashes,
159}
160
161/// Inputs controlling proof verification behavior.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163pub struct DecodeProofVerificationConfig {
164    pub expected_schema_version: u16,
165    pub expected_policy_id: u32,
166    pub decode_success_slack: u32,
167}
168
169impl Default for DecodeProofVerificationConfig {
170    fn default() -> Self {
171        Self {
172            expected_schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
173            expected_policy_id: DEFAULT_DECODE_PROOF_POLICY_ID,
174            decode_success_slack: DEFAULT_DECODE_PROOF_SLACK,
175        }
176    }
177}
178
179/// Deterministic verifier finding item.
180#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub struct DecodeProofVerificationIssue {
182    pub code: String,
183    pub detail: String,
184}
185
186/// Deterministic, structured report emitted by proof verification tooling.
187#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
188#[allow(clippy::struct_excessive_bools)]
189pub struct DecodeProofVerificationReport {
190    pub ok: bool,
191    pub expected_schema_version: u16,
192    pub expected_policy_id: u32,
193    pub decode_success_slack: u32,
194    pub schema_version_ok: bool,
195    pub policy_id_ok: bool,
196    pub internal_consistency_ok: bool,
197    pub metadata_hash_ok: bool,
198    pub source_hash_ok: bool,
199    pub repair_hash_ok: bool,
200    pub rejected_hash_ok: bool,
201    pub symbol_digests_hash_ok: bool,
202    pub replay_verifies: bool,
203    pub decode_success_budget_ok: bool,
204    pub decode_success_expected_min_symbols: u32,
205    pub decode_success_observed_symbols: u32,
206    pub rejected_reasons_hash_or_auth_only: bool,
207    pub issues: Vec<DecodeProofVerificationIssue>,
208}
209
210impl EcsDecodeProof {
211    /// Create a proof for a successful decode operation.
212    #[must_use]
213    #[allow(clippy::too_many_arguments)]
214    pub fn success(
215        object_id: ObjectId,
216        k_source: u32,
217        symbols_received: Vec<u32>,
218        source_esis: Vec<u32>,
219        repair_esis: Vec<u32>,
220        intermediate_rank: Option<u32>,
221        timing_ns: u64,
222        seed: u64,
223    ) -> Self {
224        let proof = Self::from_parts(
225            object_id,
226            None,
227            k_source,
228            symbols_received,
229            source_esis,
230            repair_esis,
231            Vec::new(),
232            Vec::new(),
233            true,
234            None,
235            intermediate_rank,
236            timing_ns,
237            seed,
238        );
239        info!(
240            bead_id = "bd-awqq",
241            object_id = ?proof.object_id,
242            k_source = proof.k_source,
243            received = proof.symbols_received.len(),
244            source = proof.source_esis.len(),
245            repair = proof.repair_esis.len(),
246            timing_ns = proof.timing_ns,
247            schema_version = proof.schema_version,
248            policy_id = proof.policy_id,
249            "decode proof: SUCCESS"
250        );
251        proof
252    }
253
254    /// Create a proof for a failed decode operation.
255    #[must_use]
256    #[allow(clippy::too_many_arguments)]
257    pub fn failure(
258        object_id: ObjectId,
259        k_source: u32,
260        symbols_received: Vec<u32>,
261        source_esis: Vec<u32>,
262        repair_esis: Vec<u32>,
263        intermediate_rank: Option<u32>,
264        timing_ns: u64,
265        seed: u64,
266    ) -> Self {
267        let proof = Self::from_parts(
268            object_id,
269            None,
270            k_source,
271            symbols_received,
272            source_esis,
273            repair_esis,
274            Vec::new(),
275            Vec::new(),
276            false,
277            Some(DecodeFailureReason::Unknown),
278            intermediate_rank,
279            timing_ns,
280            seed,
281        );
282        warn!(
283            bead_id = "bd-awqq",
284            object_id = ?proof.object_id,
285            k_source = proof.k_source,
286            received = proof.symbols_received.len(),
287            intermediate_rank = proof.intermediate_rank,
288            timing_ns = proof.timing_ns,
289            failure_reason = ?proof.failure_reason,
290            "decode proof: FAILURE"
291        );
292        proof
293    }
294
295    /// Build an `EcsDecodeProof` from raw received-symbol ESIs.
296    ///
297    /// Partitions received ESIs into source (< k_source) and repair (>= k_source).
298    #[must_use]
299    pub fn from_esis(
300        object_id: ObjectId,
301        k_source: u32,
302        all_esis: &[u32],
303        decode_success: bool,
304        intermediate_rank: Option<u32>,
305        timing_ns: u64,
306        seed: u64,
307    ) -> Self {
308        let mut source_partition = Vec::new();
309        let mut repair_partition = Vec::new();
310        for &esi in all_esis {
311            if esi < k_source {
312                source_partition.push(esi);
313            } else {
314                repair_partition.push(esi);
315            }
316        }
317        let symbols_received = canonicalize_esis(all_esis.to_vec());
318        source_partition = canonicalize_esis(source_partition);
319        repair_partition = canonicalize_esis(repair_partition);
320
321        debug!(
322            bead_id = "bd-awqq",
323            source_count = source_partition.len(),
324            repair_count = repair_partition.len(),
325            "partitioned received ESIs into source/repair"
326        );
327
328        Self::from_parts(
329            object_id,
330            None,
331            k_source,
332            symbols_received,
333            source_partition,
334            repair_partition,
335            Vec::new(),
336            Vec::new(),
337            decode_success,
338            (!decode_success).then_some(DecodeFailureReason::Unknown),
339            intermediate_rank,
340            timing_ns,
341            seed,
342        )
343    }
344
345    /// Whether this proof records a repair operation (i.e., repair symbols used).
346    #[must_use]
347    pub fn is_repair(&self) -> bool {
348        !self.repair_esis.is_empty()
349    }
350
351    /// Whether the decode used the minimum possible symbols (fragile recovery).
352    #[must_use]
353    pub fn uses_minimum_symbols(&self) -> bool {
354        #[allow(clippy::cast_possible_truncation)]
355        let received = self.symbols_received.len() as u32;
356        received == self.k_source
357    }
358
359    /// Verify that this proof is internally consistent.
360    ///
361    /// Returns `true` if source_esis + repair_esis == symbols_received and
362    /// all ESI partitions are correct.
363    #[must_use]
364    pub fn is_consistent(&self) -> bool {
365        if self.schema_version != DECODE_PROOF_SCHEMA_VERSION_V1 {
366            return false;
367        }
368        if self.decode_success && self.failure_reason.is_some() {
369            return false;
370        }
371        if !self.decode_success && self.failure_reason.is_none() {
372            return false;
373        }
374        if self.payload_mode == DecodeProofPayloadMode::HashesOnly
375            && self.debug_symbol_payloads.is_some()
376        {
377            return false;
378        }
379        if self.repair_count != u32::try_from(self.repair_esis.len()).unwrap_or(u32::MAX) {
380            return false;
381        }
382
383        if !is_sorted_unique(&self.symbols_received)
384            || !is_sorted_unique(&self.source_esis)
385            || !is_sorted_unique(&self.repair_esis)
386        {
387            return false;
388        }
389        if !is_sorted_unique(&self.rejected_symbols) || !is_sorted_unique(&self.symbol_digests) {
390            return false;
391        }
392
393        let mut union = self.source_esis.clone();
394        union.extend(self.repair_esis.iter().copied());
395        union = canonicalize_esis(union);
396        if union != self.symbols_received {
397            return false;
398        }
399
400        if self.source_esis.iter().any(|&e| e >= self.k_source) {
401            return false;
402        }
403        if self.repair_esis.iter().any(|&e| e < self.k_source) {
404            return false;
405        }
406
407        if self
408            .symbol_digests
409            .iter()
410            .any(|digest| !self.symbols_received.contains(&digest.esi))
411        {
412            return false;
413        }
414
415        self.input_hashes == self.compute_input_hashes()
416    }
417
418    /// Attach changeset identity metadata and recompute integrity hashes.
419    #[must_use]
420    pub fn with_changeset_id(mut self, changeset_id: [u8; 16]) -> Self {
421        self.changeset_id = Some(changeset_id);
422        self.input_hashes = self.compute_input_hashes();
423        self
424    }
425
426    /// Attach rejected-symbol evidence and recompute integrity hashes.
427    #[must_use]
428    pub fn with_rejected_symbols(mut self, rejected_symbols: Vec<RejectedSymbol>) -> Self {
429        self.rejected_symbols = canonicalize_rejected_symbols(rejected_symbols);
430        self.input_hashes = self.compute_input_hashes();
431        self
432    }
433
434    /// Attach accepted-symbol digests and recompute integrity hashes.
435    #[must_use]
436    pub fn with_symbol_digests(mut self, symbol_digests: Vec<SymbolDigest>) -> Self {
437        self.symbol_digests = canonicalize_symbol_digests(symbol_digests);
438        self.input_hashes = self.compute_input_hashes();
439        self
440    }
441
442    /// Switch proof to debug payload mode and embed symbol payload bytes.
443    #[must_use]
444    pub fn with_debug_symbol_payloads(mut self, payloads: Vec<Vec<u8>>) -> Self {
445        self.payload_mode = DecodeProofPayloadMode::IncludeBytesLabOnly;
446        self.debug_symbol_payloads = Some(payloads);
447        self.input_hashes = self.compute_input_hashes();
448        self
449    }
450
451    /// Replay verification: ensure digest evidence matches this proof.
452    #[must_use]
453    pub fn replay_verifies(
454        &self,
455        symbol_digests: &[SymbolDigest],
456        rejected_symbols: &[RejectedSymbol],
457    ) -> bool {
458        let expected_symbol_digests = canonicalize_symbol_digests(symbol_digests.to_vec());
459        let expected_rejected = canonicalize_rejected_symbols(rejected_symbols.to_vec());
460        if !self.symbol_digests.eq(&expected_symbol_digests) {
461            return false;
462        }
463        if !self.rejected_symbols.eq(&expected_rejected) {
464            return false;
465        }
466        self.input_hashes.symbol_digests_xxh3 == hash_symbol_digests(&expected_symbol_digests)
467            && self.input_hashes.rejected_symbols_xxh3 == hash_rejected_symbols(&expected_rejected)
468    }
469
470    /// Verify proof integrity and emit a deterministic structured report.
471    #[must_use]
472    #[allow(clippy::too_many_lines)]
473    pub fn verification_report(
474        &self,
475        config: DecodeProofVerificationConfig,
476        symbol_digests: &[SymbolDigest],
477        rejected_symbols: &[RejectedSymbol],
478    ) -> DecodeProofVerificationReport {
479        let expected_symbol_digests = canonicalize_symbol_digests(symbol_digests.to_vec());
480        let expected_rejected = canonicalize_rejected_symbols(rejected_symbols.to_vec());
481
482        let schema_version_ok = self.schema_version.eq(&config.expected_schema_version);
483        let policy_id_ok = self.policy_id.eq(&config.expected_policy_id);
484        let internal_consistency_ok = self.is_consistent();
485        let metadata_hash_ok = self.input_hashes.metadata_xxh3 == hash_metadata(self);
486        let source_hash_ok =
487            self.input_hashes.source_esis_xxh3 == hash_u32_list("source_esis", &self.source_esis);
488        let repair_hash_ok =
489            self.input_hashes.repair_esis_xxh3 == hash_u32_list("repair_esis", &self.repair_esis);
490        let rejected_hash_ok =
491            self.input_hashes.rejected_symbols_xxh3 == hash_rejected_symbols(&expected_rejected);
492        let symbol_digests_hash_ok =
493            self.input_hashes.symbol_digests_xxh3 == hash_symbol_digests(&expected_symbol_digests);
494        let replay_verifies = self.replay_verifies(&expected_symbol_digests, &expected_rejected);
495
496        let decode_success_expected_min_symbols =
497            self.k_source.saturating_add(config.decode_success_slack);
498        let decode_success_observed_symbols =
499            u32::try_from(self.symbols_received.len()).unwrap_or(u32::MAX);
500        let decode_success_budget_ok = !self.decode_success
501            || decode_success_observed_symbols >= decode_success_expected_min_symbols;
502        let rejected_reasons_hash_or_auth_only = self.rejected_symbols.iter().all(|entry| {
503            matches!(
504                entry.reason,
505                SymbolRejectionReason::HashMismatch | SymbolRejectionReason::InvalidAuthTag
506            )
507        });
508
509        let mut issues = Vec::new();
510        if !schema_version_ok {
511            issues.push(DecodeProofVerificationIssue {
512                code: String::from("schema_version_mismatch"),
513                detail: format!(
514                    "expected {}, got {}",
515                    config.expected_schema_version, self.schema_version
516                ),
517            });
518        }
519        if !policy_id_ok {
520            issues.push(DecodeProofVerificationIssue {
521                code: String::from("policy_id_mismatch"),
522                detail: format!(
523                    "expected {}, got {}",
524                    config.expected_policy_id, self.policy_id
525                ),
526            });
527        }
528        if !internal_consistency_ok {
529            issues.push(DecodeProofVerificationIssue {
530                code: String::from("internal_consistency_failed"),
531                detail: String::from("proof failed internal consistency checks"),
532            });
533        }
534        if !metadata_hash_ok
535            || !source_hash_ok
536            || !repair_hash_ok
537            || !rejected_hash_ok
538            || !symbol_digests_hash_ok
539        {
540            issues.push(DecodeProofVerificationIssue {
541                code: String::from("hash_mismatch"),
542                detail: format!(
543                    "metadata={metadata_hash_ok} source={source_hash_ok} repair={repair_hash_ok} rejected={rejected_hash_ok} symbol_digests={symbol_digests_hash_ok}"
544                ),
545            });
546        }
547        if !replay_verifies {
548            issues.push(DecodeProofVerificationIssue {
549                code: String::from("replay_verification_failed"),
550                detail: String::from("provided digest/rejection evidence did not match proof"),
551            });
552        }
553        if !decode_success_budget_ok {
554            issues.push(DecodeProofVerificationIssue {
555                code: String::from("decode_success_budget_failed"),
556                detail: format!(
557                    "success proof had {decode_success_observed_symbols} symbols, required >= {decode_success_expected_min_symbols}",
558                ),
559            });
560        }
561        if !rejected_reasons_hash_or_auth_only {
562            issues.push(DecodeProofVerificationIssue {
563                code: String::from("rejected_reason_unsupported"),
564                detail: String::from(
565                    "rejected-symbol reasons must be hash/auth mismatch for this verifier",
566                ),
567            });
568        }
569
570        let ok = issues.is_empty();
571        DecodeProofVerificationReport {
572            ok,
573            expected_schema_version: config.expected_schema_version,
574            expected_policy_id: config.expected_policy_id,
575            decode_success_slack: config.decode_success_slack,
576            schema_version_ok,
577            policy_id_ok,
578            internal_consistency_ok,
579            metadata_hash_ok,
580            source_hash_ok,
581            repair_hash_ok,
582            rejected_hash_ok,
583            symbol_digests_hash_ok,
584            replay_verifies,
585            decode_success_budget_ok,
586            decode_success_expected_min_symbols,
587            decode_success_observed_symbols,
588            rejected_reasons_hash_or_auth_only,
589            issues,
590        }
591    }
592
593    #[allow(clippy::too_many_arguments)]
594    fn from_parts(
595        object_id: ObjectId,
596        changeset_id: Option<[u8; 16]>,
597        k_source: u32,
598        symbols_received: Vec<u32>,
599        source_esis: Vec<u32>,
600        repair_esis: Vec<u32>,
601        rejected_symbols: Vec<RejectedSymbol>,
602        symbol_digests: Vec<SymbolDigest>,
603        decode_success: bool,
604        failure_reason: Option<DecodeFailureReason>,
605        intermediate_rank: Option<u32>,
606        timing_ns: u64,
607        seed: u64,
608    ) -> Self {
609        let mut proof = Self {
610            schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
611            policy_id: DEFAULT_DECODE_PROOF_POLICY_ID,
612            object_id,
613            changeset_id,
614            k_source,
615            repair_count: u32::try_from(repair_esis.len()).unwrap_or(u32::MAX),
616            symbol_size: 0,
617            oti: None,
618            symbols_received: canonicalize_esis(symbols_received),
619            source_esis: canonicalize_esis(source_esis),
620            repair_esis: canonicalize_esis(repair_esis),
621            rejected_symbols: canonicalize_rejected_symbols(rejected_symbols),
622            symbol_digests: canonicalize_symbol_digests(symbol_digests),
623            decode_success,
624            failure_reason,
625            intermediate_rank,
626            timing_ns,
627            seed,
628            payload_mode: DecodeProofPayloadMode::HashesOnly,
629            debug_symbol_payloads: None,
630            input_hashes: ProofInputHashes {
631                metadata_xxh3: 0,
632                source_esis_xxh3: 0,
633                repair_esis_xxh3: 0,
634                rejected_symbols_xxh3: 0,
635                symbol_digests_xxh3: 0,
636            },
637        };
638        proof.input_hashes = proof.compute_input_hashes();
639        proof
640    }
641
642    fn compute_input_hashes(&self) -> ProofInputHashes {
643        ProofInputHashes {
644            metadata_xxh3: hash_metadata(self),
645            source_esis_xxh3: hash_u32_list("source_esis", &self.source_esis),
646            repair_esis_xxh3: hash_u32_list("repair_esis", &self.repair_esis),
647            rejected_symbols_xxh3: hash_rejected_symbols(&self.rejected_symbols),
648            symbol_digests_xxh3: hash_symbol_digests(&self.symbol_digests),
649        }
650    }
651}
652
653fn canonicalize_esis(mut values: Vec<u32>) -> Vec<u32> {
654    values.sort_unstable();
655    values.dedup();
656    values
657}
658
659fn canonicalize_rejected_symbols(mut values: Vec<RejectedSymbol>) -> Vec<RejectedSymbol> {
660    values.sort();
661    values.dedup();
662    values
663}
664
665fn canonicalize_symbol_digests(mut values: Vec<SymbolDigest>) -> Vec<SymbolDigest> {
666    values.sort();
667    values.dedup();
668    values
669}
670
671fn is_sorted_unique<T: Ord>(values: &[T]) -> bool {
672    values.windows(2).all(|pair| pair[0] < pair[1])
673}
674
675fn hash_u32_list(domain: &str, values: &[u32]) -> u64 {
676    let mut bytes = Vec::with_capacity(domain.len() + values.len() * 4);
677    bytes.extend_from_slice(domain.as_bytes());
678    for value in values {
679        bytes.extend_from_slice(&value.to_le_bytes());
680    }
681    xxh3_64(&bytes)
682}
683
684fn rejection_reason_code(reason: SymbolRejectionReason) -> u8 {
685    match reason {
686        SymbolRejectionReason::HashMismatch => 1,
687        SymbolRejectionReason::InvalidAuthTag => 2,
688        SymbolRejectionReason::DuplicateEsi => 3,
689        SymbolRejectionReason::FormatViolation => 4,
690    }
691}
692
693fn failure_reason_code(reason: DecodeFailureReason) -> u8 {
694    match reason {
695        DecodeFailureReason::InsufficientSymbols => 1,
696        DecodeFailureReason::RankDeficiency => 2,
697        DecodeFailureReason::IntegrityMismatch => 3,
698        DecodeFailureReason::Unknown => 255,
699    }
700}
701
702fn hash_rejected_symbols(values: &[RejectedSymbol]) -> u64 {
703    let mut bytes = Vec::with_capacity("rejected".len() + values.len() * 5);
704    bytes.extend_from_slice(b"rejected");
705    for value in values {
706        bytes.extend_from_slice(&value.esi.to_le_bytes());
707        bytes.push(rejection_reason_code(value.reason));
708    }
709    xxh3_64(&bytes)
710}
711
712fn hash_symbol_digests(values: &[SymbolDigest]) -> u64 {
713    let mut bytes = Vec::with_capacity("symbol_digests".len() + values.len() * 12);
714    bytes.extend_from_slice(b"symbol_digests");
715    for value in values {
716        bytes.extend_from_slice(&value.esi.to_le_bytes());
717        bytes.extend_from_slice(&value.digest_xxh3.to_le_bytes());
718    }
719    xxh3_64(&bytes)
720}
721
722fn hash_debug_payloads(payloads: Option<&[Vec<u8>]>) -> u64 {
723    let Some(payloads) = payloads else {
724        return 0;
725    };
726    let mut bytes = Vec::new();
727    bytes.extend_from_slice(b"debug_payloads");
728    for payload in payloads {
729        let len = u64::try_from(payload.len()).unwrap_or(u64::MAX);
730        bytes.extend_from_slice(&len.to_le_bytes());
731        bytes.extend_from_slice(&xxh3_64(payload).to_le_bytes());
732    }
733    xxh3_64(&bytes)
734}
735
736fn hash_metadata(proof: &EcsDecodeProof) -> u64 {
737    let mut bytes = Vec::new();
738    bytes.extend_from_slice(b"decode_proof_metadata");
739    bytes.extend_from_slice(&proof.schema_version.to_le_bytes());
740    bytes.extend_from_slice(&proof.policy_id.to_le_bytes());
741    bytes.extend_from_slice(proof.object_id.as_bytes());
742    if let Some(changeset_id) = proof.changeset_id {
743        bytes.push(1);
744        bytes.extend_from_slice(&changeset_id);
745    } else {
746        bytes.push(0);
747    }
748    bytes.extend_from_slice(&proof.k_source.to_le_bytes());
749    bytes.extend_from_slice(&proof.repair_count.to_le_bytes());
750    bytes.extend_from_slice(&proof.symbol_size.to_le_bytes());
751    bytes.extend_from_slice(&proof.seed.to_le_bytes());
752    if let Some(oti) = proof.oti {
753        bytes.push(1);
754        bytes.extend_from_slice(&oti.to_le_bytes());
755    } else {
756        bytes.push(0);
757    }
758    bytes.push(u8::from(proof.decode_success));
759    if let Some(reason) = proof.failure_reason {
760        bytes.push(1);
761        bytes.push(failure_reason_code(reason));
762    } else {
763        bytes.push(0);
764    }
765    if let Some(rank) = proof.intermediate_rank {
766        bytes.push(1);
767        bytes.extend_from_slice(&rank.to_le_bytes());
768    } else {
769        bytes.push(0);
770    }
771    bytes.extend_from_slice(&proof.timing_ns.to_le_bytes());
772    bytes.push(match proof.payload_mode {
773        DecodeProofPayloadMode::HashesOnly => 0,
774        DecodeProofPayloadMode::IncludeBytesLabOnly => 1,
775    });
776    bytes.extend_from_slice(
777        &hash_debug_payloads(proof.debug_symbol_payloads.as_deref()).to_le_bytes(),
778    );
779    xxh3_64(&bytes)
780}
781// ---------------------------------------------------------------------------
782// Decode Audit Trail
783// ---------------------------------------------------------------------------
784
785/// An entry in the decode audit trail (§3.5.8, lab runtime integration).
786///
787/// In lab runtime, every repair decode produces a proof attached to the
788/// test trace. This struct groups the proof with its trace context.
789#[derive(Debug, Clone)]
790pub struct DecodeAuditEntry {
791    /// The decode proof artifact.
792    pub proof: EcsDecodeProof,
793    /// Monotonic sequence number within the audit trail.
794    pub seq: u64,
795    /// Whether this was produced under lab runtime (deterministic virtual time).
796    pub lab_mode: bool,
797}
798
799// ---------------------------------------------------------------------------
800// Tests
801// ---------------------------------------------------------------------------
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    fn test_object_id(seed: u64) -> ObjectId {
808        ObjectId::derive_from_canonical_bytes(&seed.to_le_bytes())
809    }
810
811    fn stable_proof_bytes_for_test(proof: &EcsDecodeProof) -> Vec<u8> {
812        let mut bytes = Vec::new();
813        bytes.extend_from_slice(&proof.schema_version.to_le_bytes());
814        bytes.extend_from_slice(&proof.policy_id.to_le_bytes());
815        bytes.extend_from_slice(proof.object_id.as_bytes());
816        bytes.extend_from_slice(&proof.changeset_id.unwrap_or([0_u8; 16]));
817        bytes.extend_from_slice(&proof.k_source.to_le_bytes());
818        bytes.extend_from_slice(&proof.repair_count.to_le_bytes());
819        bytes.extend_from_slice(&proof.symbol_size.to_le_bytes());
820        bytes.extend_from_slice(&proof.oti.unwrap_or(0).to_le_bytes());
821        bytes.extend_from_slice(&proof.seed.to_le_bytes());
822        bytes.extend_from_slice(&proof.timing_ns.to_le_bytes());
823        bytes.push(u8::from(proof.decode_success));
824        bytes.extend_from_slice(
825            &proof
826                .failure_reason
827                .map_or(255_u8, failure_reason_code)
828                .to_le_bytes(),
829        );
830        bytes.extend_from_slice(&proof.intermediate_rank.unwrap_or(u32::MAX).to_le_bytes());
831        bytes.push(match proof.payload_mode {
832            DecodeProofPayloadMode::HashesOnly => 0,
833            DecodeProofPayloadMode::IncludeBytesLabOnly => 1,
834        });
835
836        bytes.extend_from_slice(
837            &u32::try_from(proof.symbols_received.len())
838                .expect("symbols_received length fits u32")
839                .to_le_bytes(),
840        );
841        for esi in &proof.symbols_received {
842            bytes.extend_from_slice(&esi.to_le_bytes());
843        }
844
845        bytes.extend_from_slice(
846            &u32::try_from(proof.source_esis.len())
847                .expect("source_esis length fits u32")
848                .to_le_bytes(),
849        );
850        for esi in &proof.source_esis {
851            bytes.extend_from_slice(&esi.to_le_bytes());
852        }
853
854        bytes.extend_from_slice(
855            &u32::try_from(proof.repair_esis.len())
856                .expect("repair_esis length fits u32")
857                .to_le_bytes(),
858        );
859        for esi in &proof.repair_esis {
860            bytes.extend_from_slice(&esi.to_le_bytes());
861        }
862
863        bytes.extend_from_slice(
864            &u32::try_from(proof.rejected_symbols.len())
865                .expect("rejected_symbols length fits u32")
866                .to_le_bytes(),
867        );
868        for rejected in &proof.rejected_symbols {
869            bytes.extend_from_slice(&rejected.esi.to_le_bytes());
870            bytes.push(rejection_reason_code(rejected.reason));
871        }
872
873        bytes.extend_from_slice(
874            &u32::try_from(proof.symbol_digests.len())
875                .expect("symbol_digests length fits u32")
876                .to_le_bytes(),
877        );
878        for digest in &proof.symbol_digests {
879            bytes.extend_from_slice(&digest.esi.to_le_bytes());
880            bytes.extend_from_slice(&digest.digest_xxh3.to_le_bytes());
881        }
882
883        if let Some(payloads) = &proof.debug_symbol_payloads {
884            bytes.extend_from_slice(
885                &u32::try_from(payloads.len())
886                    .expect("debug payload count fits u32")
887                    .to_le_bytes(),
888            );
889            for payload in payloads {
890                bytes.extend_from_slice(
891                    &u32::try_from(payload.len())
892                        .expect("debug payload length fits u32")
893                        .to_le_bytes(),
894                );
895                bytes.extend_from_slice(payload);
896            }
897        } else {
898            bytes.extend_from_slice(&0_u32.to_le_bytes());
899        }
900
901        bytes
902    }
903
904    // -- §3.5.8 test 1: Decode proof creation --
905
906    #[test]
907    fn test_decode_proof_creation() {
908        let oid = test_object_id(0x1234);
909        let k_source = 10;
910        let all_esis: Vec<u32> = (0..12).collect(); // 10 source + 2 repair
911
912        let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, true, Some(10), 5000, 42);
913
914        assert!(proof.decode_success);
915        assert_eq!(proof.source_esis, (0..10).collect::<Vec<u32>>());
916        assert_eq!(proof.repair_esis, vec![10, 11]);
917        assert_eq!(proof.symbols_received.len(), 12);
918        assert!(proof.is_repair());
919        assert!(proof.is_consistent());
920    }
921
922    // -- §3.5.8 test 2: Lab mode timing --
923
924    #[test]
925    fn test_decode_proof_lab_mode() {
926        let oid = test_object_id(0x5678);
927        let lab_timing_ns = 1_000_000; // deterministic virtual time
928
929        let proof = EcsDecodeProof::success(
930            oid,
931            8,
932            (0..10).collect(),
933            (0..8).collect(),
934            vec![8, 9],
935            Some(8),
936            lab_timing_ns,
937            99,
938        );
939
940        let entry = DecodeAuditEntry {
941            proof,
942            seq: 1,
943            lab_mode: true,
944        };
945
946        assert!(entry.lab_mode);
947        assert_eq!(entry.proof.timing_ns, lab_timing_ns);
948        assert_eq!(entry.seq, 1);
949    }
950
951    // -- §3.5.8 test 3: Failure case --
952
953    #[test]
954    fn test_decode_proof_failure_case() {
955        let oid = test_object_id(0xABCD);
956        let k_source = 16;
957        // Only 10 symbols received (insufficient for K=16)
958        let all_esis: Vec<u32> = (0..10).collect();
959
960        let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, false, Some(10), 3000, 77);
961
962        assert!(!proof.decode_success);
963        assert_eq!(proof.intermediate_rank, Some(10));
964        assert_eq!(proof.source_esis.len(), 10);
965        assert!(proof.repair_esis.is_empty());
966        assert!(proof.is_consistent());
967    }
968
969    // -- §3.5.8 test 4: Auditable (reproducible given same seed and ESIs) --
970
971    #[test]
972    fn test_decode_proof_auditable() {
973        let oid = test_object_id(0xFEED);
974        let k_source = 8;
975        let esis: Vec<u32> = (0..10).collect();
976
977        let proof_a = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
978        let proof_b = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
979
980        assert_eq!(
981            proof_a, proof_b,
982            "same inputs must produce identical proofs"
983        );
984    }
985
986    // -- §3.5.8 test 5: Attached to trace --
987
988    #[test]
989    fn test_decode_proof_attached_to_trace() {
990        let oid = test_object_id(0xCAFE);
991        let proof = EcsDecodeProof::success(
992            oid,
993            8,
994            (0..10).collect(),
995            (0..8).collect(),
996            vec![8, 9],
997            Some(8),
998            500,
999            42,
1000        );
1001
1002        // In lab runtime, every repair decode produces a proof attached to trace.
1003        let mut trace: Vec<DecodeAuditEntry> = Vec::new();
1004        if proof.is_repair() {
1005            trace.push(DecodeAuditEntry {
1006                proof,
1007                seq: 0,
1008                lab_mode: true,
1009            });
1010        }
1011
1012        assert_eq!(trace.len(), 1, "repair decode must produce audit entry");
1013        assert!(trace[0].lab_mode);
1014    }
1015
1016    // -- §3.5.9 test 8: Deterministic repair generation --
1017
1018    #[test]
1019    fn test_deterministic_repair_generation() {
1020        // Same ObjectId + same config → same seed → same repair symbols.
1021        // Different ObjectId → different seed.
1022        let oid_a = test_object_id(0x1111);
1023        let oid_b = test_object_id(0x2222);
1024
1025        let seed_a = crate::repair_symbols::derive_repair_seed(&oid_a);
1026        let seed_b = crate::repair_symbols::derive_repair_seed(&oid_a);
1027        let seed_c = crate::repair_symbols::derive_repair_seed(&oid_b);
1028
1029        assert_eq!(
1030            seed_a, seed_b,
1031            "same ObjectId must produce same repair seed"
1032        );
1033        assert_ne!(
1034            seed_a, seed_c,
1035            "different ObjectIds must produce different seeds"
1036        );
1037    }
1038
1039    // -- §3.5.9 test 9: Cross-replica determinism --
1040
1041    #[test]
1042    fn test_cross_replica_determinism() {
1043        // Two independent "replicas" derive seeds from the same ObjectId.
1044        // Both must get the same seed, proving any replica can regenerate
1045        // repair symbols independently.
1046        let payload = b"commit_capsule_payload_12345";
1047        let oid = ObjectId::derive_from_canonical_bytes(payload);
1048
1049        // Replica 1
1050        let seed_r1 = crate::repair_symbols::derive_repair_seed(&oid);
1051        let budget_r1 = crate::repair_symbols::compute_repair_budget(
1052            100,
1053            &crate::repair_symbols::RepairConfig::new(),
1054        );
1055
1056        // Replica 2 (same inputs, independent derivation)
1057        let seed_r2 = crate::repair_symbols::derive_repair_seed(&oid);
1058        let budget_r2 = crate::repair_symbols::compute_repair_budget(
1059            100,
1060            &crate::repair_symbols::RepairConfig::new(),
1061        );
1062
1063        assert_eq!(seed_r1, seed_r2, "cross-replica seed derivation must match");
1064        assert_eq!(
1065            budget_r1, budget_r2,
1066            "cross-replica repair budgets must match"
1067        );
1068    }
1069
1070    // -- §3.5.8 property: consistency invariant --
1071
1072    #[test]
1073    fn prop_proof_consistency_invariant() {
1074        for k in [1_u32, 4, 8, 16, 100] {
1075            for extra in [0_u32, 1, 2, 5, 10] {
1076                let total = k + extra;
1077                let esis: Vec<u32> = (0..total).collect();
1078                let oid = test_object_id(u64::from(k) * 1000 + u64::from(extra));
1079                let proof = EcsDecodeProof::from_esis(oid, k, &esis, true, None, 0, 0);
1080                assert!(
1081                    proof.is_consistent(),
1082                    "proof must be consistent for k={k}, extra={extra}"
1083                );
1084                assert_eq!(
1085                    proof.source_esis.len(),
1086                    k as usize,
1087                    "source count must equal k"
1088                );
1089                assert_eq!(
1090                    proof.repair_esis.len(),
1091                    extra as usize,
1092                    "repair count must equal extra"
1093                );
1094            }
1095        }
1096    }
1097
1098    // -- §3.5.9 property: seed no collision --
1099
1100    #[test]
1101    fn prop_seed_no_collision() {
1102        use std::collections::HashSet;
1103
1104        let mut seeds = HashSet::new();
1105        for i in 0..100_000_u64 {
1106            let oid = ObjectId::derive_from_canonical_bytes(&i.to_le_bytes());
1107            let seed = crate::repair_symbols::derive_repair_seed(&oid);
1108            seeds.insert(seed);
1109        }
1110
1111        // With 64-bit seeds and 100k entries, collision probability is ~2.7e-10.
1112        // We allow at most 1 collision for robustness.
1113        assert!(
1114            seeds.len() >= 99_999,
1115            "expected at most 1 collision in 100k seeds, got {} unique out of 100000",
1116            seeds.len()
1117        );
1118    }
1119
1120    // -- §3.5.8 test: minimum decode detection --
1121
1122    #[test]
1123    fn test_minimum_decode_detection() {
1124        let oid = test_object_id(0xBEEF);
1125        let k_source = 10;
1126
1127        // Exactly K symbols (minimum decode)
1128        let esis_min: Vec<u32> = (0..10).collect();
1129        let proof_min =
1130            EcsDecodeProof::from_esis(oid, k_source, &esis_min, true, Some(10), 100, 42);
1131        assert!(
1132            proof_min.uses_minimum_symbols(),
1133            "K=10 received=10 should be minimum decode"
1134        );
1135
1136        // K+2 symbols (not minimum)
1137        let esis_extra: Vec<u32> = (0..12).collect();
1138        let proof_extra =
1139            EcsDecodeProof::from_esis(oid, k_source, &esis_extra, true, Some(10), 100, 42);
1140        assert!(
1141            !proof_extra.uses_minimum_symbols(),
1142            "K=10 received=12 should not be minimum decode"
1143        );
1144    }
1145
1146    // -- bd-awqq schema tests --
1147
1148    #[test]
1149    fn test_decode_proof_schema_versioned_defaults() {
1150        let oid = test_object_id(0xAAAA);
1151        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 42, 99);
1152        assert_eq!(proof.schema_version, DECODE_PROOF_SCHEMA_VERSION_V1);
1153        assert_eq!(proof.policy_id, DEFAULT_DECODE_PROOF_POLICY_ID);
1154        assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1155        assert!(proof.debug_symbol_payloads.is_none());
1156        assert!(proof.is_consistent());
1157    }
1158
1159    #[test]
1160    fn test_decode_proof_replay_verification_with_digests_and_rejections() {
1161        let oid = test_object_id(0xBBBB);
1162        let symbol_digests = vec![
1163            SymbolDigest {
1164                esi: 0,
1165                digest_xxh3: 11,
1166            },
1167            SymbolDigest {
1168                esi: 1,
1169                digest_xxh3: 22,
1170            },
1171        ];
1172        let rejected = vec![RejectedSymbol {
1173            esi: 9,
1174            reason: SymbolRejectionReason::InvalidAuthTag,
1175        }];
1176        let proof = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 100, 17)
1177            .with_symbol_digests(symbol_digests.clone())
1178            .with_rejected_symbols(rejected.clone());
1179
1180        assert!(proof.replay_verifies(&symbol_digests, &rejected));
1181        assert!(!proof.replay_verifies(
1182            &[SymbolDigest {
1183                esi: 0,
1184                digest_xxh3: 999
1185            }],
1186            &rejected
1187        ));
1188    }
1189
1190    #[test]
1191    fn test_decode_proof_canonicalization_is_deterministic() {
1192        let oid = test_object_id(0xCCCC);
1193        let a = EcsDecodeProof::from_esis(oid, 4, &[3, 0, 1, 3, 2, 4, 4], false, Some(3), 77, 5)
1194            .with_rejected_symbols(vec![
1195                RejectedSymbol {
1196                    esi: 8,
1197                    reason: SymbolRejectionReason::HashMismatch,
1198                },
1199                RejectedSymbol {
1200                    esi: 8,
1201                    reason: SymbolRejectionReason::HashMismatch,
1202                },
1203            ]);
1204        let b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4], false, Some(3), 77, 5)
1205            .with_rejected_symbols(vec![RejectedSymbol {
1206                esi: 8,
1207                reason: SymbolRejectionReason::HashMismatch,
1208            }]);
1209        assert_eq!(a, b, "canonicalization must make output deterministic");
1210        assert!(a.is_consistent());
1211    }
1212
1213    #[test]
1214    fn test_decode_proof_failure_reason_consistency() {
1215        let oid = test_object_id(0xDDDD);
1216        let proof = EcsDecodeProof::failure(
1217            oid,
1218            8,
1219            vec![0, 1, 2],
1220            vec![0, 1, 2],
1221            vec![],
1222            Some(3),
1223            900,
1224            33,
1225        );
1226        assert_eq!(proof.failure_reason, Some(DecodeFailureReason::Unknown));
1227        assert!(!proof.decode_success);
1228        assert!(proof.is_consistent());
1229    }
1230
1231    #[test]
1232    fn test_decode_proof_verification_report_success() {
1233        let oid = test_object_id(0xEEEE);
1234        let symbol_digests = vec![
1235            SymbolDigest {
1236                esi: 0,
1237                digest_xxh3: 10,
1238            },
1239            SymbolDigest {
1240                esi: 1,
1241                digest_xxh3: 20,
1242            },
1243        ];
1244        let rejected = vec![RejectedSymbol {
1245            esi: 9,
1246            reason: SymbolRejectionReason::HashMismatch,
1247        }];
1248        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 50, 7)
1249            .with_symbol_digests(symbol_digests.clone())
1250            .with_rejected_symbols(rejected.clone());
1251
1252        let report = proof.verification_report(
1253            DecodeProofVerificationConfig::default(),
1254            &symbol_digests,
1255            &rejected,
1256        );
1257        assert!(report.ok, "report should pass: {report:?}");
1258        assert!(report.replay_verifies);
1259        assert!(report.decode_success_budget_ok);
1260        assert!(report.issues.is_empty());
1261    }
1262
1263    #[test]
1264    fn test_decode_proof_verification_report_detects_mismatch() {
1265        let oid = test_object_id(0xFFFF);
1266        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 90, 17)
1267            .with_rejected_symbols(vec![RejectedSymbol {
1268                esi: 7,
1269                reason: SymbolRejectionReason::DuplicateEsi,
1270            }]);
1271
1272        let config = DecodeProofVerificationConfig {
1273            expected_schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
1274            expected_policy_id: DEFAULT_DECODE_PROOF_POLICY_ID + 1,
1275            decode_success_slack: DEFAULT_DECODE_PROOF_SLACK,
1276        };
1277        let report = proof.verification_report(config, &[], &[]);
1278        let issue_codes: Vec<&str> = report
1279            .issues
1280            .iter()
1281            .map(|issue| issue.code.as_str())
1282            .collect();
1283
1284        assert!(!report.ok);
1285        assert!(!report.policy_id_ok);
1286        assert!(!report.decode_success_budget_ok);
1287        assert!(!report.replay_verifies);
1288        assert!(!report.rejected_reasons_hash_or_auth_only);
1289        assert!(
1290            issue_codes.contains(&"policy_id_mismatch"),
1291            "expected policy mismatch in {issue_codes:?}"
1292        );
1293        assert!(
1294            issue_codes.contains(&"decode_success_budget_failed"),
1295            "expected decode budget mismatch in {issue_codes:?}"
1296        );
1297        assert!(
1298            issue_codes.contains(&"replay_verification_failed"),
1299            "expected replay verification mismatch in {issue_codes:?}"
1300        );
1301        assert!(
1302            issue_codes.contains(&"rejected_reason_unsupported"),
1303            "expected rejected reason mismatch in {issue_codes:?}"
1304        );
1305    }
1306
1307    // -- bd-221l proof stability + replay-verifier hardening tests --
1308
1309    #[test]
1310    fn test_decode_proof_serialized_stability_fixed_inputs() {
1311        let oid = test_object_id(0x2210);
1312        let symbol_digests = vec![
1313            SymbolDigest {
1314                esi: 0,
1315                digest_xxh3: 0xAA01,
1316            },
1317            SymbolDigest {
1318                esi: 1,
1319                digest_xxh3: 0xAA02,
1320            },
1321            SymbolDigest {
1322                esi: 2,
1323                digest_xxh3: 0xAA03,
1324            },
1325        ];
1326        let rejected = vec![RejectedSymbol {
1327            esi: 6,
1328            reason: SymbolRejectionReason::HashMismatch,
1329        }];
1330
1331        let proof_a = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1332            .with_symbol_digests(symbol_digests.clone())
1333            .with_rejected_symbols(rejected.clone());
1334        let proof_b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1335            .with_symbol_digests(symbol_digests)
1336            .with_rejected_symbols(rejected);
1337
1338        let json_a = stable_proof_bytes_for_test(&proof_a);
1339        let json_b = stable_proof_bytes_for_test(&proof_b);
1340        assert_eq!(
1341            json_a, json_b,
1342            "fixed inputs must produce byte-identical serialized proof artifacts"
1343        );
1344        assert!(
1345            proof_a.debug_symbol_payloads.is_none(),
1346            "default proof must not embed raw symbol payload bytes"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_decode_proof_verifier_rejects_altered_esi_list() {
1352        let oid = test_object_id(0x2211);
1353        let symbol_digests = vec![SymbolDigest {
1354            esi: 0,
1355            digest_xxh3: 0x10,
1356        }];
1357        let rejected = vec![RejectedSymbol {
1358            esi: 9,
1359            reason: SymbolRejectionReason::InvalidAuthTag,
1360        }];
1361        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 13, 99)
1362            .with_symbol_digests(symbol_digests.clone())
1363            .with_rejected_symbols(rejected.clone());
1364        tampered.symbols_received.push(99);
1365        let report = tampered.verification_report(
1366            DecodeProofVerificationConfig::default(),
1367            &symbol_digests,
1368            &rejected,
1369        );
1370
1371        let issue_codes: Vec<&str> = report
1372            .issues
1373            .iter()
1374            .map(|issue| issue.code.as_str())
1375            .collect();
1376        assert!(!report.ok, "tampered ESI list must fail verification");
1377        assert!(!report.internal_consistency_ok);
1378        assert!(
1379            issue_codes.contains(&"internal_consistency_failed"),
1380            "expected consistency failure in {issue_codes:?}"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_decode_proof_verifier_rejects_altered_hashes() {
1386        let oid = test_object_id(0x2212);
1387        let symbol_digests = vec![SymbolDigest {
1388            esi: 0,
1389            digest_xxh3: 0x20,
1390        }];
1391        let rejected = vec![RejectedSymbol {
1392            esi: 7,
1393            reason: SymbolRejectionReason::HashMismatch,
1394        }];
1395        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 21, 123)
1396            .with_symbol_digests(symbol_digests.clone())
1397            .with_rejected_symbols(rejected.clone());
1398        tampered.input_hashes.metadata_xxh3 ^= 1;
1399        let report = tampered.verification_report(
1400            DecodeProofVerificationConfig::default(),
1401            &symbol_digests,
1402            &rejected,
1403        );
1404
1405        let issue_codes: Vec<&str> = report
1406            .issues
1407            .iter()
1408            .map(|issue| issue.code.as_str())
1409            .collect();
1410        assert!(!report.ok, "tampered hash evidence must fail verification");
1411        assert!(!report.metadata_hash_ok);
1412        assert!(
1413            issue_codes.contains(&"hash_mismatch"),
1414            "expected hash mismatch issue in {issue_codes:?}"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_decode_proof_verifier_rejects_wrong_schema_version() {
1420        let oid = test_object_id(0x2213);
1421        let symbol_digests = vec![SymbolDigest {
1422            esi: 0,
1423            digest_xxh3: 0x30,
1424        }];
1425        let rejected = vec![RejectedSymbol {
1426            esi: 8,
1427            reason: SymbolRejectionReason::InvalidAuthTag,
1428        }];
1429        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 34, 77)
1430            .with_symbol_digests(symbol_digests.clone())
1431            .with_rejected_symbols(rejected.clone());
1432        tampered.schema_version = DECODE_PROOF_SCHEMA_VERSION_V1 + 1;
1433        let report = tampered.verification_report(
1434            DecodeProofVerificationConfig::default(),
1435            &symbol_digests,
1436            &rejected,
1437        );
1438
1439        let issue_codes: Vec<&str> = report
1440            .issues
1441            .iter()
1442            .map(|issue| issue.code.as_str())
1443            .collect();
1444        assert!(!report.ok);
1445        assert!(!report.schema_version_ok);
1446        assert!(
1447            issue_codes.contains(&"schema_version_mismatch"),
1448            "expected schema mismatch issue in {issue_codes:?}"
1449        );
1450    }
1451
1452    #[test]
1453    fn test_decode_proof_hashes_only_artifact_is_compact() {
1454        let oid = test_object_id(0x2214);
1455        let proof =
1456            EcsDecodeProof::from_esis(oid, 8, &[0, 1, 2, 3, 4, 8, 9], true, Some(8), 55, 100);
1457        let serialized = stable_proof_bytes_for_test(&proof);
1458
1459        assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1460        assert!(
1461            proof.debug_symbol_payloads.is_none(),
1462            "hashes-only mode must not include raw symbol payloads"
1463        );
1464        assert!(
1465            serialized.len() < 1024,
1466            "proof artifact unexpectedly large: {} bytes",
1467            serialized.len()
1468        );
1469    }
1470}