1use std::fmt;
11
12use fsqlite_types::ObjectId;
13use tracing::{debug, info, warn};
14use xxhash_rust::xxh3::xxh3_64;
15
16pub const DECODE_PROOF_SCHEMA_VERSION_V1: u16 = 1;
22
23pub const DEFAULT_DECODE_PROOF_POLICY_ID: u32 = 1;
25pub const DEFAULT_DECODE_PROOF_SLACK: u32 = 2;
27
28#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83pub enum DecodeProofPayloadMode {
84 HashesOnly,
86 IncludeBytesLabOnly,
88}
89
90#[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#[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116pub struct EcsDecodeProof {
117 pub schema_version: u16,
119 pub policy_id: u32,
121 pub object_id: ObjectId,
123 pub changeset_id: Option<[u8; 16]>,
125 pub k_source: u32,
127 pub repair_count: u32,
129 pub symbol_size: u32,
131 pub oti: Option<u64>,
133 pub symbols_received: Vec<u32>,
135 pub source_esis: Vec<u32>,
137 pub repair_esis: Vec<u32>,
139 pub rejected_symbols: Vec<RejectedSymbol>,
141 pub symbol_digests: Vec<SymbolDigest>,
143 pub decode_success: bool,
145 pub failure_reason: Option<DecodeFailureReason>,
147 pub intermediate_rank: Option<u32>,
149 pub timing_ns: u64,
151 pub seed: u64,
153 pub payload_mode: DecodeProofPayloadMode,
155 pub debug_symbol_payloads: Option<Vec<Vec<u8>>>,
157 pub input_hashes: ProofInputHashes,
159}
160
161#[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub struct DecodeProofVerificationIssue {
182 pub code: String,
183 pub detail: String,
184}
185
186#[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 #[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 #[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 #[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 #[must_use]
347 pub fn is_repair(&self) -> bool {
348 !self.repair_esis.is_empty()
349 }
350
351 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
790pub struct DecodeAuditEntry {
791 pub proof: EcsDecodeProof,
793 pub seq: u64,
795 pub lab_mode: bool,
797}
798
799#[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 #[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(); 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 #[test]
929 fn test_decode_proof_lab_mode() {
930 let oid = test_object_id(0x5678);
931 let lab_timing_ns = 1_000_000; 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 #[test]
958 fn test_decode_proof_failure_case() {
959 let oid = test_object_id(0xABCD);
960 let k_source = 16;
961 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 #[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 #[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 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 #[test]
1023 fn test_deterministic_repair_generation() {
1024 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 #[test]
1046 fn test_cross_replica_determinism() {
1047 let payload = b"commit_capsule_payload_12345";
1051 let oid = ObjectId::derive_from_canonical_bytes(payload);
1052
1053 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 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 #[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 #[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 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 #[test]
1127 fn test_minimum_decode_detection() {
1128 let oid = test_object_id(0xBEEF);
1129 let k_source = 10;
1130
1131 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 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 #[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 #[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}