Skip to main content

sbo3l_core/
audit_bundle.rs

1//! Verifiable audit export bundle (v1).
2//!
3//! A self-contained, machine-readable proof that a SBO3L decision happened
4//! and is internally consistent. The bundle pulls together everything a
5//! third party (or a future you) needs to verify that:
6//!
7//! - the policy receipt was signed by the recorded receipt-signer key,
8//! - the audit event referenced by the receipt was signed by the recorded
9//!   audit-signer key,
10//! - the audit event sits in a hash-chained log whose `prev_event_hash`
11//!   linkage and per-event `event_hash` reproduce from the canonical event
12//!   bytes,
13//! - and the receipt's `audit_event_id` actually maps to the supplied
14//!   audit event.
15//!
16//! The bundle is *not* an oracle of legitimacy — it does not say "SBO3L
17//! issued this receipt"; only the public keys you decide to trust can do
18//! that. The bundle says "given that you trust these two public keys,
19//! every signature, hash and link in this proof is valid".
20//!
21//! Tagline: **SBO3L does not just decide. It leaves behind verifiable proof.**
22
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27use crate::audit::{verify_chain, ChainError, SignedAuditEvent};
28use crate::receipt::{Decision, PolicyReceipt};
29use crate::signer::VerifyError;
30
31/// Top-level bundle envelope. See module docs.
32///
33/// `audit_chain_segment` MUST start at the genesis event (seq=1) and run
34/// in seq order through the event referenced by `receipt.audit_event_id`.
35/// A future revision can carry a Merkle proof instead of the full segment.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct AuditBundle {
39    pub bundle_type: BundleType,
40    pub version: u32,
41    pub exported_at: DateTime<Utc>,
42    pub receipt: PolicyReceipt,
43    pub audit_event: SignedAuditEvent,
44    pub audit_chain_segment: Vec<SignedAuditEvent>,
45    pub verification_keys: VerificationKeys,
46    pub summary: BundleSummary,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum BundleType {
51    #[serde(rename = "sbo3l.audit_bundle.v1")]
52    AuditBundleV1,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct VerificationKeys {
58    pub receipt_signer_pubkey_hex: String,
59    pub audit_signer_pubkey_hex: String,
60}
61
62/// Pre-extracted convenience fields. Always derived from the other bundle
63/// fields; `verify` re-derives and asserts equality, so a tampered summary
64/// cannot lie about the receipt or chain.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct BundleSummary {
68    pub decision: Decision,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub deny_code: Option<String>,
71    pub request_hash: String,
72    pub policy_hash: String,
73    pub audit_event_id: String,
74    pub audit_event_hash: String,
75    pub audit_chain_root: String,
76    pub audit_chain_latest: String,
77}
78
79/// Result of a successful verification. Mirrors the bundle summary plus a
80/// few derived counters.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerifySummary {
83    pub receipt_signature_ok: bool,
84    pub audit_event_signature_ok: bool,
85    pub audit_chain_ok: bool,
86    pub receipt_audit_link_ok: bool,
87    pub decision: Decision,
88    pub deny_code: Option<String>,
89    pub request_hash: String,
90    pub policy_hash: String,
91    pub audit_event_id: String,
92    pub audit_event_hash: String,
93    pub audit_chain_length: usize,
94}
95
96/// The single supported bundle format identity. Both fields are checked
97/// in `verify()`; either mismatching is a fail-closed format-confusion guard.
98const SUPPORTED_BUNDLE_VERSION: u32 = 1;
99
100#[derive(Debug, Error)]
101pub enum BundleError {
102    #[error("bundle is missing a receipt's audit_event_id from the chain segment")]
103    AuditEventNotInChain,
104    #[error("receipt.audit_event_id does not match audit_event.event.id")]
105    ReceiptAuditMismatch,
106    #[error("audit_event hash in chain does not match standalone audit_event")]
107    AuditEventHashMismatch,
108    #[error("summary field '{0}' does not match the bundle body")]
109    SummaryMismatch(&'static str),
110    #[error("receipt signature does not verify under verification_keys.receipt_signer_pubkey_hex")]
111    ReceiptSignatureInvalid,
112    #[error(
113        "audit_event signature does not verify under verification_keys.audit_signer_pubkey_hex"
114    )]
115    AuditEventSignatureInvalid,
116    #[error("audit chain invalid: {0}")]
117    Chain(#[from] ChainError),
118    #[error("signer error: {0}")]
119    Signer(#[from] VerifyError),
120    #[error("serde error: {0}")]
121    Serde(#[from] serde_json::Error),
122    #[error("unsupported bundle version: {0} (this build supports v1)")]
123    UnsupportedVersion(u32),
124    #[error("unsupported bundle_type: only sbo3l.audit_bundle.v1 is accepted in this build")]
125    UnsupportedBundleType,
126}
127
128/// Build a bundle from already-signed pieces. Both signer public keys must
129/// be supplied — the bundle records who you intend the verifier to trust,
130/// not who actually signed (signatures themselves prove that).
131///
132/// `audit_chain_segment` must include the receipt's audit event and every
133/// preceding event back to seq=1, in seq order.
134pub fn build(
135    receipt: PolicyReceipt,
136    audit_chain_segment: Vec<SignedAuditEvent>,
137    receipt_signer_pubkey_hex: String,
138    audit_signer_pubkey_hex: String,
139    exported_at: DateTime<Utc>,
140) -> Result<AuditBundle, BundleError> {
141    let audit_event = audit_chain_segment
142        .iter()
143        .find(|e| e.event.id == receipt.audit_event_id)
144        .cloned()
145        .ok_or(BundleError::AuditEventNotInChain)?;
146    let chain_root = audit_chain_segment
147        .first()
148        .map(|e| e.event_hash.clone())
149        .ok_or(BundleError::AuditEventNotInChain)?;
150    let chain_latest = audit_chain_segment
151        .last()
152        .map(|e| e.event_hash.clone())
153        .ok_or(BundleError::AuditEventNotInChain)?;
154    let summary = BundleSummary {
155        decision: receipt.decision.clone(),
156        deny_code: receipt.deny_code.clone(),
157        request_hash: receipt.request_hash.clone(),
158        policy_hash: receipt.policy_hash.clone(),
159        audit_event_id: audit_event.event.id.clone(),
160        audit_event_hash: audit_event.event_hash.clone(),
161        audit_chain_root: chain_root,
162        audit_chain_latest: chain_latest,
163    };
164    Ok(AuditBundle {
165        bundle_type: BundleType::AuditBundleV1,
166        version: 1,
167        exported_at,
168        receipt,
169        audit_event,
170        audit_chain_segment,
171        verification_keys: VerificationKeys {
172            receipt_signer_pubkey_hex,
173            audit_signer_pubkey_hex,
174        },
175        summary,
176    })
177}
178
179/// Verify every claim the bundle makes. Returns a populated `VerifySummary`
180/// on success or the first invariant violation as an error. We deliberately
181/// fail fast — partial-success reporting would let a tampered bundle pick
182/// which checks the verifier sees pass.
183///
184/// The summary block carried inside the bundle is re-derived and compared
185/// against the body, so a tampered summary cannot misrepresent the receipt
186/// or chain. (The acceptance test for this is
187/// `verify_fails_when_summary_lies_about_decision`.)
188pub fn verify(bundle: &AuditBundle) -> Result<VerifySummary, BundleError> {
189    // 0. Format-confusion guard. A bundle with `version: 2` (or any other
190    //    value) MUST NOT verify as if it were v1, even if every signature
191    //    inside still happens to round-trip — a future v2 may carry
192    //    different fields, different canonical-body rules, or different
193    //    semantics, and silently accepting it under v1 rules would let an
194    //    attacker present a v2 bundle to a v1 verifier. Same reasoning for
195    //    `bundle_type`: serde already rejects unknown enum variants at
196    //    parse time, but the explicit `matches!` here is defence-in-depth
197    //    against future enum additions and against callers who construct
198    //    a bundle programmatically (bypassing serde).
199    if !matches!(bundle.bundle_type, BundleType::AuditBundleV1) {
200        return Err(BundleError::UnsupportedBundleType);
201    }
202    if bundle.version != SUPPORTED_BUNDLE_VERSION {
203        return Err(BundleError::UnsupportedVersion(bundle.version));
204    }
205
206    // 1. Receipt signature — covers request_hash, policy_hash, decision,
207    //    deny_code, audit_event_id, etc. via canonical-body signing.
208    bundle
209        .receipt
210        .verify(&bundle.verification_keys.receipt_signer_pubkey_hex)
211        .map_err(|_| BundleError::ReceiptSignatureInvalid)?;
212
213    // 2. Standalone audit_event signature.
214    bundle
215        .audit_event
216        .verify_signature(&bundle.verification_keys.audit_signer_pubkey_hex)
217        .map_err(|_| BundleError::AuditEventSignatureInvalid)?;
218
219    // 3. Chain integrity — recomputes every event_hash, walks prev_event_hash,
220    //    re-verifies every signature with the same audit signer key.
221    verify_chain(
222        &bundle.audit_chain_segment,
223        true,
224        Some(&bundle.verification_keys.audit_signer_pubkey_hex),
225    )?;
226
227    // 4. The receipt must point at an event that actually exists in the
228    //    chain segment, and the standalone audit_event must match the chain
229    //    member with the same id (id, hash, signature, prev pointer all the
230    //    same — equality on the SignedAuditEvent struct).
231    if bundle.receipt.audit_event_id != bundle.audit_event.event.id {
232        return Err(BundleError::ReceiptAuditMismatch);
233    }
234    let chain_member = bundle
235        .audit_chain_segment
236        .iter()
237        .find(|e| e.event.id == bundle.audit_event.event.id)
238        .ok_or(BundleError::AuditEventNotInChain)?;
239    if chain_member != &bundle.audit_event {
240        // Same id but different signed contents — the standalone event
241        // disagrees with the chain member.
242        return Err(BundleError::AuditEventHashMismatch);
243    }
244
245    // 5. Summary block must agree with the body. Cheap protection against
246    //    a tampered summary that contradicts what the (signed) receipt says.
247    let s = &bundle.summary;
248    if s.decision != bundle.receipt.decision {
249        return Err(BundleError::SummaryMismatch("decision"));
250    }
251    if s.deny_code != bundle.receipt.deny_code {
252        return Err(BundleError::SummaryMismatch("deny_code"));
253    }
254    if s.request_hash != bundle.receipt.request_hash {
255        return Err(BundleError::SummaryMismatch("request_hash"));
256    }
257    if s.policy_hash != bundle.receipt.policy_hash {
258        return Err(BundleError::SummaryMismatch("policy_hash"));
259    }
260    if s.audit_event_id != bundle.audit_event.event.id {
261        return Err(BundleError::SummaryMismatch("audit_event_id"));
262    }
263    if s.audit_event_hash != bundle.audit_event.event_hash {
264        return Err(BundleError::SummaryMismatch("audit_event_hash"));
265    }
266    let expected_root = &bundle
267        .audit_chain_segment
268        .first()
269        .ok_or(BundleError::AuditEventNotInChain)?
270        .event_hash;
271    if &s.audit_chain_root != expected_root {
272        return Err(BundleError::SummaryMismatch("audit_chain_root"));
273    }
274    let expected_latest = &bundle
275        .audit_chain_segment
276        .last()
277        .ok_or(BundleError::AuditEventNotInChain)?
278        .event_hash;
279    if &s.audit_chain_latest != expected_latest {
280        return Err(BundleError::SummaryMismatch("audit_chain_latest"));
281    }
282
283    Ok(VerifySummary {
284        receipt_signature_ok: true,
285        audit_event_signature_ok: true,
286        audit_chain_ok: true,
287        receipt_audit_link_ok: true,
288        decision: bundle.receipt.decision.clone(),
289        deny_code: bundle.receipt.deny_code.clone(),
290        request_hash: bundle.receipt.request_hash.clone(),
291        policy_hash: bundle.receipt.policy_hash.clone(),
292        audit_event_id: bundle.audit_event.event.id.clone(),
293        audit_event_hash: bundle.audit_event.event_hash.clone(),
294        audit_chain_length: bundle.audit_chain_segment.len(),
295    })
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::audit::{AuditEvent, ZERO_HASH};
302    use crate::receipt::UnsignedReceipt;
303    use crate::signer::DevSigner;
304
305    /// Build a small but realistic bundle covering the receipt for seq=2
306    /// in a 3-event chain. Exposes the two signers so tampering tests can
307    /// flip pieces and re-verify.
308    fn fixture() -> (AuditBundle, DevSigner, DevSigner) {
309        let audit_signer = DevSigner::from_seed("audit-signer-v1", [11u8; 32]);
310        let receipt_signer = DevSigner::from_seed("decision-signer-v1", [7u8; 32]);
311
312        let e1_event = AuditEvent {
313            version: 1,
314            seq: 1,
315            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGQ".to_string(),
316            ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
317                .unwrap()
318                .into(),
319            event_type: "runtime_started".to_string(),
320            actor: "sbo3l-server".to_string(),
321            subject_id: "runtime".to_string(),
322            payload_hash: ZERO_HASH.to_string(),
323            metadata: serde_json::Map::new(),
324            policy_version: None,
325            policy_hash: None,
326            attestation_ref: None,
327            prev_event_hash: ZERO_HASH.to_string(),
328        };
329        let e1 = SignedAuditEvent::sign(e1_event, &audit_signer).unwrap();
330
331        let e2_event = AuditEvent {
332            version: 1,
333            seq: 2,
334            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGR".to_string(),
335            ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01Z")
336                .unwrap()
337                .into(),
338            event_type: "policy_decided".to_string(),
339            actor: "policy_engine".to_string(),
340            subject_id: "pr-test-001".to_string(),
341            payload_hash: "1111111111111111111111111111111111111111111111111111111111111111"
342                .to_string(),
343            metadata: serde_json::Map::new(),
344            policy_version: Some(1),
345            policy_hash: Some(
346                "2222222222222222222222222222222222222222222222222222222222222222".to_string(),
347            ),
348            attestation_ref: None,
349            prev_event_hash: e1.event_hash.clone(),
350        };
351        let e2 = SignedAuditEvent::sign(e2_event, &audit_signer).unwrap();
352
353        let e3_event = AuditEvent {
354            version: 1,
355            seq: 3,
356            id: "evt-01HTAWX5K3R8YV9NQB7C6P2DGS".to_string(),
357            ts: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:02Z")
358                .unwrap()
359                .into(),
360            event_type: "policy_decided".to_string(),
361            actor: "policy_engine".to_string(),
362            subject_id: "pr-test-002".to_string(),
363            payload_hash: "3333333333333333333333333333333333333333333333333333333333333333"
364                .to_string(),
365            metadata: serde_json::Map::new(),
366            policy_version: Some(1),
367            policy_hash: Some(
368                "2222222222222222222222222222222222222222222222222222222222222222".to_string(),
369            ),
370            attestation_ref: None,
371            prev_event_hash: e2.event_hash.clone(),
372        };
373        let e3 = SignedAuditEvent::sign(e3_event, &audit_signer).unwrap();
374
375        let unsigned = UnsignedReceipt {
376            agent_id: "research-agent-01".to_string(),
377            decision: Decision::Allow,
378            deny_code: None,
379            request_hash: "1111111111111111111111111111111111111111111111111111111111111111"
380                .to_string(),
381            policy_hash: "2222222222222222222222222222222222222222222222222222222222222222"
382                .to_string(),
383            policy_version: Some(1),
384            audit_event_id: e2.event.id.clone(),
385            execution_ref: None,
386            issued_at: chrono::DateTime::parse_from_rfc3339("2026-04-27T12:00:01.500Z")
387                .unwrap()
388                .into(),
389            expires_at: None,
390        };
391        let receipt = unsigned.sign(&receipt_signer).unwrap();
392        let exported_at: DateTime<Utc> =
393            chrono::DateTime::parse_from_rfc3339("2026-04-28T08:00:00Z")
394                .unwrap()
395                .into();
396        let bundle = build(
397            receipt,
398            vec![e1, e2, e3],
399            receipt_signer.verifying_key_hex(),
400            audit_signer.verifying_key_hex(),
401            exported_at,
402        )
403        .unwrap();
404        (bundle, receipt_signer, audit_signer)
405    }
406
407    #[test]
408    fn happy_path_round_trip_verifies() {
409        let (bundle, _, _) = fixture();
410        let summary = verify(&bundle).expect("bundle must verify");
411        assert!(summary.receipt_signature_ok);
412        assert!(summary.audit_event_signature_ok);
413        assert!(summary.audit_chain_ok);
414        assert!(summary.receipt_audit_link_ok);
415        assert_eq!(summary.audit_chain_length, 3);
416        assert_eq!(summary.decision, Decision::Allow);
417    }
418
419    #[test]
420    fn bundle_canonical_export_is_deterministic() {
421        // Two identical inputs must produce byte-identical JSON. We use the
422        // standard serde_json::to_vec because the bundle's serde derives
423        // serialise fields in a fixed declaration order.
424        let (bundle, _, _) = fixture();
425        let a = serde_json::to_vec(&bundle).unwrap();
426        let b = serde_json::to_vec(&bundle).unwrap();
427        assert_eq!(a, b);
428    }
429
430    #[test]
431    fn verify_fails_when_request_hash_mutated() {
432        // Mutating any signature-covered field on the receipt invalidates
433        // the receipt signature. This pins the security claim: a tampered
434        // request_hash cannot pass verification even if the signature bytes
435        // are kept intact.
436        let (mut bundle, _, _) = fixture();
437        bundle.receipt.request_hash =
438            "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string();
439        // Note: we deliberately do NOT touch the summary here; the receipt-
440        // signature check fails before the summary mismatch check would run.
441        let err = verify(&bundle).expect_err("must reject mutated request_hash");
442        assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
443    }
444
445    #[test]
446    fn verify_fails_when_policy_hash_mutated() {
447        let (mut bundle, _, _) = fixture();
448        bundle.receipt.policy_hash =
449            "cafebabecafebabecafebabecafebabecafebabecafebabecafebabecafebabe".to_string();
450        let err = verify(&bundle).expect_err("must reject mutated policy_hash");
451        assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
452    }
453
454    #[test]
455    fn verify_fails_when_receipt_signature_bytes_mutated() {
456        let (mut bundle, _, _) = fixture();
457        // Flip one nibble in the signature — must invalidate it without
458        // changing any field the signature covers.
459        let sig = &mut bundle.receipt.signature.signature_hex;
460        let last = sig.pop().unwrap();
461        sig.push(if last == '0' { '1' } else { '0' });
462        let err = verify(&bundle).expect_err("must reject mutated signature");
463        assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
464    }
465
466    #[test]
467    fn verify_fails_when_audit_event_hash_mutated() {
468        // The standalone audit_event must match the chain member of the
469        // same id. Mutating the standalone event's hash makes the standalone
470        // and the chain disagree — caught by the AuditEventHashMismatch
471        // check before chain verification would even matter.
472        let (mut bundle, _, _) = fixture();
473        bundle.audit_event.event_hash =
474            "0000000000000000000000000000000000000000000000000000000000000001".to_string();
475        let err = verify(&bundle).expect_err("must reject mutated audit_event hash");
476        // Standalone audit_event signature is computed over the *event*, not
477        // event_hash; flipping event_hash alone passes signature verify but
478        // breaks the standalone vs chain equality check.
479        assert!(matches!(err, BundleError::AuditEventHashMismatch));
480    }
481
482    #[test]
483    fn verify_fails_when_audit_chain_linkage_broken() {
484        let (mut bundle, _, _) = fixture();
485        // Flip prev_event_hash on seq=3 — verify_chain detects PrevHashMismatch.
486        bundle.audit_chain_segment[2].event.prev_event_hash =
487            "0000000000000000000000000000000000000000000000000000000000000001".to_string();
488        let err = verify(&bundle).expect_err("must reject broken chain linkage");
489        assert!(matches!(err, BundleError::Chain(_)));
490    }
491
492    #[test]
493    fn verify_fails_when_audit_event_not_in_chain() {
494        let (mut bundle, _, _) = fixture();
495        // Drop the receipt's referenced event from the chain segment.
496        bundle.audit_chain_segment.retain(|e| e.event.seq != 2);
497        // Patch summary's chain endpoints to keep the summary self-consistent
498        // so we exercise the audit-link check, not the summary check.
499        bundle.summary.audit_chain_root = bundle.audit_chain_segment[0].event_hash.clone();
500        bundle.summary.audit_chain_latest = bundle
501            .audit_chain_segment
502            .last()
503            .unwrap()
504            .event_hash
505            .clone();
506        let err = verify(&bundle).expect_err("must reject missing audit_event");
507        // The chain segment now skips seq=2, so prev_event_hash on the
508        // remaining seq=3 no longer matches its predecessor — chain verify
509        // catches that first. (If the receipt's audit_event_id had pointed
510        // outside any plausible chain, the AuditEventNotInChain branch
511        // would fire instead. This test pins the realistic path.)
512        assert!(matches!(err, BundleError::Chain(_)));
513    }
514
515    #[test]
516    fn verify_fails_when_summary_lies_about_decision() {
517        // Independent property: the summary cannot disagree with the body.
518        let (mut bundle, _, _) = fixture();
519        bundle.summary.decision = Decision::Deny;
520        let err = verify(&bundle).expect_err("must reject summary that lies");
521        assert!(matches!(err, BundleError::SummaryMismatch("decision")));
522    }
523
524    #[test]
525    fn verify_fails_when_wrong_pubkey_supplied() {
526        // If the caller swaps the verification key (e.g. pretends a
527        // different signer issued the receipt), receipt verification fails.
528        let (mut bundle, _, _) = fixture();
529        let other = DevSigner::from_seed("attacker", [99u8; 32]);
530        bundle.verification_keys.receipt_signer_pubkey_hex = other.verifying_key_hex();
531        let err = verify(&bundle).expect_err("must reject wrong receipt pubkey");
532        assert!(matches!(err, BundleError::ReceiptSignatureInvalid));
533    }
534
535    #[test]
536    fn verify_fails_when_version_field_is_not_one() {
537        // Format-confusion guard: a bundle that claims to be v2 (or any
538        // value other than 1) must NOT verify under v1 rules even if every
539        // signature inside still happens to round-trip. This fires before
540        // any signature/chain check so a malicious v2 bundle never reaches
541        // the v1 verification path.
542        let (mut bundle, _, _) = fixture();
543        bundle.version = 2;
544        let err = verify(&bundle).expect_err("must reject unsupported bundle version");
545        assert!(
546            matches!(err, BundleError::UnsupportedVersion(2)),
547            "got {err:?}"
548        );
549
550        // Sanity: a fresh v1 bundle still verifies, so the gate isn't a
551        // false positive.
552        let (good, _, _) = fixture();
553        assert_eq!(good.version, 1);
554        verify(&good).expect("valid v1 bundle must still verify");
555    }
556
557    #[test]
558    fn verify_fails_when_version_is_unsupported_via_json_round_trip() {
559        // Same property as above, but exercised through the JSON path: the
560        // serde derive happily round-trips arbitrary u32 values for
561        // `version`, so a tampered exported bundle reaches `verify()` with
562        // a non-1 version. The gate must reject it.
563        let (bundle, _, _) = fixture();
564        let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
565        value["version"] = serde_json::Value::Number(serde_json::Number::from(2));
566        let tampered: AuditBundle = serde_json::from_value(value).expect(
567            "serde must deserialise an arbitrary u32; the format gate runs in verify(), not parse",
568        );
569        let err = verify(&tampered).expect_err("must reject v2 on disk");
570        assert!(matches!(err, BundleError::UnsupportedVersion(2)));
571    }
572
573    #[test]
574    fn unknown_bundle_type_string_is_rejected_by_serde_at_parse_time() {
575        // Belt-and-braces evidence that the bundle_type field is not a
576        // confusion vector. `BundleType` is a single-variant enum mapped
577        // to the literal `"sbo3l.audit_bundle.v1"`; serde refuses any
578        // other string before `verify()` is even called.
579        //
580        // The defensive `matches!` check inside `verify()` (covered by
581        // `verify_fails_when_bundle_type_is_unsupported_variant_in_future`
582        // would catch additions to the enum) is therefore unreachable
583        // through normal JSON paths today, but pins fail-closed semantics
584        // for the day a v2 variant is added.
585        let (bundle, _, _) = fixture();
586        let mut value: serde_json::Value = serde_json::to_value(&bundle).unwrap();
587        value["bundle_type"] = serde_json::Value::String("sbo3l.audit_bundle.v2".to_string());
588        let parse_err = serde_json::from_value::<AuditBundle>(value)
589            .expect_err("serde must reject an unknown bundle_type string before reaching verify()");
590        // The exact serde error message isn't part of the contract; we
591        // just assert that the parse fails so a v2 string never reaches
592        // the v1 verifier.
593        let msg = parse_err.to_string();
594        assert!(
595            msg.contains("bundle_type") || msg.contains("variant"),
596            "expected a serde enum-variant error mentioning bundle_type; got {msg}"
597        );
598    }
599}