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 is_minimum_decode(&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 != expected_symbol_digests {
461            return false;
462        }
463        if self.rejected_symbols != 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 == config.expected_schema_version;
483        let policy_id_ok = self.policy_id == 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(
817            &proof
818                .changeset_id
819                .map_or([0_u8; 16], |changeset_id| changeset_id),
820        );
821        bytes.extend_from_slice(&proof.k_source.to_le_bytes());
822        bytes.extend_from_slice(&proof.repair_count.to_le_bytes());
823        bytes.extend_from_slice(&proof.symbol_size.to_le_bytes());
824        bytes.extend_from_slice(&proof.oti.unwrap_or(0).to_le_bytes());
825        bytes.extend_from_slice(&proof.seed.to_le_bytes());
826        bytes.extend_from_slice(&proof.timing_ns.to_le_bytes());
827        bytes.push(u8::from(proof.decode_success));
828        bytes.extend_from_slice(
829            &proof
830                .failure_reason
831                .map_or(255_u8, failure_reason_code)
832                .to_le_bytes(),
833        );
834        bytes.extend_from_slice(&proof.intermediate_rank.unwrap_or(u32::MAX).to_le_bytes());
835        bytes.push(match proof.payload_mode {
836            DecodeProofPayloadMode::HashesOnly => 0,
837            DecodeProofPayloadMode::IncludeBytesLabOnly => 1,
838        });
839
840        bytes.extend_from_slice(
841            &u32::try_from(proof.symbols_received.len())
842                .expect("symbols_received length fits u32")
843                .to_le_bytes(),
844        );
845        for esi in &proof.symbols_received {
846            bytes.extend_from_slice(&esi.to_le_bytes());
847        }
848
849        bytes.extend_from_slice(
850            &u32::try_from(proof.source_esis.len())
851                .expect("source_esis length fits u32")
852                .to_le_bytes(),
853        );
854        for esi in &proof.source_esis {
855            bytes.extend_from_slice(&esi.to_le_bytes());
856        }
857
858        bytes.extend_from_slice(
859            &u32::try_from(proof.repair_esis.len())
860                .expect("repair_esis length fits u32")
861                .to_le_bytes(),
862        );
863        for esi in &proof.repair_esis {
864            bytes.extend_from_slice(&esi.to_le_bytes());
865        }
866
867        bytes.extend_from_slice(
868            &u32::try_from(proof.rejected_symbols.len())
869                .expect("rejected_symbols length fits u32")
870                .to_le_bytes(),
871        );
872        for rejected in &proof.rejected_symbols {
873            bytes.extend_from_slice(&rejected.esi.to_le_bytes());
874            bytes.push(rejection_reason_code(rejected.reason));
875        }
876
877        bytes.extend_from_slice(
878            &u32::try_from(proof.symbol_digests.len())
879                .expect("symbol_digests length fits u32")
880                .to_le_bytes(),
881        );
882        for digest in &proof.symbol_digests {
883            bytes.extend_from_slice(&digest.esi.to_le_bytes());
884            bytes.extend_from_slice(&digest.digest_xxh3.to_le_bytes());
885        }
886
887        if let Some(payloads) = &proof.debug_symbol_payloads {
888            bytes.extend_from_slice(
889                &u32::try_from(payloads.len())
890                    .expect("debug payload count fits u32")
891                    .to_le_bytes(),
892            );
893            for payload in payloads {
894                bytes.extend_from_slice(
895                    &u32::try_from(payload.len())
896                        .expect("debug payload length fits u32")
897                        .to_le_bytes(),
898                );
899                bytes.extend_from_slice(payload);
900            }
901        } else {
902            bytes.extend_from_slice(&0_u32.to_le_bytes());
903        }
904
905        bytes
906    }
907
908    // -- §3.5.8 test 1: Decode proof creation --
909
910    #[test]
911    fn test_decode_proof_creation() {
912        let oid = test_object_id(0x1234);
913        let k_source = 10;
914        let all_esis: Vec<u32> = (0..12).collect(); // 10 source + 2 repair
915
916        let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, true, Some(10), 5000, 42);
917
918        assert!(proof.decode_success);
919        assert_eq!(proof.source_esis, (0..10).collect::<Vec<u32>>());
920        assert_eq!(proof.repair_esis, vec![10, 11]);
921        assert_eq!(proof.symbols_received.len(), 12);
922        assert!(proof.is_repair());
923        assert!(proof.is_consistent());
924    }
925
926    // -- §3.5.8 test 2: Lab mode timing --
927
928    #[test]
929    fn test_decode_proof_lab_mode() {
930        let oid = test_object_id(0x5678);
931        let lab_timing_ns = 1_000_000; // deterministic virtual time
932
933        let proof = EcsDecodeProof::success(
934            oid,
935            8,
936            (0..10).collect(),
937            (0..8).collect(),
938            vec![8, 9],
939            Some(8),
940            lab_timing_ns,
941            99,
942        );
943
944        let entry = DecodeAuditEntry {
945            proof,
946            seq: 1,
947            lab_mode: true,
948        };
949
950        assert!(entry.lab_mode);
951        assert_eq!(entry.proof.timing_ns, lab_timing_ns);
952        assert_eq!(entry.seq, 1);
953    }
954
955    // -- §3.5.8 test 3: Failure case --
956
957    #[test]
958    fn test_decode_proof_failure_case() {
959        let oid = test_object_id(0xABCD);
960        let k_source = 16;
961        // Only 10 symbols received (insufficient for K=16)
962        let all_esis: Vec<u32> = (0..10).collect();
963
964        let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, false, Some(10), 3000, 77);
965
966        assert!(!proof.decode_success);
967        assert_eq!(proof.intermediate_rank, Some(10));
968        assert_eq!(proof.source_esis.len(), 10);
969        assert!(proof.repair_esis.is_empty());
970        assert!(proof.is_consistent());
971    }
972
973    // -- §3.5.8 test 4: Auditable (reproducible given same seed and ESIs) --
974
975    #[test]
976    fn test_decode_proof_auditable() {
977        let oid = test_object_id(0xFEED);
978        let k_source = 8;
979        let esis: Vec<u32> = (0..10).collect();
980
981        let proof_a = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
982        let proof_b = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
983
984        assert_eq!(
985            proof_a, proof_b,
986            "same inputs must produce identical proofs"
987        );
988    }
989
990    // -- §3.5.8 test 5: Attached to trace --
991
992    #[test]
993    fn test_decode_proof_attached_to_trace() {
994        let oid = test_object_id(0xCAFE);
995        let proof = EcsDecodeProof::success(
996            oid,
997            8,
998            (0..10).collect(),
999            (0..8).collect(),
1000            vec![8, 9],
1001            Some(8),
1002            500,
1003            42,
1004        );
1005
1006        // In lab runtime, every repair decode produces a proof attached to trace.
1007        let mut trace: Vec<DecodeAuditEntry> = Vec::new();
1008        if proof.is_repair() {
1009            trace.push(DecodeAuditEntry {
1010                proof,
1011                seq: 0,
1012                lab_mode: true,
1013            });
1014        }
1015
1016        assert_eq!(trace.len(), 1, "repair decode must produce audit entry");
1017        assert!(trace[0].lab_mode);
1018    }
1019
1020    // -- §3.5.9 test 8: Deterministic repair generation --
1021
1022    #[test]
1023    fn test_deterministic_repair_generation() {
1024        // Same ObjectId + same config → same seed → same repair symbols.
1025        // Different ObjectId → different seed.
1026        let oid_a = test_object_id(0x1111);
1027        let oid_b = test_object_id(0x2222);
1028
1029        let seed_a = crate::repair_symbols::derive_repair_seed(&oid_a);
1030        let seed_b = crate::repair_symbols::derive_repair_seed(&oid_a);
1031        let seed_c = crate::repair_symbols::derive_repair_seed(&oid_b);
1032
1033        assert_eq!(
1034            seed_a, seed_b,
1035            "same ObjectId must produce same repair seed"
1036        );
1037        assert_ne!(
1038            seed_a, seed_c,
1039            "different ObjectIds must produce different seeds"
1040        );
1041    }
1042
1043    // -- §3.5.9 test 9: Cross-replica determinism --
1044
1045    #[test]
1046    fn test_cross_replica_determinism() {
1047        // Two independent "replicas" derive seeds from the same ObjectId.
1048        // Both must get the same seed, proving any replica can regenerate
1049        // repair symbols independently.
1050        let payload = b"commit_capsule_payload_12345";
1051        let oid = ObjectId::derive_from_canonical_bytes(payload);
1052
1053        // Replica 1
1054        let seed_r1 = crate::repair_symbols::derive_repair_seed(&oid);
1055        let budget_r1 = crate::repair_symbols::compute_repair_budget(
1056            100,
1057            &crate::repair_symbols::RepairConfig::new(),
1058        );
1059
1060        // Replica 2 (same inputs, independent derivation)
1061        let seed_r2 = crate::repair_symbols::derive_repair_seed(&oid);
1062        let budget_r2 = crate::repair_symbols::compute_repair_budget(
1063            100,
1064            &crate::repair_symbols::RepairConfig::new(),
1065        );
1066
1067        assert_eq!(seed_r1, seed_r2, "cross-replica seed derivation must match");
1068        assert_eq!(
1069            budget_r1, budget_r2,
1070            "cross-replica repair budgets must match"
1071        );
1072    }
1073
1074    // -- §3.5.8 property: consistency invariant --
1075
1076    #[test]
1077    fn prop_proof_consistency_invariant() {
1078        for k in [1_u32, 4, 8, 16, 100] {
1079            for extra in [0_u32, 1, 2, 5, 10] {
1080                let total = k + extra;
1081                let esis: Vec<u32> = (0..total).collect();
1082                let oid = test_object_id(u64::from(k) * 1000 + u64::from(extra));
1083                let proof = EcsDecodeProof::from_esis(oid, k, &esis, true, None, 0, 0);
1084                assert!(
1085                    proof.is_consistent(),
1086                    "proof must be consistent for k={k}, extra={extra}"
1087                );
1088                assert_eq!(
1089                    proof.source_esis.len(),
1090                    k as usize,
1091                    "source count must equal k"
1092                );
1093                assert_eq!(
1094                    proof.repair_esis.len(),
1095                    extra as usize,
1096                    "repair count must equal extra"
1097                );
1098            }
1099        }
1100    }
1101
1102    // -- §3.5.9 property: seed no collision --
1103
1104    #[test]
1105    fn prop_seed_no_collision() {
1106        use std::collections::HashSet;
1107
1108        let mut seeds = HashSet::new();
1109        for i in 0..100_000_u64 {
1110            let oid = ObjectId::derive_from_canonical_bytes(&i.to_le_bytes());
1111            let seed = crate::repair_symbols::derive_repair_seed(&oid);
1112            seeds.insert(seed);
1113        }
1114
1115        // With 64-bit seeds and 100k entries, collision probability is ~2.7e-10.
1116        // We allow at most 1 collision for robustness.
1117        assert!(
1118            seeds.len() >= 99_999,
1119            "expected at most 1 collision in 100k seeds, got {} unique out of 100000",
1120            seeds.len()
1121        );
1122    }
1123
1124    // -- §3.5.8 test: minimum decode detection --
1125
1126    #[test]
1127    fn test_minimum_decode_detection() {
1128        let oid = test_object_id(0xBEEF);
1129        let k_source = 10;
1130
1131        // Exactly K symbols (minimum decode)
1132        let esis_min: Vec<u32> = (0..10).collect();
1133        let proof_min =
1134            EcsDecodeProof::from_esis(oid, k_source, &esis_min, true, Some(10), 100, 42);
1135        assert!(
1136            proof_min.is_minimum_decode(),
1137            "K=10 received=10 should be minimum decode"
1138        );
1139
1140        // K+2 symbols (not minimum)
1141        let esis_extra: Vec<u32> = (0..12).collect();
1142        let proof_extra =
1143            EcsDecodeProof::from_esis(oid, k_source, &esis_extra, true, Some(10), 100, 42);
1144        assert!(
1145            !proof_extra.is_minimum_decode(),
1146            "K=10 received=12 should not be minimum decode"
1147        );
1148    }
1149
1150    // -- bd-awqq schema tests --
1151
1152    #[test]
1153    fn test_decode_proof_schema_versioned_defaults() {
1154        let oid = test_object_id(0xAAAA);
1155        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 42, 99);
1156        assert_eq!(proof.schema_version, DECODE_PROOF_SCHEMA_VERSION_V1);
1157        assert_eq!(proof.policy_id, DEFAULT_DECODE_PROOF_POLICY_ID);
1158        assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1159        assert!(proof.debug_symbol_payloads.is_none());
1160        assert!(proof.is_consistent());
1161    }
1162
1163    #[test]
1164    fn test_decode_proof_replay_verification_with_digests_and_rejections() {
1165        let oid = test_object_id(0xBBBB);
1166        let symbol_digests = vec![
1167            SymbolDigest {
1168                esi: 0,
1169                digest_xxh3: 11,
1170            },
1171            SymbolDigest {
1172                esi: 1,
1173                digest_xxh3: 22,
1174            },
1175        ];
1176        let rejected = vec![RejectedSymbol {
1177            esi: 9,
1178            reason: SymbolRejectionReason::InvalidAuthTag,
1179        }];
1180        let proof = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 100, 17)
1181            .with_symbol_digests(symbol_digests.clone())
1182            .with_rejected_symbols(rejected.clone());
1183
1184        assert!(proof.replay_verifies(&symbol_digests, &rejected));
1185        assert!(!proof.replay_verifies(
1186            &[SymbolDigest {
1187                esi: 0,
1188                digest_xxh3: 999
1189            }],
1190            &rejected
1191        ));
1192    }
1193
1194    #[test]
1195    fn test_decode_proof_canonicalization_is_deterministic() {
1196        let oid = test_object_id(0xCCCC);
1197        let a = EcsDecodeProof::from_esis(oid, 4, &[3, 0, 1, 3, 2, 4, 4], false, Some(3), 77, 5)
1198            .with_rejected_symbols(vec![
1199                RejectedSymbol {
1200                    esi: 8,
1201                    reason: SymbolRejectionReason::HashMismatch,
1202                },
1203                RejectedSymbol {
1204                    esi: 8,
1205                    reason: SymbolRejectionReason::HashMismatch,
1206                },
1207            ]);
1208        let b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4], false, Some(3), 77, 5)
1209            .with_rejected_symbols(vec![RejectedSymbol {
1210                esi: 8,
1211                reason: SymbolRejectionReason::HashMismatch,
1212            }]);
1213        assert_eq!(a, b, "canonicalization must make output deterministic");
1214        assert!(a.is_consistent());
1215    }
1216
1217    #[test]
1218    fn test_decode_proof_failure_reason_consistency() {
1219        let oid = test_object_id(0xDDDD);
1220        let proof = EcsDecodeProof::failure(
1221            oid,
1222            8,
1223            vec![0, 1, 2],
1224            vec![0, 1, 2],
1225            vec![],
1226            Some(3),
1227            900,
1228            33,
1229        );
1230        assert_eq!(proof.failure_reason, Some(DecodeFailureReason::Unknown));
1231        assert!(!proof.decode_success);
1232        assert!(proof.is_consistent());
1233    }
1234
1235    #[test]
1236    fn test_decode_proof_verification_report_success() {
1237        let oid = test_object_id(0xEEEE);
1238        let symbol_digests = vec![
1239            SymbolDigest {
1240                esi: 0,
1241                digest_xxh3: 10,
1242            },
1243            SymbolDigest {
1244                esi: 1,
1245                digest_xxh3: 20,
1246            },
1247        ];
1248        let rejected = vec![RejectedSymbol {
1249            esi: 9,
1250            reason: SymbolRejectionReason::HashMismatch,
1251        }];
1252        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 50, 7)
1253            .with_symbol_digests(symbol_digests.clone())
1254            .with_rejected_symbols(rejected.clone());
1255
1256        let report = proof.verification_report(
1257            DecodeProofVerificationConfig::default(),
1258            &symbol_digests,
1259            &rejected,
1260        );
1261        assert!(report.ok, "report should pass: {report:?}");
1262        assert!(report.replay_verifies);
1263        assert!(report.decode_success_budget_ok);
1264        assert!(report.issues.is_empty());
1265    }
1266
1267    #[test]
1268    fn test_decode_proof_verification_report_detects_mismatch() {
1269        let oid = test_object_id(0xFFFF);
1270        let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 90, 17)
1271            .with_rejected_symbols(vec![RejectedSymbol {
1272                esi: 7,
1273                reason: SymbolRejectionReason::DuplicateEsi,
1274            }]);
1275
1276        let config = DecodeProofVerificationConfig {
1277            expected_schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
1278            expected_policy_id: DEFAULT_DECODE_PROOF_POLICY_ID + 1,
1279            decode_success_slack: DEFAULT_DECODE_PROOF_SLACK,
1280        };
1281        let report = proof.verification_report(config, &[], &[]);
1282        let issue_codes: Vec<&str> = report
1283            .issues
1284            .iter()
1285            .map(|issue| issue.code.as_str())
1286            .collect();
1287
1288        assert!(!report.ok);
1289        assert!(!report.policy_id_ok);
1290        assert!(!report.decode_success_budget_ok);
1291        assert!(!report.replay_verifies);
1292        assert!(!report.rejected_reasons_hash_or_auth_only);
1293        assert!(
1294            issue_codes.contains(&"policy_id_mismatch"),
1295            "expected policy mismatch in {issue_codes:?}"
1296        );
1297        assert!(
1298            issue_codes.contains(&"decode_success_budget_failed"),
1299            "expected decode budget mismatch in {issue_codes:?}"
1300        );
1301        assert!(
1302            issue_codes.contains(&"replay_verification_failed"),
1303            "expected replay verification mismatch in {issue_codes:?}"
1304        );
1305        assert!(
1306            issue_codes.contains(&"rejected_reason_unsupported"),
1307            "expected rejected reason mismatch in {issue_codes:?}"
1308        );
1309    }
1310
1311    // -- bd-221l proof stability + replay-verifier hardening tests --
1312
1313    #[test]
1314    fn test_decode_proof_serialized_stability_fixed_inputs() {
1315        let oid = test_object_id(0x2210);
1316        let symbol_digests = vec![
1317            SymbolDigest {
1318                esi: 0,
1319                digest_xxh3: 0xAA01,
1320            },
1321            SymbolDigest {
1322                esi: 1,
1323                digest_xxh3: 0xAA02,
1324            },
1325            SymbolDigest {
1326                esi: 2,
1327                digest_xxh3: 0xAA03,
1328            },
1329        ];
1330        let rejected = vec![RejectedSymbol {
1331            esi: 6,
1332            reason: SymbolRejectionReason::HashMismatch,
1333        }];
1334
1335        let proof_a = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1336            .with_symbol_digests(symbol_digests.clone())
1337            .with_rejected_symbols(rejected.clone());
1338        let proof_b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1339            .with_symbol_digests(symbol_digests)
1340            .with_rejected_symbols(rejected);
1341
1342        let json_a = stable_proof_bytes_for_test(&proof_a);
1343        let json_b = stable_proof_bytes_for_test(&proof_b);
1344        assert_eq!(
1345            json_a, json_b,
1346            "fixed inputs must produce byte-identical serialized proof artifacts"
1347        );
1348        assert!(
1349            proof_a.debug_symbol_payloads.is_none(),
1350            "default proof must not embed raw symbol payload bytes"
1351        );
1352    }
1353
1354    #[test]
1355    fn test_decode_proof_verifier_rejects_altered_esi_list() {
1356        let oid = test_object_id(0x2211);
1357        let symbol_digests = vec![SymbolDigest {
1358            esi: 0,
1359            digest_xxh3: 0x10,
1360        }];
1361        let rejected = vec![RejectedSymbol {
1362            esi: 9,
1363            reason: SymbolRejectionReason::InvalidAuthTag,
1364        }];
1365        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 13, 99)
1366            .with_symbol_digests(symbol_digests.clone())
1367            .with_rejected_symbols(rejected.clone());
1368        tampered.symbols_received.push(99);
1369        let report = tampered.verification_report(
1370            DecodeProofVerificationConfig::default(),
1371            &symbol_digests,
1372            &rejected,
1373        );
1374
1375        let issue_codes: Vec<&str> = report
1376            .issues
1377            .iter()
1378            .map(|issue| issue.code.as_str())
1379            .collect();
1380        assert!(!report.ok, "tampered ESI list must fail verification");
1381        assert!(!report.internal_consistency_ok);
1382        assert!(
1383            issue_codes.contains(&"internal_consistency_failed"),
1384            "expected consistency failure in {issue_codes:?}"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_decode_proof_verifier_rejects_altered_hashes() {
1390        let oid = test_object_id(0x2212);
1391        let symbol_digests = vec![SymbolDigest {
1392            esi: 0,
1393            digest_xxh3: 0x20,
1394        }];
1395        let rejected = vec![RejectedSymbol {
1396            esi: 7,
1397            reason: SymbolRejectionReason::HashMismatch,
1398        }];
1399        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 21, 123)
1400            .with_symbol_digests(symbol_digests.clone())
1401            .with_rejected_symbols(rejected.clone());
1402        tampered.input_hashes.metadata_xxh3 ^= 1;
1403        let report = tampered.verification_report(
1404            DecodeProofVerificationConfig::default(),
1405            &symbol_digests,
1406            &rejected,
1407        );
1408
1409        let issue_codes: Vec<&str> = report
1410            .issues
1411            .iter()
1412            .map(|issue| issue.code.as_str())
1413            .collect();
1414        assert!(!report.ok, "tampered hash evidence must fail verification");
1415        assert!(!report.metadata_hash_ok);
1416        assert!(
1417            issue_codes.contains(&"hash_mismatch"),
1418            "expected hash mismatch issue in {issue_codes:?}"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_decode_proof_verifier_rejects_wrong_schema_version() {
1424        let oid = test_object_id(0x2213);
1425        let symbol_digests = vec![SymbolDigest {
1426            esi: 0,
1427            digest_xxh3: 0x30,
1428        }];
1429        let rejected = vec![RejectedSymbol {
1430            esi: 8,
1431            reason: SymbolRejectionReason::InvalidAuthTag,
1432        }];
1433        let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 34, 77)
1434            .with_symbol_digests(symbol_digests.clone())
1435            .with_rejected_symbols(rejected.clone());
1436        tampered.schema_version = DECODE_PROOF_SCHEMA_VERSION_V1 + 1;
1437        let report = tampered.verification_report(
1438            DecodeProofVerificationConfig::default(),
1439            &symbol_digests,
1440            &rejected,
1441        );
1442
1443        let issue_codes: Vec<&str> = report
1444            .issues
1445            .iter()
1446            .map(|issue| issue.code.as_str())
1447            .collect();
1448        assert!(!report.ok);
1449        assert!(!report.schema_version_ok);
1450        assert!(
1451            issue_codes.contains(&"schema_version_mismatch"),
1452            "expected schema mismatch issue in {issue_codes:?}"
1453        );
1454    }
1455
1456    #[test]
1457    fn test_decode_proof_hashes_only_artifact_is_compact() {
1458        let oid = test_object_id(0x2214);
1459        let proof =
1460            EcsDecodeProof::from_esis(oid, 8, &[0, 1, 2, 3, 4, 8, 9], true, Some(8), 55, 100);
1461        let serialized = stable_proof_bytes_for_test(&proof);
1462
1463        assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1464        assert!(
1465            proof.debug_symbol_payloads.is_none(),
1466            "hashes-only mode must not include raw symbol payloads"
1467        );
1468        assert!(
1469            serialized.len() < 1024,
1470            "proof artifact unexpectedly large: {} bytes",
1471            serialized.len()
1472        );
1473    }
1474}