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 uses_minimum_symbols(&self) -> bool {
354 #[allow(clippy::cast_possible_truncation)]
355 let received = self.symbols_received.len() as u32;
356 received == self.k_source
357 }
358
359 #[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.eq(&expected_symbol_digests) {
461 return false;
462 }
463 if !self.rejected_symbols.eq(&expected_rejected) {
464 return false;
465 }
466 self.input_hashes.symbol_digests_xxh3 == hash_symbol_digests(&expected_symbol_digests)
467 && self.input_hashes.rejected_symbols_xxh3 == hash_rejected_symbols(&expected_rejected)
468 }
469
470 #[must_use]
472 #[allow(clippy::too_many_lines)]
473 pub fn verification_report(
474 &self,
475 config: DecodeProofVerificationConfig,
476 symbol_digests: &[SymbolDigest],
477 rejected_symbols: &[RejectedSymbol],
478 ) -> DecodeProofVerificationReport {
479 let expected_symbol_digests = canonicalize_symbol_digests(symbol_digests.to_vec());
480 let expected_rejected = canonicalize_rejected_symbols(rejected_symbols.to_vec());
481
482 let schema_version_ok = self.schema_version.eq(&config.expected_schema_version);
483 let policy_id_ok = self.policy_id.eq(&config.expected_policy_id);
484 let internal_consistency_ok = self.is_consistent();
485 let metadata_hash_ok = self.input_hashes.metadata_xxh3 == hash_metadata(self);
486 let source_hash_ok =
487 self.input_hashes.source_esis_xxh3 == hash_u32_list("source_esis", &self.source_esis);
488 let repair_hash_ok =
489 self.input_hashes.repair_esis_xxh3 == hash_u32_list("repair_esis", &self.repair_esis);
490 let rejected_hash_ok =
491 self.input_hashes.rejected_symbols_xxh3 == hash_rejected_symbols(&expected_rejected);
492 let symbol_digests_hash_ok =
493 self.input_hashes.symbol_digests_xxh3 == hash_symbol_digests(&expected_symbol_digests);
494 let replay_verifies = self.replay_verifies(&expected_symbol_digests, &expected_rejected);
495
496 let decode_success_expected_min_symbols =
497 self.k_source.saturating_add(config.decode_success_slack);
498 let decode_success_observed_symbols =
499 u32::try_from(self.symbols_received.len()).unwrap_or(u32::MAX);
500 let decode_success_budget_ok = !self.decode_success
501 || decode_success_observed_symbols >= decode_success_expected_min_symbols;
502 let rejected_reasons_hash_or_auth_only = self.rejected_symbols.iter().all(|entry| {
503 matches!(
504 entry.reason,
505 SymbolRejectionReason::HashMismatch | SymbolRejectionReason::InvalidAuthTag
506 )
507 });
508
509 let mut issues = Vec::new();
510 if !schema_version_ok {
511 issues.push(DecodeProofVerificationIssue {
512 code: String::from("schema_version_mismatch"),
513 detail: format!(
514 "expected {}, got {}",
515 config.expected_schema_version, self.schema_version
516 ),
517 });
518 }
519 if !policy_id_ok {
520 issues.push(DecodeProofVerificationIssue {
521 code: String::from("policy_id_mismatch"),
522 detail: format!(
523 "expected {}, got {}",
524 config.expected_policy_id, self.policy_id
525 ),
526 });
527 }
528 if !internal_consistency_ok {
529 issues.push(DecodeProofVerificationIssue {
530 code: String::from("internal_consistency_failed"),
531 detail: String::from("proof failed internal consistency checks"),
532 });
533 }
534 if !metadata_hash_ok
535 || !source_hash_ok
536 || !repair_hash_ok
537 || !rejected_hash_ok
538 || !symbol_digests_hash_ok
539 {
540 issues.push(DecodeProofVerificationIssue {
541 code: String::from("hash_mismatch"),
542 detail: format!(
543 "metadata={metadata_hash_ok} source={source_hash_ok} repair={repair_hash_ok} rejected={rejected_hash_ok} symbol_digests={symbol_digests_hash_ok}"
544 ),
545 });
546 }
547 if !replay_verifies {
548 issues.push(DecodeProofVerificationIssue {
549 code: String::from("replay_verification_failed"),
550 detail: String::from("provided digest/rejection evidence did not match proof"),
551 });
552 }
553 if !decode_success_budget_ok {
554 issues.push(DecodeProofVerificationIssue {
555 code: String::from("decode_success_budget_failed"),
556 detail: format!(
557 "success proof had {decode_success_observed_symbols} symbols, required >= {decode_success_expected_min_symbols}",
558 ),
559 });
560 }
561 if !rejected_reasons_hash_or_auth_only {
562 issues.push(DecodeProofVerificationIssue {
563 code: String::from("rejected_reason_unsupported"),
564 detail: String::from(
565 "rejected-symbol reasons must be hash/auth mismatch for this verifier",
566 ),
567 });
568 }
569
570 let ok = issues.is_empty();
571 DecodeProofVerificationReport {
572 ok,
573 expected_schema_version: config.expected_schema_version,
574 expected_policy_id: config.expected_policy_id,
575 decode_success_slack: config.decode_success_slack,
576 schema_version_ok,
577 policy_id_ok,
578 internal_consistency_ok,
579 metadata_hash_ok,
580 source_hash_ok,
581 repair_hash_ok,
582 rejected_hash_ok,
583 symbol_digests_hash_ok,
584 replay_verifies,
585 decode_success_budget_ok,
586 decode_success_expected_min_symbols,
587 decode_success_observed_symbols,
588 rejected_reasons_hash_or_auth_only,
589 issues,
590 }
591 }
592
593 #[allow(clippy::too_many_arguments)]
594 fn from_parts(
595 object_id: ObjectId,
596 changeset_id: Option<[u8; 16]>,
597 k_source: u32,
598 symbols_received: Vec<u32>,
599 source_esis: Vec<u32>,
600 repair_esis: Vec<u32>,
601 rejected_symbols: Vec<RejectedSymbol>,
602 symbol_digests: Vec<SymbolDigest>,
603 decode_success: bool,
604 failure_reason: Option<DecodeFailureReason>,
605 intermediate_rank: Option<u32>,
606 timing_ns: u64,
607 seed: u64,
608 ) -> Self {
609 let mut proof = Self {
610 schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
611 policy_id: DEFAULT_DECODE_PROOF_POLICY_ID,
612 object_id,
613 changeset_id,
614 k_source,
615 repair_count: u32::try_from(repair_esis.len()).unwrap_or(u32::MAX),
616 symbol_size: 0,
617 oti: None,
618 symbols_received: canonicalize_esis(symbols_received),
619 source_esis: canonicalize_esis(source_esis),
620 repair_esis: canonicalize_esis(repair_esis),
621 rejected_symbols: canonicalize_rejected_symbols(rejected_symbols),
622 symbol_digests: canonicalize_symbol_digests(symbol_digests),
623 decode_success,
624 failure_reason,
625 intermediate_rank,
626 timing_ns,
627 seed,
628 payload_mode: DecodeProofPayloadMode::HashesOnly,
629 debug_symbol_payloads: None,
630 input_hashes: ProofInputHashes {
631 metadata_xxh3: 0,
632 source_esis_xxh3: 0,
633 repair_esis_xxh3: 0,
634 rejected_symbols_xxh3: 0,
635 symbol_digests_xxh3: 0,
636 },
637 };
638 proof.input_hashes = proof.compute_input_hashes();
639 proof
640 }
641
642 fn compute_input_hashes(&self) -> ProofInputHashes {
643 ProofInputHashes {
644 metadata_xxh3: hash_metadata(self),
645 source_esis_xxh3: hash_u32_list("source_esis", &self.source_esis),
646 repair_esis_xxh3: hash_u32_list("repair_esis", &self.repair_esis),
647 rejected_symbols_xxh3: hash_rejected_symbols(&self.rejected_symbols),
648 symbol_digests_xxh3: hash_symbol_digests(&self.symbol_digests),
649 }
650 }
651}
652
653fn canonicalize_esis(mut values: Vec<u32>) -> Vec<u32> {
654 values.sort_unstable();
655 values.dedup();
656 values
657}
658
659fn canonicalize_rejected_symbols(mut values: Vec<RejectedSymbol>) -> Vec<RejectedSymbol> {
660 values.sort();
661 values.dedup();
662 values
663}
664
665fn canonicalize_symbol_digests(mut values: Vec<SymbolDigest>) -> Vec<SymbolDigest> {
666 values.sort();
667 values.dedup();
668 values
669}
670
671fn is_sorted_unique<T: Ord>(values: &[T]) -> bool {
672 values.windows(2).all(|pair| pair[0] < pair[1])
673}
674
675fn hash_u32_list(domain: &str, values: &[u32]) -> u64 {
676 let mut bytes = Vec::with_capacity(domain.len() + values.len() * 4);
677 bytes.extend_from_slice(domain.as_bytes());
678 for value in values {
679 bytes.extend_from_slice(&value.to_le_bytes());
680 }
681 xxh3_64(&bytes)
682}
683
684fn rejection_reason_code(reason: SymbolRejectionReason) -> u8 {
685 match reason {
686 SymbolRejectionReason::HashMismatch => 1,
687 SymbolRejectionReason::InvalidAuthTag => 2,
688 SymbolRejectionReason::DuplicateEsi => 3,
689 SymbolRejectionReason::FormatViolation => 4,
690 }
691}
692
693fn failure_reason_code(reason: DecodeFailureReason) -> u8 {
694 match reason {
695 DecodeFailureReason::InsufficientSymbols => 1,
696 DecodeFailureReason::RankDeficiency => 2,
697 DecodeFailureReason::IntegrityMismatch => 3,
698 DecodeFailureReason::Unknown => 255,
699 }
700}
701
702fn hash_rejected_symbols(values: &[RejectedSymbol]) -> u64 {
703 let mut bytes = Vec::with_capacity("rejected".len() + values.len() * 5);
704 bytes.extend_from_slice(b"rejected");
705 for value in values {
706 bytes.extend_from_slice(&value.esi.to_le_bytes());
707 bytes.push(rejection_reason_code(value.reason));
708 }
709 xxh3_64(&bytes)
710}
711
712fn hash_symbol_digests(values: &[SymbolDigest]) -> u64 {
713 let mut bytes = Vec::with_capacity("symbol_digests".len() + values.len() * 12);
714 bytes.extend_from_slice(b"symbol_digests");
715 for value in values {
716 bytes.extend_from_slice(&value.esi.to_le_bytes());
717 bytes.extend_from_slice(&value.digest_xxh3.to_le_bytes());
718 }
719 xxh3_64(&bytes)
720}
721
722fn hash_debug_payloads(payloads: Option<&[Vec<u8>]>) -> u64 {
723 let Some(payloads) = payloads else {
724 return 0;
725 };
726 let mut bytes = Vec::new();
727 bytes.extend_from_slice(b"debug_payloads");
728 for payload in payloads {
729 let len = u64::try_from(payload.len()).unwrap_or(u64::MAX);
730 bytes.extend_from_slice(&len.to_le_bytes());
731 bytes.extend_from_slice(&xxh3_64(payload).to_le_bytes());
732 }
733 xxh3_64(&bytes)
734}
735
736fn hash_metadata(proof: &EcsDecodeProof) -> u64 {
737 let mut bytes = Vec::new();
738 bytes.extend_from_slice(b"decode_proof_metadata");
739 bytes.extend_from_slice(&proof.schema_version.to_le_bytes());
740 bytes.extend_from_slice(&proof.policy_id.to_le_bytes());
741 bytes.extend_from_slice(proof.object_id.as_bytes());
742 if let Some(changeset_id) = proof.changeset_id {
743 bytes.push(1);
744 bytes.extend_from_slice(&changeset_id);
745 } else {
746 bytes.push(0);
747 }
748 bytes.extend_from_slice(&proof.k_source.to_le_bytes());
749 bytes.extend_from_slice(&proof.repair_count.to_le_bytes());
750 bytes.extend_from_slice(&proof.symbol_size.to_le_bytes());
751 bytes.extend_from_slice(&proof.seed.to_le_bytes());
752 if let Some(oti) = proof.oti {
753 bytes.push(1);
754 bytes.extend_from_slice(&oti.to_le_bytes());
755 } else {
756 bytes.push(0);
757 }
758 bytes.push(u8::from(proof.decode_success));
759 if let Some(reason) = proof.failure_reason {
760 bytes.push(1);
761 bytes.push(failure_reason_code(reason));
762 } else {
763 bytes.push(0);
764 }
765 if let Some(rank) = proof.intermediate_rank {
766 bytes.push(1);
767 bytes.extend_from_slice(&rank.to_le_bytes());
768 } else {
769 bytes.push(0);
770 }
771 bytes.extend_from_slice(&proof.timing_ns.to_le_bytes());
772 bytes.push(match proof.payload_mode {
773 DecodeProofPayloadMode::HashesOnly => 0,
774 DecodeProofPayloadMode::IncludeBytesLabOnly => 1,
775 });
776 bytes.extend_from_slice(
777 &hash_debug_payloads(proof.debug_symbol_payloads.as_deref()).to_le_bytes(),
778 );
779 xxh3_64(&bytes)
780}
781#[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(&proof.changeset_id.unwrap_or([0_u8; 16]));
817 bytes.extend_from_slice(&proof.k_source.to_le_bytes());
818 bytes.extend_from_slice(&proof.repair_count.to_le_bytes());
819 bytes.extend_from_slice(&proof.symbol_size.to_le_bytes());
820 bytes.extend_from_slice(&proof.oti.unwrap_or(0).to_le_bytes());
821 bytes.extend_from_slice(&proof.seed.to_le_bytes());
822 bytes.extend_from_slice(&proof.timing_ns.to_le_bytes());
823 bytes.push(u8::from(proof.decode_success));
824 bytes.extend_from_slice(
825 &proof
826 .failure_reason
827 .map_or(255_u8, failure_reason_code)
828 .to_le_bytes(),
829 );
830 bytes.extend_from_slice(&proof.intermediate_rank.unwrap_or(u32::MAX).to_le_bytes());
831 bytes.push(match proof.payload_mode {
832 DecodeProofPayloadMode::HashesOnly => 0,
833 DecodeProofPayloadMode::IncludeBytesLabOnly => 1,
834 });
835
836 bytes.extend_from_slice(
837 &u32::try_from(proof.symbols_received.len())
838 .expect("symbols_received length fits u32")
839 .to_le_bytes(),
840 );
841 for esi in &proof.symbols_received {
842 bytes.extend_from_slice(&esi.to_le_bytes());
843 }
844
845 bytes.extend_from_slice(
846 &u32::try_from(proof.source_esis.len())
847 .expect("source_esis length fits u32")
848 .to_le_bytes(),
849 );
850 for esi in &proof.source_esis {
851 bytes.extend_from_slice(&esi.to_le_bytes());
852 }
853
854 bytes.extend_from_slice(
855 &u32::try_from(proof.repair_esis.len())
856 .expect("repair_esis length fits u32")
857 .to_le_bytes(),
858 );
859 for esi in &proof.repair_esis {
860 bytes.extend_from_slice(&esi.to_le_bytes());
861 }
862
863 bytes.extend_from_slice(
864 &u32::try_from(proof.rejected_symbols.len())
865 .expect("rejected_symbols length fits u32")
866 .to_le_bytes(),
867 );
868 for rejected in &proof.rejected_symbols {
869 bytes.extend_from_slice(&rejected.esi.to_le_bytes());
870 bytes.push(rejection_reason_code(rejected.reason));
871 }
872
873 bytes.extend_from_slice(
874 &u32::try_from(proof.symbol_digests.len())
875 .expect("symbol_digests length fits u32")
876 .to_le_bytes(),
877 );
878 for digest in &proof.symbol_digests {
879 bytes.extend_from_slice(&digest.esi.to_le_bytes());
880 bytes.extend_from_slice(&digest.digest_xxh3.to_le_bytes());
881 }
882
883 if let Some(payloads) = &proof.debug_symbol_payloads {
884 bytes.extend_from_slice(
885 &u32::try_from(payloads.len())
886 .expect("debug payload count fits u32")
887 .to_le_bytes(),
888 );
889 for payload in payloads {
890 bytes.extend_from_slice(
891 &u32::try_from(payload.len())
892 .expect("debug payload length fits u32")
893 .to_le_bytes(),
894 );
895 bytes.extend_from_slice(payload);
896 }
897 } else {
898 bytes.extend_from_slice(&0_u32.to_le_bytes());
899 }
900
901 bytes
902 }
903
904 #[test]
907 fn test_decode_proof_creation() {
908 let oid = test_object_id(0x1234);
909 let k_source = 10;
910 let all_esis: Vec<u32> = (0..12).collect(); let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, true, Some(10), 5000, 42);
913
914 assert!(proof.decode_success);
915 assert_eq!(proof.source_esis, (0..10).collect::<Vec<u32>>());
916 assert_eq!(proof.repair_esis, vec![10, 11]);
917 assert_eq!(proof.symbols_received.len(), 12);
918 assert!(proof.is_repair());
919 assert!(proof.is_consistent());
920 }
921
922 #[test]
925 fn test_decode_proof_lab_mode() {
926 let oid = test_object_id(0x5678);
927 let lab_timing_ns = 1_000_000; let proof = EcsDecodeProof::success(
930 oid,
931 8,
932 (0..10).collect(),
933 (0..8).collect(),
934 vec![8, 9],
935 Some(8),
936 lab_timing_ns,
937 99,
938 );
939
940 let entry = DecodeAuditEntry {
941 proof,
942 seq: 1,
943 lab_mode: true,
944 };
945
946 assert!(entry.lab_mode);
947 assert_eq!(entry.proof.timing_ns, lab_timing_ns);
948 assert_eq!(entry.seq, 1);
949 }
950
951 #[test]
954 fn test_decode_proof_failure_case() {
955 let oid = test_object_id(0xABCD);
956 let k_source = 16;
957 let all_esis: Vec<u32> = (0..10).collect();
959
960 let proof = EcsDecodeProof::from_esis(oid, k_source, &all_esis, false, Some(10), 3000, 77);
961
962 assert!(!proof.decode_success);
963 assert_eq!(proof.intermediate_rank, Some(10));
964 assert_eq!(proof.source_esis.len(), 10);
965 assert!(proof.repair_esis.is_empty());
966 assert!(proof.is_consistent());
967 }
968
969 #[test]
972 fn test_decode_proof_auditable() {
973 let oid = test_object_id(0xFEED);
974 let k_source = 8;
975 let esis: Vec<u32> = (0..10).collect();
976
977 let proof_a = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
978 let proof_b = EcsDecodeProof::from_esis(oid, k_source, &esis, true, Some(8), 100, 42);
979
980 assert_eq!(
981 proof_a, proof_b,
982 "same inputs must produce identical proofs"
983 );
984 }
985
986 #[test]
989 fn test_decode_proof_attached_to_trace() {
990 let oid = test_object_id(0xCAFE);
991 let proof = EcsDecodeProof::success(
992 oid,
993 8,
994 (0..10).collect(),
995 (0..8).collect(),
996 vec![8, 9],
997 Some(8),
998 500,
999 42,
1000 );
1001
1002 let mut trace: Vec<DecodeAuditEntry> = Vec::new();
1004 if proof.is_repair() {
1005 trace.push(DecodeAuditEntry {
1006 proof,
1007 seq: 0,
1008 lab_mode: true,
1009 });
1010 }
1011
1012 assert_eq!(trace.len(), 1, "repair decode must produce audit entry");
1013 assert!(trace[0].lab_mode);
1014 }
1015
1016 #[test]
1019 fn test_deterministic_repair_generation() {
1020 let oid_a = test_object_id(0x1111);
1023 let oid_b = test_object_id(0x2222);
1024
1025 let seed_a = crate::repair_symbols::derive_repair_seed(&oid_a);
1026 let seed_b = crate::repair_symbols::derive_repair_seed(&oid_a);
1027 let seed_c = crate::repair_symbols::derive_repair_seed(&oid_b);
1028
1029 assert_eq!(
1030 seed_a, seed_b,
1031 "same ObjectId must produce same repair seed"
1032 );
1033 assert_ne!(
1034 seed_a, seed_c,
1035 "different ObjectIds must produce different seeds"
1036 );
1037 }
1038
1039 #[test]
1042 fn test_cross_replica_determinism() {
1043 let payload = b"commit_capsule_payload_12345";
1047 let oid = ObjectId::derive_from_canonical_bytes(payload);
1048
1049 let seed_r1 = crate::repair_symbols::derive_repair_seed(&oid);
1051 let budget_r1 = crate::repair_symbols::compute_repair_budget(
1052 100,
1053 &crate::repair_symbols::RepairConfig::new(),
1054 );
1055
1056 let seed_r2 = crate::repair_symbols::derive_repair_seed(&oid);
1058 let budget_r2 = crate::repair_symbols::compute_repair_budget(
1059 100,
1060 &crate::repair_symbols::RepairConfig::new(),
1061 );
1062
1063 assert_eq!(seed_r1, seed_r2, "cross-replica seed derivation must match");
1064 assert_eq!(
1065 budget_r1, budget_r2,
1066 "cross-replica repair budgets must match"
1067 );
1068 }
1069
1070 #[test]
1073 fn prop_proof_consistency_invariant() {
1074 for k in [1_u32, 4, 8, 16, 100] {
1075 for extra in [0_u32, 1, 2, 5, 10] {
1076 let total = k + extra;
1077 let esis: Vec<u32> = (0..total).collect();
1078 let oid = test_object_id(u64::from(k) * 1000 + u64::from(extra));
1079 let proof = EcsDecodeProof::from_esis(oid, k, &esis, true, None, 0, 0);
1080 assert!(
1081 proof.is_consistent(),
1082 "proof must be consistent for k={k}, extra={extra}"
1083 );
1084 assert_eq!(
1085 proof.source_esis.len(),
1086 k as usize,
1087 "source count must equal k"
1088 );
1089 assert_eq!(
1090 proof.repair_esis.len(),
1091 extra as usize,
1092 "repair count must equal extra"
1093 );
1094 }
1095 }
1096 }
1097
1098 #[test]
1101 fn prop_seed_no_collision() {
1102 use std::collections::HashSet;
1103
1104 let mut seeds = HashSet::new();
1105 for i in 0..100_000_u64 {
1106 let oid = ObjectId::derive_from_canonical_bytes(&i.to_le_bytes());
1107 let seed = crate::repair_symbols::derive_repair_seed(&oid);
1108 seeds.insert(seed);
1109 }
1110
1111 assert!(
1114 seeds.len() >= 99_999,
1115 "expected at most 1 collision in 100k seeds, got {} unique out of 100000",
1116 seeds.len()
1117 );
1118 }
1119
1120 #[test]
1123 fn test_minimum_decode_detection() {
1124 let oid = test_object_id(0xBEEF);
1125 let k_source = 10;
1126
1127 let esis_min: Vec<u32> = (0..10).collect();
1129 let proof_min =
1130 EcsDecodeProof::from_esis(oid, k_source, &esis_min, true, Some(10), 100, 42);
1131 assert!(
1132 proof_min.uses_minimum_symbols(),
1133 "K=10 received=10 should be minimum decode"
1134 );
1135
1136 let esis_extra: Vec<u32> = (0..12).collect();
1138 let proof_extra =
1139 EcsDecodeProof::from_esis(oid, k_source, &esis_extra, true, Some(10), 100, 42);
1140 assert!(
1141 !proof_extra.uses_minimum_symbols(),
1142 "K=10 received=12 should not be minimum decode"
1143 );
1144 }
1145
1146 #[test]
1149 fn test_decode_proof_schema_versioned_defaults() {
1150 let oid = test_object_id(0xAAAA);
1151 let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 42, 99);
1152 assert_eq!(proof.schema_version, DECODE_PROOF_SCHEMA_VERSION_V1);
1153 assert_eq!(proof.policy_id, DEFAULT_DECODE_PROOF_POLICY_ID);
1154 assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1155 assert!(proof.debug_symbol_payloads.is_none());
1156 assert!(proof.is_consistent());
1157 }
1158
1159 #[test]
1160 fn test_decode_proof_replay_verification_with_digests_and_rejections() {
1161 let oid = test_object_id(0xBBBB);
1162 let symbol_digests = vec![
1163 SymbolDigest {
1164 esi: 0,
1165 digest_xxh3: 11,
1166 },
1167 SymbolDigest {
1168 esi: 1,
1169 digest_xxh3: 22,
1170 },
1171 ];
1172 let rejected = vec![RejectedSymbol {
1173 esi: 9,
1174 reason: SymbolRejectionReason::InvalidAuthTag,
1175 }];
1176 let proof = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 100, 17)
1177 .with_symbol_digests(symbol_digests.clone())
1178 .with_rejected_symbols(rejected.clone());
1179
1180 assert!(proof.replay_verifies(&symbol_digests, &rejected));
1181 assert!(!proof.replay_verifies(
1182 &[SymbolDigest {
1183 esi: 0,
1184 digest_xxh3: 999
1185 }],
1186 &rejected
1187 ));
1188 }
1189
1190 #[test]
1191 fn test_decode_proof_canonicalization_is_deterministic() {
1192 let oid = test_object_id(0xCCCC);
1193 let a = EcsDecodeProof::from_esis(oid, 4, &[3, 0, 1, 3, 2, 4, 4], false, Some(3), 77, 5)
1194 .with_rejected_symbols(vec![
1195 RejectedSymbol {
1196 esi: 8,
1197 reason: SymbolRejectionReason::HashMismatch,
1198 },
1199 RejectedSymbol {
1200 esi: 8,
1201 reason: SymbolRejectionReason::HashMismatch,
1202 },
1203 ]);
1204 let b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4], false, Some(3), 77, 5)
1205 .with_rejected_symbols(vec![RejectedSymbol {
1206 esi: 8,
1207 reason: SymbolRejectionReason::HashMismatch,
1208 }]);
1209 assert_eq!(a, b, "canonicalization must make output deterministic");
1210 assert!(a.is_consistent());
1211 }
1212
1213 #[test]
1214 fn test_decode_proof_failure_reason_consistency() {
1215 let oid = test_object_id(0xDDDD);
1216 let proof = EcsDecodeProof::failure(
1217 oid,
1218 8,
1219 vec![0, 1, 2],
1220 vec![0, 1, 2],
1221 vec![],
1222 Some(3),
1223 900,
1224 33,
1225 );
1226 assert_eq!(proof.failure_reason, Some(DecodeFailureReason::Unknown));
1227 assert!(!proof.decode_success);
1228 assert!(proof.is_consistent());
1229 }
1230
1231 #[test]
1232 fn test_decode_proof_verification_report_success() {
1233 let oid = test_object_id(0xEEEE);
1234 let symbol_digests = vec![
1235 SymbolDigest {
1236 esi: 0,
1237 digest_xxh3: 10,
1238 },
1239 SymbolDigest {
1240 esi: 1,
1241 digest_xxh3: 20,
1242 },
1243 ];
1244 let rejected = vec![RejectedSymbol {
1245 esi: 9,
1246 reason: SymbolRejectionReason::HashMismatch,
1247 }];
1248 let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 50, 7)
1249 .with_symbol_digests(symbol_digests.clone())
1250 .with_rejected_symbols(rejected.clone());
1251
1252 let report = proof.verification_report(
1253 DecodeProofVerificationConfig::default(),
1254 &symbol_digests,
1255 &rejected,
1256 );
1257 assert!(report.ok, "report should pass: {report:?}");
1258 assert!(report.replay_verifies);
1259 assert!(report.decode_success_budget_ok);
1260 assert!(report.issues.is_empty());
1261 }
1262
1263 #[test]
1264 fn test_decode_proof_verification_report_detects_mismatch() {
1265 let oid = test_object_id(0xFFFF);
1266 let proof = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3], true, Some(4), 90, 17)
1267 .with_rejected_symbols(vec![RejectedSymbol {
1268 esi: 7,
1269 reason: SymbolRejectionReason::DuplicateEsi,
1270 }]);
1271
1272 let config = DecodeProofVerificationConfig {
1273 expected_schema_version: DECODE_PROOF_SCHEMA_VERSION_V1,
1274 expected_policy_id: DEFAULT_DECODE_PROOF_POLICY_ID + 1,
1275 decode_success_slack: DEFAULT_DECODE_PROOF_SLACK,
1276 };
1277 let report = proof.verification_report(config, &[], &[]);
1278 let issue_codes: Vec<&str> = report
1279 .issues
1280 .iter()
1281 .map(|issue| issue.code.as_str())
1282 .collect();
1283
1284 assert!(!report.ok);
1285 assert!(!report.policy_id_ok);
1286 assert!(!report.decode_success_budget_ok);
1287 assert!(!report.replay_verifies);
1288 assert!(!report.rejected_reasons_hash_or_auth_only);
1289 assert!(
1290 issue_codes.contains(&"policy_id_mismatch"),
1291 "expected policy mismatch in {issue_codes:?}"
1292 );
1293 assert!(
1294 issue_codes.contains(&"decode_success_budget_failed"),
1295 "expected decode budget mismatch in {issue_codes:?}"
1296 );
1297 assert!(
1298 issue_codes.contains(&"replay_verification_failed"),
1299 "expected replay verification mismatch in {issue_codes:?}"
1300 );
1301 assert!(
1302 issue_codes.contains(&"rejected_reason_unsupported"),
1303 "expected rejected reason mismatch in {issue_codes:?}"
1304 );
1305 }
1306
1307 #[test]
1310 fn test_decode_proof_serialized_stability_fixed_inputs() {
1311 let oid = test_object_id(0x2210);
1312 let symbol_digests = vec![
1313 SymbolDigest {
1314 esi: 0,
1315 digest_xxh3: 0xAA01,
1316 },
1317 SymbolDigest {
1318 esi: 1,
1319 digest_xxh3: 0xAA02,
1320 },
1321 SymbolDigest {
1322 esi: 2,
1323 digest_xxh3: 0xAA03,
1324 },
1325 ];
1326 let rejected = vec![RejectedSymbol {
1327 esi: 6,
1328 reason: SymbolRejectionReason::HashMismatch,
1329 }];
1330
1331 let proof_a = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1332 .with_symbol_digests(symbol_digests.clone())
1333 .with_rejected_symbols(rejected.clone());
1334 let proof_b = EcsDecodeProof::from_esis(oid, 4, &[0, 1, 2, 3, 4, 5], true, Some(4), 88, 55)
1335 .with_symbol_digests(symbol_digests)
1336 .with_rejected_symbols(rejected);
1337
1338 let json_a = stable_proof_bytes_for_test(&proof_a);
1339 let json_b = stable_proof_bytes_for_test(&proof_b);
1340 assert_eq!(
1341 json_a, json_b,
1342 "fixed inputs must produce byte-identical serialized proof artifacts"
1343 );
1344 assert!(
1345 proof_a.debug_symbol_payloads.is_none(),
1346 "default proof must not embed raw symbol payload bytes"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_decode_proof_verifier_rejects_altered_esi_list() {
1352 let oid = test_object_id(0x2211);
1353 let symbol_digests = vec![SymbolDigest {
1354 esi: 0,
1355 digest_xxh3: 0x10,
1356 }];
1357 let rejected = vec![RejectedSymbol {
1358 esi: 9,
1359 reason: SymbolRejectionReason::InvalidAuthTag,
1360 }];
1361 let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], true, Some(2), 13, 99)
1362 .with_symbol_digests(symbol_digests.clone())
1363 .with_rejected_symbols(rejected.clone());
1364 tampered.symbols_received.push(99);
1365 let report = tampered.verification_report(
1366 DecodeProofVerificationConfig::default(),
1367 &symbol_digests,
1368 &rejected,
1369 );
1370
1371 let issue_codes: Vec<&str> = report
1372 .issues
1373 .iter()
1374 .map(|issue| issue.code.as_str())
1375 .collect();
1376 assert!(!report.ok, "tampered ESI list must fail verification");
1377 assert!(!report.internal_consistency_ok);
1378 assert!(
1379 issue_codes.contains(&"internal_consistency_failed"),
1380 "expected consistency failure in {issue_codes:?}"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_decode_proof_verifier_rejects_altered_hashes() {
1386 let oid = test_object_id(0x2212);
1387 let symbol_digests = vec![SymbolDigest {
1388 esi: 0,
1389 digest_xxh3: 0x20,
1390 }];
1391 let rejected = vec![RejectedSymbol {
1392 esi: 7,
1393 reason: SymbolRejectionReason::HashMismatch,
1394 }];
1395 let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 21, 123)
1396 .with_symbol_digests(symbol_digests.clone())
1397 .with_rejected_symbols(rejected.clone());
1398 tampered.input_hashes.metadata_xxh3 ^= 1;
1399 let report = tampered.verification_report(
1400 DecodeProofVerificationConfig::default(),
1401 &symbol_digests,
1402 &rejected,
1403 );
1404
1405 let issue_codes: Vec<&str> = report
1406 .issues
1407 .iter()
1408 .map(|issue| issue.code.as_str())
1409 .collect();
1410 assert!(!report.ok, "tampered hash evidence must fail verification");
1411 assert!(!report.metadata_hash_ok);
1412 assert!(
1413 issue_codes.contains(&"hash_mismatch"),
1414 "expected hash mismatch issue in {issue_codes:?}"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_decode_proof_verifier_rejects_wrong_schema_version() {
1420 let oid = test_object_id(0x2213);
1421 let symbol_digests = vec![SymbolDigest {
1422 esi: 0,
1423 digest_xxh3: 0x30,
1424 }];
1425 let rejected = vec![RejectedSymbol {
1426 esi: 8,
1427 reason: SymbolRejectionReason::InvalidAuthTag,
1428 }];
1429 let mut tampered = EcsDecodeProof::from_esis(oid, 2, &[0, 1, 2], false, Some(2), 34, 77)
1430 .with_symbol_digests(symbol_digests.clone())
1431 .with_rejected_symbols(rejected.clone());
1432 tampered.schema_version = DECODE_PROOF_SCHEMA_VERSION_V1 + 1;
1433 let report = tampered.verification_report(
1434 DecodeProofVerificationConfig::default(),
1435 &symbol_digests,
1436 &rejected,
1437 );
1438
1439 let issue_codes: Vec<&str> = report
1440 .issues
1441 .iter()
1442 .map(|issue| issue.code.as_str())
1443 .collect();
1444 assert!(!report.ok);
1445 assert!(!report.schema_version_ok);
1446 assert!(
1447 issue_codes.contains(&"schema_version_mismatch"),
1448 "expected schema mismatch issue in {issue_codes:?}"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_decode_proof_hashes_only_artifact_is_compact() {
1454 let oid = test_object_id(0x2214);
1455 let proof =
1456 EcsDecodeProof::from_esis(oid, 8, &[0, 1, 2, 3, 4, 8, 9], true, Some(8), 55, 100);
1457 let serialized = stable_proof_bytes_for_test(&proof);
1458
1459 assert_eq!(proof.payload_mode, DecodeProofPayloadMode::HashesOnly);
1460 assert!(
1461 proof.debug_symbol_payloads.is_none(),
1462 "hashes-only mode must not include raw symbol payloads"
1463 );
1464 assert!(
1465 serialized.len() < 1024,
1466 "proof artifact unexpectedly large: {} bytes",
1467 serialized.len()
1468 );
1469 }
1470}