Skip to main content

av_receipts/
receipt.rs

1//! The Receipt: an offline-verifiable, Ed25519-signed record of a session.
2//!
3//! Payload per the brief Module G: session id, agent identity block
4//! (version/charter/instance_uid), tool-call summary, cost, stop reason,
5//! event-chain hash, signature, signer public-key reference. Subjects are an
6//! enum so the same envelope covers signed-workflow chains and retroactive
7//! ATIF promotions (Module H reconciliation).
8//!
9//! Money is carried as integer micro-USD — floats never touch a signed field.
10
11use crate::jcs::canonicalize;
12use crate::keys::{KeyError, Keyring, Signer};
13use base64::Engine as _;
14use serde::{Deserialize, Serialize};
15
16/// Version of the receipt format itself (evolution surface).
17///
18/// Version history:
19/// * **1** — the signature covers the bare JCS canonicalization of the
20///   body. Legacy; still verifiable (see [`signing_message`]) but never
21///   issued anymore.
22/// * **2** — the signature covers a domain-separated message:
23///   [`RECEIPT_DOMAIN_TAG_V2`] + the u64 big-endian byte length of the
24///   JCS canonical body + the JCS canonical body bytes. This closes a
25///   cross-protocol confusion risk: the same Ed25519 key also
26///   feeds the journal-MAC KDF (`journal.rs`), and a signature over
27///   un-tagged bytes could in principle be replayed as a signature over
28///   some other protocol's message. Mirrors the framing style of
29///   `journal.rs` (`b"agentvisor-journal-v1\0"` + BE length prefixes).
30pub const RECEIPT_VERSION: u32 = 2;
31
32/// Domain-separation tag prepended to every version-2 receipt signing
33/// message. The trailing NUL makes the tag self-delimiting so no JCS
34/// body could extend it into a different tag.
35pub const RECEIPT_DOMAIN_TAG_V2: &[u8] = b"agentvisor-receipt-v2\0";
36
37/// Build the exact byte message the receipt signature covers for a
38/// given `receipt_version`, from the JCS canonical form of the body.
39///
40/// * version 1: the bare canonical bytes (legacy wire format);
41/// * version 2: `RECEIPT_DOMAIN_TAG_V2 || len(canonical) as u64 BE ||
42///   canonical` — domain-separated and length-framed, mirroring
43///   `journal.rs`;
44/// * any other version: refused with
45///   [`ReceiptError::SemanticInvariant`] (there is no defined framing,
46///   so neither signing nor verifying may proceed).
47///
48/// Public so external tooling and tests can reproduce the signing
49/// message byte-for-byte.
50pub fn signing_message(receipt_version: u32, canonical_body: &str) -> Result<Vec<u8>, ReceiptError> {
51    match receipt_version {
52        1 => Ok(canonical_body.as_bytes().to_vec()),
53        2 => {
54            let mut message = Vec::with_capacity(RECEIPT_DOMAIN_TAG_V2.len() + 8 + canonical_body.len());
55            message.extend_from_slice(RECEIPT_DOMAIN_TAG_V2);
56            message.extend_from_slice(&(canonical_body.len() as u64).to_be_bytes());
57            message.extend_from_slice(canonical_body.as_bytes());
58            Ok(message)
59        }
60        other => Err(ReceiptError::SemanticInvariant(format!(
61            "receipt_version {other} has no defined signature framing (supported: 1, 2)"
62        ))),
63    }
64}
65
66/// What this receipt attests.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
69#[non_exhaustive]
70pub enum ReceiptSubject {
71    /// A signed-workflow session: the OCSF event chain.
72    EventChain {
73        /// Head hash of the session event chain (hex).
74        chain_head: String,
75        /// Number of events in the chain.
76        event_count: u64,
77    },
78    /// An unsigned-workflow trajectory promoted retroactively (Module H).
79    AtifTrajectory {
80        /// SHA-256 of the exported trajectory file bytes (hex).
81        trajectory_digest: String,
82        /// Number of steps in the trajectory.
83        step_count: u64,
84        /// Always true for promotions; kept explicit for auditability.
85        retroactive: bool,
86    },
87}
88
89/// Aggregate tool-call statistics for the session.
90#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct ToolCallSummary {
93    /// Total tool calls observed.
94    pub total: u64,
95    /// Calls allowed by policy.
96    pub allowed: u64,
97    /// Calls blocked by policy/budget/schema.
98    pub blocked: u64,
99}
100
101/// Aggregate cost for the session (integers only — JCS-exact).
102#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct CostSummary {
105    /// Total prompt tokens.
106    pub prompt_tokens: u64,
107    /// Total completion tokens.
108    pub completion_tokens: u64,
109    /// Total provider-cached tokens.
110    pub cached_tokens: u64,
111    /// Cost in micro-USD (1_000_000 = $1).
112    pub cost_usd_micros: u64,
113}
114
115/// The signed body (everything except the signature itself).
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[serde(deny_unknown_fields)]
118pub struct ReceiptBody {
119    /// Receipt format version.
120    pub receipt_version: u32,
121    /// Unique receipt id (UUIDv7).
122    pub receipt_id: String,
123    /// Session this receipt covers.
124    pub session_id: String,
125    /// Issuance time, epoch ms.
126    pub issued_at: u64,
127    /// Issuance time, ISO-8601.
128    pub issued_at_iso: String,
129    /// Agent config-state identity block.
130    pub ai_agent: av_events::AgentIdentity,
131    /// What is attested.
132    pub subject: ReceiptSubject,
133    /// Tool-call summary.
134    pub tool_calls: ToolCallSummary,
135    /// Cost summary.
136    pub cost: CostSummary,
137    /// Final stop reason id.
138    pub stop_reason_id: u8,
139    /// Final stop reason caption.
140    pub stop_reason: String,
141    /// Signer key id.
142    pub key_id: String,
143    /// Signer public key, base64 (self-contained offline verification).
144    pub public_key_b64: String,
145}
146
147/// A complete receipt: body + detached signature over the version's
148/// signing message (see [`signing_message`] — bare `JCS(body)` for v1,
149/// domain-tagged and length-framed `JCS(body)` for v2).
150///
151/// The wire shape is intentionally flat (13 body fields + `signature_b64`
152/// at the top level) — the schemas at `schemas/receipt-v1.schema.json`
153/// and `schemas/receipt-v2.schema.json` commit to it (they differ only
154/// in the pinned `receipt_version`) and downstream verifiers
155/// (`avctl receipt-verify`, external tools) consume it that way.
156///
157/// `#[serde(flatten)]` normally silently disables `deny_unknown_fields`
158/// on the inner struct, which would let a hostile issuer add extra
159/// top-level fields ("claim_extra": "grant admin") that the human
160/// reviewer sees but `verify()` accepts (the field never survives the
161/// round-trip to `ReceiptBody` used inside `canonicalize`). To close
162/// that gap without breaking the wire shape, the [`Deserialize`] impl
163/// is written by hand and rejects any top-level key outside the
164/// declared whitelist. See the
165/// `allowed_receipt_top_level_keys_cover_body_fields_exactly`
166/// test for the compile-time-ish drift guard.
167#[derive(Debug, Clone, PartialEq, Serialize)]
168pub struct Receipt {
169    /// Signed body.
170    #[serde(flatten)]
171    pub body: ReceiptBody,
172    /// Ed25519 signature, base64. Covers the version's signing message:
173    /// bare `JCS(body)` for v1, [`RECEIPT_DOMAIN_TAG_V2`] + u64-BE
174    /// length + `JCS(body)` for v2.
175    pub signature_b64: String,
176}
177
178/// The 14 top-level keys a receipt is allowed to carry over the wire:
179/// the 13 fields of [`ReceiptBody`] plus [`Receipt::signature_b64`].
180///
181/// Kept in lockstep with [`ReceiptBody`] by
182/// `tests::allowed_receipt_top_level_keys_cover_body_fields_exactly`
183/// — adding a field to `ReceiptBody` without updating this list breaks
184/// that test at CI time.
185const ALLOWED_RECEIPT_TOP_LEVEL_KEYS: &[&str] = &[
186    "receipt_version",
187    "receipt_id",
188    "session_id",
189    "issued_at",
190    "issued_at_iso",
191    "ai_agent",
192    "subject",
193    "tool_calls",
194    "cost",
195    "stop_reason_id",
196    "stop_reason",
197    "key_id",
198    "public_key_b64",
199    "signature_b64",
200];
201
202impl<'de> Deserialize<'de> for Receipt {
203    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
204        use serde::de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor};
205
206        /// Recursively-checked JSON value: refuses duplicate keys and
207        /// explicit `null`s at EVERY depth while parsing. The top-level
208        /// duplicate guard below is not enough — `next_value::<Value>()`
209        /// collapses NESTED duplicates last-wins (serde_json), so a
210        /// hostile issuer could ship `"prompt_tokens":0,
211        /// "prompt_tokens":100` inside `cost`: the signature verifies
212        /// (canonicalization sees the collapsed value) while a
213        /// first-wins parser shows an auditor the other number — the
214        /// exact equivocation the top-level guard exists to refuse.
215        /// Explicit nulls are refused for the same display-vs-canonical
216        /// reason: every optional field is written with
217        /// `skip_serializing_if`, so a null can only be a planted
218        /// artifact that vanishes on reserialization.
219        /// (`Receipt::from_json_slice` enforces both at the bytes level;
220        /// this closes the plain `serde_json::from_*::<Receipt>` path.)
221        struct CheckedValue;
222        struct CheckedValueVisitor;
223        impl<'de> Visitor<'de> for CheckedValueVisitor {
224            type Value = serde_json::Value;
225            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226                f.write_str("a JSON value without duplicate keys or nulls")
227            }
228            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
229                Ok(serde_json::Value::Bool(v))
230            }
231            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
232                Ok(serde_json::Value::from(v))
233            }
234            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
235                Ok(serde_json::Value::from(v))
236            }
237            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
238                Ok(serde_json::Value::from(v))
239            }
240            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
241                Ok(serde_json::Value::String(v.to_owned()))
242            }
243            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
244                Ok(serde_json::Value::String(v))
245            }
246            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
247                Err(E::custom(
248                    "explicit null in Receipt; optional fields are omitted when absent, \
249                     so a null is a planted artifact that would vanish on reserialization",
250                ))
251            }
252            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
253                let mut items = Vec::new();
254                while let Some(item) = seq.next_element_seed(CheckedValue)? {
255                    items.push(item);
256                }
257                Ok(serde_json::Value::Array(items))
258            }
259            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
260                let mut object = serde_json::Map::new();
261                while let Some(key) = map.next_key::<String>()? {
262                    if object.contains_key(&key) {
263                        return Err(A::Error::custom(format!(
264                            "duplicate field `{key}` nested in Receipt; parsers disagree on \
265                             duplicate-key semantics, so accepting one would let a hostile \
266                             issuer sign one interpretation while auditors read the other"
267                        )));
268                    }
269                    let value = map.next_value_seed(CheckedValue)?;
270                    object.insert(key, value);
271                }
272                Ok(serde_json::Value::Object(object))
273            }
274        }
275        impl<'de> DeserializeSeed<'de> for CheckedValue {
276            type Value = serde_json::Value;
277            fn deserialize<D2: serde::Deserializer<'de>>(
278                self,
279                deserializer: D2,
280            ) -> Result<Self::Value, D2::Error> {
281                deserializer.deserialize_any(CheckedValueVisitor)
282            }
283        }
284
285        // Custom visitor so we can reject duplicate keys explicitly.
286        // `serde_json::Map::deserialize` silently collapses duplicate
287        // keys (last-wins) — RFC 8259 leaves the behaviour undefined
288        // and other JSON parsers (jq's `--sort-keys`, some strict
289        // Python configs, several audit tools) pick first-wins.
290        // A hostile issuer could sign under the last-wins interpretation
291        // while an auditor reading with a first-wins parser saw the
292        // friendly value; the signature would verify but the audit
293        // would show a different receipt.
294        struct ReceiptVisitor;
295        impl<'de> Visitor<'de> for ReceiptVisitor {
296            type Value = Receipt;
297            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298                f.write_str("an AgentVisor AI Receipt JSON object")
299            }
300            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Receipt, M::Error> {
301                let mut raw = serde_json::Map::new();
302                while let Some(key) = map.next_key::<String>()? {
303                    if !ALLOWED_RECEIPT_TOP_LEVEL_KEYS.contains(&key.as_str()) {
304                        return Err(M::Error::custom(format!(
305                            "unknown field `{key}` in Receipt; a signed receipt \
306                             may only carry the fields declared in ReceiptBody + \
307                             signature_b64. Extra fields would be visible to a raw \
308                             JSON reader but not covered by the signature — treat \
309                             as tampering."
310                        )));
311                    }
312                    if raw.contains_key(&key) {
313                        return Err(M::Error::custom(format!(
314                            "duplicate field `{key}` in Receipt; JSON parsers \
315                             disagree on duplicate-key semantics (last-wins vs \
316                             first-wins), so accepting duplicates would let a \
317                             hostile issuer sign under one interpretation while an \
318                             auditor's parser reports the other"
319                        )));
320                    }
321                    let value = map.next_value_seed(CheckedValue)?;
322                    raw.insert(key, value);
323                }
324                let signature_value = raw
325                    .remove("signature_b64")
326                    .ok_or_else(|| M::Error::missing_field("signature_b64"))?;
327                let signature_b64: String =
328                    serde_json::from_value(signature_value).map_err(M::Error::custom)?;
329                let body: ReceiptBody =
330                    serde_json::from_value(serde_json::Value::Object(raw)).map_err(M::Error::custom)?;
331                Ok(Receipt { body, signature_b64 })
332            }
333        }
334        deserializer.deserialize_map(ReceiptVisitor)
335    }
336}
337
338/// Receipt errors.
339#[derive(Debug, thiserror::Error)]
340#[non_exhaustive]
341pub enum ReceiptError {
342    /// Canonicalization failed (unsafe numbers in body).
343    #[error("canonicalization: {0}")]
344    Jcs(#[from] crate::jcs::JcsError),
345    /// Serialization failed.
346    #[error("serialization: {0}")]
347    Serde(#[from] serde_json::Error),
348    /// Key/signature failure.
349    #[error("key: {0}")]
350    Key(#[from] KeyError),
351    /// Signature or public key not valid base64.
352    #[error("invalid base64 in receipt")]
353    Base64,
354    /// The receipt's embedded public key does not match the keyring entry for
355    /// its key id (substitution attempt).
356    #[error("embedded public key mismatches keyring entry for {0:?}")]
357    KeyMismatch(String),
358    /// A JSON object at some nesting level carried a
359    /// duplicate key. Rejecting these closes the last-wins vs
360    /// first-wins auditor split-brain that would otherwise let a
361    /// hostile issuer sign under one interpretation while an
362    /// external audit tool displayed the other.
363    ///
364    /// The payload is a complete scanner message (duplicate key,
365    /// nesting-depth, or explicit-null refusal), so the template
366    /// must not re-wrap it in a `key`-shaped slot — a
367    /// "duplicate JSON key `{0}` at nesting" template produced garbled
368    /// double-wrapped diagnostics like ``duplicate JSON key
369    /// `duplicate key `x` in JSON object …` at nesting``.
370    #[error("{0}; strict receipt parsers refuse this")]
371    DuplicateKey(String),
372    /// A receipt carries a semantically-invalid field.
373    /// Enforced by `verify_semantic_invariants`: supported
374    /// `receipt_version` ∈ {1, 2}, `ai_agent.charter.type_id == 1`
375    /// (OCSF Regular File), non-empty required string fields
376    /// (`receipt_id`, `session_id`, `ai_agent.charter.name`,
377    /// `ai_agent.charter.instance_uid`), 64-hex `chain_head` and
378    /// per-step `trajectory_digest`, `issued_at_iso ==
379    /// iso8601_ms(issued_at)`, stop-reason ↔ stop-reason-id
380    /// cross-wiring, and `AtifTrajectory.retroactive == true`.
381    /// Distinct from `Serde` and `DuplicateKey` so ops triage
382    /// can name the class.
383    #[error("semantic invariant violated: {0}")]
384    SemanticInvariant(String),
385}
386
387impl Receipt {
388    /// Deserialize a receipt from wire bytes, rejecting any duplicate
389    /// key at ANY nesting level.
390    ///
391    /// The custom `Deserialize` impl for `Receipt` (below) already
392    /// rejects duplicate top-level keys, but nested structs
393    /// (`ai_agent`, `tool_calls`, `cost`, `subject`) went through
394    /// serde's default derive which relies on `serde_json::Map` /
395    /// `IndexMap` — both silently collapse duplicate keys with
396    /// last-wins semantics. A hostile issuer could then sign a
397    /// receipt whose `ai_agent.instance_uid` appeared twice
398    /// (`"a"` then `"b"`); JCS canonicalisation saw only `"b"` so
399    /// the signature verified, but a first-wins auditor tool (jq's
400    /// default, some Python configs) displayed `"a"` — the exact
401    /// split-brain the top-level guard was written to prevent, one
402    /// level deeper.
403    ///
404    /// Pre-scans the JSON with a strict duplicate-key
405    /// checker (`check_no_duplicate_keys`) before deserialising into
406    /// `Receipt`. Callers verifying a receipt off the wire should
407    /// prefer this over `serde_json::from_slice::<Receipt>`.
408    pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ReceiptError> {
409        check_no_duplicate_keys(bytes)?;
410        serde_json::from_slice(bytes).map_err(ReceiptError::Serde)
411    }
412
413    /// Same as [`Receipt::from_json_slice`] for owned/borrowed strings.
414    pub fn from_json_str(s: &str) -> Result<Self, ReceiptError> {
415        Self::from_json_slice(s.as_bytes())
416    }
417
418    /// Issue (sign) a receipt over `body` with `signer`.
419    ///
420    /// The `key_id` and `public_key_b64` fields of the body are overwritten
421    /// from the signer — a caller can never claim someone else's key identity.
422    /// `receipt_version` is likewise overwritten to [`RECEIPT_VERSION`]:
423    /// every freshly issued receipt uses the current (domain-separated)
424    /// signing format, so no issuance path can produce a legacy v1
425    /// signature.
426    pub fn issue(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
427        body.receipt_version = RECEIPT_VERSION;
428        body.key_id = signer.key_id().to_owned();
429        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
430        let canon = canonicalize(&serde_json::to_value(&body)?)?;
431        let message = signing_message(body.receipt_version, &canon)?;
432        let sig = signer.sign(&message);
433        let receipt = Self {
434            body,
435            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
436        };
437        // Never mint a receipt this crate's own verifier refuses:
438        // issuance used to sign ANY canonicalizable body (empty
439        // session_id, retroactive:false trajectory subjects,
440        // inconsistent timestamps, allowed+blocked > total) and the
441        // dead receipt only surfaced when a verifier finally ran —
442        // long after the session evidence that could have re-minted
443        // it was gone. verify_embedded runs the full semantic
444        // invariant set plus the signature round-trip (~one Ed25519
445        // verify per session close — noise next to the sign itself).
446        receipt.verify_embedded()?;
447        Ok(receipt)
448    }
449
450    /// Sign WITHOUT the issue-time validation — test-only, for pinning
451    /// that `verify` refuses hostile-keyholder receipts that `issue`
452    /// now refuses to mint in the first place.
453    #[cfg(test)]
454    pub(crate) fn issue_unchecked(mut body: ReceiptBody, signer: &dyn Signer) -> Result<Self, ReceiptError> {
455        body.receipt_version = RECEIPT_VERSION;
456        body.key_id = signer.key_id().to_owned();
457        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
458        let canon = canonicalize(&serde_json::to_value(&body)?)?;
459        let message = signing_message(body.receipt_version, &canon)?;
460        let sig = signer.sign(&message);
461        Ok(Self {
462            body,
463            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
464        })
465    }
466
467    /// Verify offline against a keyring. Checks, in execution order:
468    /// 1. `AtifTrajectory.retroactive == true` (
469    ///    `retroactive: false` on an ATIF-promoted receipt is
470    ///    semantically nonsense; the schema pins it to `const: true`
471    ///    but the Rust type still accepts `false`; refuse it here so
472    ///    the two agree);
473    /// 2. the embedded public key matches the ring's key for `key_id`
474    ///    (anti-substitution);
475    /// 3. the signature verifies over the version's signing message
476    ///    (bare `JCS(body)` for v1; domain-tagged, length-framed
477    ///    `JCS(body)` for v2 — see [`signing_message`]).
478    pub fn verify(&self, ring: &Keyring) -> Result<(), ReceiptError> {
479        self.verify_semantic_invariants()?;
480        let embedded = base64::engine::general_purpose::STANDARD
481            .decode(&self.body.public_key_b64)
482            .map_err(|_| ReceiptError::Base64)?;
483        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
484        // Re-derive the ring id for the embedded key; it must equal the stated
485        // key id AND exist in the ring with the same bytes.
486        let mut probe = Keyring::new();
487        let derived_id = probe.add_key_bytes(&embedded)?;
488        if derived_id != self.body.key_id {
489            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
490        }
491        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
492        let message = signing_message(self.body.receipt_version, &canon)?;
493        let sig = base64::engine::general_purpose::STANDARD
494            .decode(&self.signature_b64)
495            .map_err(|_| ReceiptError::Base64)?;
496        ring.verify(&self.body.key_id, &message, &sig)?;
497        Ok(())
498    }
499
500    /// **SECURITY WARNING — proves internal consistency, NOT authenticity.**
501    ///
502    /// Verifies the signature under the receipt's OWN embedded
503    /// `public_key_b64`. An attacker who generates a fresh Ed25519 keypair
504    /// and signs an arbitrary body with it will pass this check. The
505    /// result is equivalent to "this JSON blob is syntactically a
506    /// well-formed receipt whose signature is consistent with the key it
507    /// embeds"; it says nothing about whether the KEY is one the verifier
508    /// should trust.
509    ///
510    /// This method is safe ONLY when:
511    ///   1. The receipt was obtained over an authenticated channel that
512    ///      binds it to a trusted origin (e.g. a mutually-authenticated
513    ///      TLS connection to a known signer), **and/or**
514    ///   2. The caller separately pins `receipt.body.key_id` against a
515    ///      known-good allowlist and rejects any mismatch BEFORE reading
516    ///      any receipt content.
517    ///
518    /// For general receipt verification, use [`Receipt::verify`] with a
519    /// [`Keyring`] pre-populated with trusted keys. The browser `/verify`
520    /// page enforces the same discipline via `TRUSTED_RECEIPT_KEYS`
521    /// (docs/verify/verify.js) and shows an "internally consistent — trust
522    /// anchor NOT verified" warning without a pinned key; downstream Rust
523    /// callers must maintain the same distinction.
524    pub fn verify_embedded(&self) -> Result<(), ReceiptError> {
525        self.verify_semantic_invariants()?;
526        let embedded = base64::engine::general_purpose::STANDARD
527            .decode(&self.body.public_key_b64)
528            .map_err(|_| ReceiptError::Base64)?;
529        let embedded: [u8; 32] = embedded.try_into().map_err(|_| ReceiptError::Base64)?;
530        let mut ring = Keyring::new();
531        let id = ring.add_key_bytes(&embedded)?;
532        if id != self.body.key_id {
533            return Err(ReceiptError::KeyMismatch(self.body.key_id.clone()));
534        }
535        let canon = canonicalize(&serde_json::to_value(&self.body)?)?;
536        let message = signing_message(self.body.receipt_version, &canon)?;
537        let sig = base64::engine::general_purpose::STANDARD
538            .decode(&self.signature_b64)
539            .map_err(|_| ReceiptError::Base64)?;
540        ring.verify(&id, &message, &sig)?;
541        Ok(())
542    }
543
544    /// Check semantic invariants that the type system
545    /// cannot express. Currently enforces: supported
546    /// `receipt_version` ∈ {1, 2}, `ai_agent.charter.type_id == 1`
547    /// (OCSF Regular File), non-empty required string fields
548    /// (`receipt_id`, `session_id`, `ai_agent.charter.name`,
549    /// `ai_agent.charter.instance_uid`), 64-hex `chain_head` and
550    /// per-step `trajectory_digest`, `issued_at_iso ==
551    /// iso8601_ms(issued_at)`, stop-reason ↔ stop-reason-id
552    /// cross-wiring, and `AtifTrajectory.retroactive == true`
553    /// (schema pins it via `const: true`; the Rust type is `bool`
554    /// for auditability). Called from both `verify` and
555    /// `verify_embedded` so no verification path can skip it.
556    fn verify_semantic_invariants(&self) -> Result<(), ReceiptError> {
557        // Schema parity: `receipt_version` is pinned per-version in the
558        // shipped schemas (`const: 1` in receipt-v1, `const: 2` in
559        // receipt-v2) and the subject digests are pinned
560        // `^[0-9a-f]{64}$`, but verification never checked either — a
561        // keyholder could issue `receipt_version: 999` or free-text
562        // digests that `avctl receipt-verify` accepted while
563        // schema-based external verifiers rejected (split
564        // offline-verification verdicts). Versions 1 (bare-JCS
565        // signature) and 2 (domain-separated signature) are the only
566        // ones with a defined signing message; anything else is
567        // refused before a signature check could misinterpret it.
568        if !matches!(self.body.receipt_version, 1 | 2) {
569            return Err(ReceiptError::SemanticInvariant(format!(
570                "receipt_version {} is not a supported version (1 or 2)",
571                self.body.receipt_version
572            )));
573        }
574        // Schema parity: the receipt schema (and the events validator)
575        // pin `ai_agent.charter.type_id` to the OCSF Regular File
576        // const 1; in-tree issuers always set it, so this only rejects
577        // inbound documents that external schema verifiers already
578        // refuse — keeping the two verification paths in agreement.
579        if self.body.ai_agent.charter.type_id != 1 {
580            return Err(ReceiptError::SemanticInvariant(format!(
581                "ai_agent.charter.type_id {} is not Regular File (1)",
582                self.body.ai_agent.charter.type_id
583            )));
584        }
585        // Schema parity: the shipped schema pins `minLength: 1` on these
586        // string fields. In-tree issuers always populate them, so this
587        // only rejects documents that external schema-based verifiers
588        // already refuse — without it, a keyholder could sign a receipt
589        // with an empty session_id/instance_uid that `avctl
590        // receipt-verify` accepted while schema verifiers rejected
591        // (split offline-verification verdicts, same class as the
592        // receipt_version / issued_at_iso checks around this one).
593        for (field, value) in [
594            ("receipt_id", self.body.receipt_id.as_str()),
595            ("session_id", self.body.session_id.as_str()),
596            ("ai_agent.version", self.body.ai_agent.version.as_str()),
597            ("ai_agent.charter.name", self.body.ai_agent.charter.name.as_str()),
598            ("ai_agent.instance_uid", self.body.ai_agent.instance_uid.as_str()),
599        ] {
600            if value.is_empty() {
601                return Err(ReceiptError::SemanticInvariant(format!(
602                    "{field} is empty (schema requires minLength 1)"
603                )));
604            }
605        }
606        // Schema parity, upper bound: the shipped schemas pin
607        // `maxLength: 128` (code points) on the identifier fields, and
608        // RFC 3339 `date-time` cannot express years past 9999. A
609        // keyholder could otherwise sign a 10-KB session_id or a
610        // year-10000 timestamp that this verifier accepted while every
611        // schema-based verifier refused — the same split-verdict class
612        // as the emptiness checks above, in the other direction.
613        for (field, value) in [
614            ("receipt_id", self.body.receipt_id.as_str()),
615            ("session_id", self.body.session_id.as_str()),
616            ("ai_agent.version", self.body.ai_agent.version.as_str()),
617            ("ai_agent.charter.name", self.body.ai_agent.charter.name.as_str()),
618            ("ai_agent.instance_uid", self.body.ai_agent.instance_uid.as_str()),
619        ] {
620            if value.chars().count() > 128 {
621                return Err(ReceiptError::SemanticInvariant(format!(
622                    "{field} exceeds 128 characters (schema maxLength)"
623                )));
624            }
625        }
626        // 9999-12-31T23:59:59.999Z in epoch ms — the last instant RFC
627        // 3339 date-time (and iso8601_ms's 24-char shape) can carry.
628        const MAX_ISSUED_AT_MS: u64 = 253_402_300_799_999;
629        if self.body.issued_at > MAX_ISSUED_AT_MS {
630            return Err(ReceiptError::SemanticInvariant(format!(
631                "issued_at {} is beyond year 9999 (RFC 3339 date-time cannot express it)",
632                self.body.issued_at
633            )));
634        }
635        let digest_ok = |digest: &str| {
636            digest.len() == 64
637                && digest
638                    .bytes()
639                    .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
640        };
641        match &self.body.subject {
642            ReceiptSubject::EventChain { chain_head, .. } => {
643                if !digest_ok(chain_head) {
644                    return Err(ReceiptError::SemanticInvariant(
645                        "chain_head is not a lowercase 64-hex digest".to_owned(),
646                    ));
647                }
648            }
649            ReceiptSubject::AtifTrajectory {
650                trajectory_digest, ..
651            } => {
652                if !digest_ok(trajectory_digest) {
653                    return Err(ReceiptError::SemanticInvariant(
654                        "trajectory_digest is not a lowercase 64-hex digest".to_owned(),
655                    ));
656                }
657            }
658        }
659        if let ReceiptSubject::AtifTrajectory { retroactive, .. } = &self.body.subject {
660            if !retroactive {
661                return Err(ReceiptError::SemanticInvariant(
662                    "AtifTrajectory.retroactive must be true (this receipt attests a retroactive promotion)"
663                        .to_owned(),
664                ));
665            }
666        }
667        // Cross-check `issued_at_iso` against the
668        // authoritative epoch-ms `issued_at`. Checking
669        // only shapes plus the retroactive invariant would let a
670        // hostile-but-keyholding issuer sign a receipt whose ISO
671        // caption and epoch-ms disagreed — auditors reading ISO and
672        // tooling reading ms would see different facts from the same
673        // verified receipt. Derive the ISO deterministically and
674        // require equality.
675        let expected_iso = av_core::time::iso8601_ms(self.body.issued_at);
676        if self.body.issued_at_iso != expected_iso {
677            return Err(ReceiptError::SemanticInvariant(format!(
678                "issued_at_iso {:?} does not match issued_at ({}); expected {:?}",
679                self.body.issued_at_iso, self.body.issued_at, expected_iso
680            )));
681        }
682        // Cross-check the stop-reason dual representation — the same
683        // split-brain class as issued_at/issued_at_iso above, but with
684        // the same tolerance the events validator uses:
685        // `stop_reason` may legitimately carry
686        // a provider-native or free-text caption, so equality with the
687        // canonical caption cannot be required. Refuse only the
688        // unambiguous cross-wiring where the caption IS the canonical
689        // caption of a DIFFERENT known variant than `stop_reason_id`
690        // names (e.g. id 92 "Budget Exceeded" paired with caption
691        // "Stop") — the two signed fields then assert contradictory
692        // outcomes to auditors reading the caption vs tooling reading
693        // the id.
694        let expected_caption = av_events::StopReason::from_id(self.body.stop_reason_id).caption();
695        let id_is_known = matches!(self.body.stop_reason_id, 0..=4 | 90..=94 | 99);
696        let caption_is_canonical_of_other_variant = id_is_known
697            && self.body.stop_reason != expected_caption
698            && [
699                av_events::StopReason::Unknown,
700                av_events::StopReason::Stop,
701                av_events::StopReason::MaxTokens,
702                av_events::StopReason::ToolUse,
703                av_events::StopReason::SessionClosed,
704                av_events::StopReason::ContentFilter,
705                av_events::StopReason::LoopDetected,
706                av_events::StopReason::BudgetExceeded,
707                av_events::StopReason::PolicyBlocked,
708                av_events::StopReason::IdentityRejected,
709                av_events::StopReason::Other,
710            ]
711            .iter()
712            .any(|variant| variant.caption() == self.body.stop_reason);
713        if caption_is_canonical_of_other_variant {
714            return Err(ReceiptError::SemanticInvariant(format!(
715                "stop_reason {:?} is the canonical caption of a different variant than stop_reason_id ({}); expected {:?} or a non-canonical native caption",
716                self.body.stop_reason, self.body.stop_reason_id, expected_caption
717            )));
718        }
719        // Tool-accounting invariant: classified outcomes can never
720        // exceed observed calls. The issuer maintains this by
721        // construction and journal recovery hard-fails on its violation
722        // (`RecoveredTotals::validate_tool_accounting`), but
723        // verification never checked it — a keyholding issuer could
724        // sign `total: 1, allowed: 1_000_000` and every offline
725        // verifier would attest arithmetic the pipeline itself refuses
726        // to recover. Same cross-field class as issued_at/issued_at_iso
727        // above (the JSON Schema cannot express cross-field sums).
728        let classified = self
729            .body
730            .tool_calls
731            .allowed
732            .checked_add(self.body.tool_calls.blocked);
733        if classified.is_none_or(|classified| classified > self.body.tool_calls.total) {
734            return Err(ReceiptError::SemanticInvariant(format!(
735                "tool_calls allowed ({}) + blocked ({}) exceeds total ({})",
736                self.body.tool_calls.allowed, self.body.tool_calls.blocked, self.body.tool_calls.total
737            )));
738        }
739        Ok(())
740    }
741}
742
743/// Walk a JSON document and reject any object whose keys contain a
744/// duplicate at ANY nesting level.
745///
746/// Used by [`Receipt::from_json_slice`] as a strict pre-parse gate.
747/// serde_json's default `Value` / `Map` uses `IndexMap` (with
748/// `preserve_order`), which silently keeps only the LAST value for a
749/// duplicate key at deserialize time — collapsing evidence that a
750/// hostile issuer signed a document with two spellings of the same
751/// key and a first-wins auditor would see the earlier one. This
752/// walk runs upfront so the collapse never happens.
753///
754/// Also enforce a hard depth cap so a hostile issuer
755/// cannot use a deeply-nested `[[[[…]]]]` payload to blow the walker's
756/// stack. serde_json's own limit is 128 by default, but the cost of
757/// a per-level check is a single integer increment.
758const MAX_NESTED_DEPTH: usize = 128;
759
760/// Sentinel prefix that lets `check_no_duplicate_keys` distinguish a
761/// duplicate-key rejection (mapped to `ReceiptError::DuplicateKey`)
762/// from a general parse failure (mapped to `ReceiptError::Serde`).
763/// A blanket `.map_err(DuplicateKey)` would fold
764/// serde_json's own EOF / recursion / expected-value errors into the
765/// DuplicateKey variant, actively misleading ops.
766const DUP_KEY_SENTINEL: &str = "__av_dup:";
767
768fn check_no_duplicate_keys(bytes: &[u8]) -> Result<(), ReceiptError> {
769    use serde::Deserializer as _;
770    let mut deser = serde_json::Deserializer::from_slice(bytes);
771    match deser.deserialize_any(NoDupVisitor { depth: 0 }) {
772        Ok(()) => Ok(()),
773        Err(error) => {
774            let msg = error.to_string();
775            if let Some(rest) = msg.strip_prefix(DUP_KEY_SENTINEL) {
776                Err(ReceiptError::DuplicateKey(rest.to_owned()))
777            } else if msg.contains("recursion limit exceeded") {
778                // Preserve the intent of the depth check even when
779                // serde_json fires first, so callers still see a
780                // DuplicateKey-family error class for hostile
781                // deeply-nested input.
782                Err(ReceiptError::DuplicateKey(msg))
783            } else {
784                // Genuine parse error (EOF, expected value, invalid
785                // number). Bubble the underlying serde_json::Error
786                // via Serde so tracing sites don't misattribute.
787                Err(ReceiptError::Serde(error))
788            }
789        }
790    }
791}
792
793struct NoDupVisitor {
794    depth: usize,
795}
796
797impl<'de> serde::de::Visitor<'de> for NoDupVisitor {
798    type Value = ();
799    fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
800        f.write_str("any JSON value (strict-mode duplicate-key check)")
801    }
802    fn visit_bool<E>(self, _: bool) -> Result<(), E> {
803        Ok(())
804    }
805    fn visit_i64<E>(self, _: i64) -> Result<(), E> {
806        Ok(())
807    }
808    fn visit_u64<E>(self, _: u64) -> Result<(), E> {
809        Ok(())
810    }
811    fn visit_f64<E>(self, _: f64) -> Result<(), E> {
812        Ok(())
813    }
814    fn visit_i128<E>(self, _: i128) -> Result<(), E> {
815        Ok(())
816    }
817    fn visit_u128<E>(self, _: u128) -> Result<(), E> {
818        Ok(())
819    }
820    fn visit_str<E>(self, _: &str) -> Result<(), E> {
821        Ok(())
822    }
823    fn visit_string<E>(self, _: String) -> Result<(), E> {
824        Ok(())
825    }
826    /// Serde may invoke `visit_none` for `Option` layers that are
827    /// already Nothing; `deserialize_any` on JSON never routes there
828    /// (JSON null goes through `visit_unit`), so this remains a
829    /// no-op fallback that only fires if a future caller layers a
830    /// typed deserializer on top.
831    fn visit_none<E>(self) -> Result<(), E> {
832        Ok(())
833    }
834
835    /// Reject explicit JSON `null` anywhere
836    /// in the receipt wire form. Every optional field on `ReceiptBody`
837    /// / `AgentIdentity` / `EventMetrics` / … carries
838    /// `#[serde(skip_serializing_if = "Option::is_none")]`, so
839    /// `Option::None` is emitted as *field absence*. But
840    /// `serde_json::from_slice` also accepts `"field": null` for those
841    /// same Options — and `Receipt::verify` re-canonicalises via
842    /// `serde_json::to_value(&self.body)`, which drops the `None`
843    /// again. The consequence: an intermediary can flip `"…": null`
844    /// on/off in the raw bytes without invalidating the signature,
845    /// violating the "same signature ⇔ same bytes" auditor
846    /// invariant. Refuse explicit null across the whole payload
847    /// (receipts contain no legitimate JSON null anywhere — top level
848    /// is an object; every field is either a value type, a container
849    /// with omit-on-none, or a bounded set-of-strings enum).
850    fn visit_unit<E: serde::de::Error>(self) -> Result<(), E> {
851        Err(E::custom(format!(
852            "{DUP_KEY_SENTINEL}explicit JSON null is not permitted (a signed absent-Option \
853             cannot be re-serialised as null without breaking canonicalisation invariance)"
854        )))
855    }
856    fn visit_seq<S: serde::de::SeqAccess<'de>>(self, mut seq: S) -> Result<(), S::Error> {
857        use serde::de::Error as _;
858        let next_depth = self.depth.saturating_add(1);
859        if next_depth > MAX_NESTED_DEPTH {
860            return Err(S::Error::custom(format!(
861                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
862            )));
863        }
864        while seq.next_element_seed(NoDupSeed { depth: next_depth })?.is_some() {}
865        Ok(())
866    }
867    fn visit_map<M: serde::de::MapAccess<'de>>(self, mut map: M) -> Result<(), M::Error> {
868        use serde::de::Error as _;
869        let next_depth = self.depth.saturating_add(1);
870        if next_depth > MAX_NESTED_DEPTH {
871            return Err(M::Error::custom(format!(
872                "{DUP_KEY_SENTINEL}JSON nesting exceeds {MAX_NESTED_DEPTH}"
873            )));
874        }
875        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
876        while let Some(key) = map.next_key::<String>()? {
877            if !seen.insert(key.clone()) {
878                // Escape the key so a hostile spelling
879                // containing `\n` or ANSI escapes cannot inject a
880                // fake extra log line at every tracing site that
881                // logs the resulting error via `%error`.
882                return Err(M::Error::custom(format!(
883                    "{DUP_KEY_SENTINEL}duplicate key `{}` in JSON object",
884                    key.escape_debug()
885                )));
886            }
887            map.next_value_seed(NoDupSeed { depth: next_depth })?;
888        }
889        Ok(())
890    }
891}
892
893struct NoDupSeed {
894    depth: usize,
895}
896
897impl<'de> serde::de::DeserializeSeed<'de> for NoDupSeed {
898    type Value = ();
899    fn deserialize<D: serde::Deserializer<'de>>(self, deser: D) -> Result<(), D::Error> {
900        deser.deserialize_any(NoDupVisitor { depth: self.depth })
901    }
902}
903
904/// Convenience constructor filling issuance time and ids.
905#[allow(clippy::too_many_arguments)]
906pub fn new_body(
907    session_id: String,
908    ai_agent: av_events::AgentIdentity,
909    subject: ReceiptSubject,
910    tool_calls: ToolCallSummary,
911    cost: CostSummary,
912    stop_reason: av_events::StopReason,
913) -> ReceiptBody {
914    let now = av_core::time::now_ms();
915    ReceiptBody {
916        receipt_version: RECEIPT_VERSION,
917        receipt_id: av_core::new_event_uid(),
918        session_id,
919        issued_at: now,
920        issued_at_iso: av_core::time::iso8601_ms(now),
921        ai_agent,
922        subject,
923        tool_calls,
924        cost,
925        stop_reason_id: stop_reason.id(),
926        stop_reason: stop_reason.caption().to_owned(),
927        key_id: String::new(),         // filled by issue()
928        public_key_b64: String::new(), // filled by issue()
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    #![allow(
935        clippy::unwrap_used,
936        clippy::expect_used,
937        clippy::panic,
938        clippy::indexing_slicing
939    )]
940
941    use super::*;
942    use crate::keys::Ed25519Signer;
943
944    fn body() -> ReceiptBody {
945        new_body(
946            "sess-77".into(),
947            av_events::AgentIdentity {
948                version: "2.0.1".into(),
949                charter: "payments".into(),
950                instance_uid: "inst-9".into(),
951                ttl_remaining_s: None,
952            },
953            ReceiptSubject::EventChain {
954                chain_head: "ab".repeat(32),
955                event_count: 41,
956            },
957            ToolCallSummary {
958                total: 12,
959                allowed: 10,
960                blocked: 2,
961            },
962            CostSummary {
963                prompt_tokens: 52_000,
964                completion_tokens: 9_000,
965                cached_tokens: 30_000,
966                cost_usd_micros: 137_500,
967            },
968            av_events::StopReason::SessionClosed,
969        )
970    }
971
972    #[test]
973    fn issue_verify_roundtrip() {
974        let signer = Ed25519Signer::generate();
975        let mut ring = Keyring::new();
976        ring.add_signer(&signer).unwrap();
977        let receipt = Receipt::issue(body(), &signer).unwrap();
978        assert_eq!(
979            receipt.body.receipt_version, 2,
980            "freshly issued receipts must use the domain-separated v2 format"
981        );
982        receipt.verify(&ring).unwrap();
983        receipt.verify_embedded().unwrap();
984    }
985
986    /// `issue` must overwrite a caller-supplied `receipt_version` the
987    /// same way it overwrites `key_id`/`public_key_b64` — otherwise a
988    /// caller passing a stale body could still mint bare-JCS (v1)
989    /// signatures, reopening the cross-protocol confusion the v2
990    /// domain tag closes.
991    #[test]
992    fn issue_overwrites_caller_supplied_receipt_version() {
993        let signer = Ed25519Signer::generate();
994        let mut b = body();
995        b.receipt_version = 1;
996        let receipt = Receipt::issue(b, &signer).unwrap();
997        assert_eq!(receipt.body.receipt_version, RECEIPT_VERSION);
998        receipt.verify_embedded().unwrap();
999    }
1000
1001    /// Sign `body` under the LEGACY v1 wire format: `receipt_version: 1`
1002    /// and a signature over the bare JCS canonical bytes (no domain tag,
1003    /// no length framing). This is what every pre-v2 deployment wrote
1004    /// to disk; the verifier must keep accepting it.
1005    fn issue_v1(mut body: ReceiptBody, signer: &Ed25519Signer) -> Receipt {
1006        body.receipt_version = 1;
1007        body.key_id = signer.key_id().to_owned();
1008        body.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
1009        let canon = canonicalize(&serde_json::to_value(&body).unwrap()).unwrap();
1010        let sig = signer.sign(canon.as_bytes());
1011        Receipt {
1012            body,
1013            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
1014        }
1015    }
1016
1017    /// Migration window: a v1 receipt (bare-JCS signature) issued by an
1018    /// old deployment must still verify on both paths.
1019    #[test]
1020    fn legacy_v1_receipt_still_verifies_on_both_paths() {
1021        let signer = Ed25519Signer::generate();
1022        let mut ring = Keyring::new();
1023        ring.add_signer(&signer).unwrap();
1024        let receipt = issue_v1(body(), &signer);
1025        assert_eq!(receipt.body.receipt_version, 1);
1026        receipt.verify(&ring).unwrap();
1027        receipt.verify_embedded().unwrap();
1028    }
1029
1030    /// Cross-version confusion: a receipt labeled `receipt_version:
1031    /// 2` whose signature was computed WITHOUT the domain prefix (i.e. a
1032    /// v1-style bare-JCS signature) must be refused as `BadSignature` —
1033    /// the domain tag is not decorative, it is signature-covered.
1034    #[test]
1035    fn v2_receipt_with_bare_jcs_signature_is_refused() {
1036        let signer = Ed25519Signer::generate();
1037        let mut ring = Keyring::new();
1038        ring.add_signer(&signer).unwrap();
1039        let mut b = body();
1040        b.receipt_version = 2;
1041        b.key_id = signer.key_id().to_owned();
1042        b.public_key_b64 = base64::engine::general_purpose::STANDARD.encode(signer.public_key_bytes());
1043        let canon = canonicalize(&serde_json::to_value(&b).unwrap()).unwrap();
1044        // Bare-JCS signature (no tag, no length prefix) over a body
1045        // that CLAIMS the domain-separated format.
1046        let sig = signer.sign(canon.as_bytes());
1047        let confused = Receipt {
1048            body: b,
1049            signature_b64: base64::engine::general_purpose::STANDARD.encode(sig),
1050        };
1051        assert!(
1052            matches!(
1053                confused.verify(&ring),
1054                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1055            ),
1056            "v2 receipt with an un-tagged signature must fail as BadSignature"
1057        );
1058        assert!(
1059            matches!(
1060                confused.verify_embedded(),
1061                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1062            ),
1063            "embedded path must refuse the un-tagged v2 signature too"
1064        );
1065    }
1066
1067    /// Relabeling both ways: a body honestly signed under one
1068    /// version must not verify when its `receipt_version` label claims
1069    /// the other version. The version field is inside the signed body,
1070    /// so relabeling changes the canonical bytes AND selects a
1071    /// different signing message — both paths must land in
1072    /// `BadSignature`, never in silent acceptance.
1073    #[test]
1074    fn version_relabeling_is_refused_in_both_directions() {
1075        let signer = Ed25519Signer::generate();
1076        let mut ring = Keyring::new();
1077        ring.add_signer(&signer).unwrap();
1078
1079        // v1-signed body relabeled as v2.
1080        let mut up = issue_v1(body(), &signer);
1081        up.body.receipt_version = 2;
1082        assert!(
1083            matches!(
1084                up.verify(&ring),
1085                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1086            ),
1087            "v1 signature relabeled as v2 must fail as BadSignature"
1088        );
1089        assert!(matches!(
1090            up.verify_embedded(),
1091            Err(ReceiptError::Key(KeyError::BadSignature(_)))
1092        ));
1093
1094        // v2-signed body relabeled as v1.
1095        let mut down = Receipt::issue(body(), &signer).unwrap();
1096        down.body.receipt_version = 1;
1097        assert!(
1098            matches!(
1099                down.verify(&ring),
1100                Err(ReceiptError::Key(KeyError::BadSignature(_)))
1101            ),
1102            "v2 signature relabeled as v1 must fail as BadSignature"
1103        );
1104        assert!(matches!(
1105            down.verify_embedded(),
1106            Err(ReceiptError::Key(KeyError::BadSignature(_)))
1107        ));
1108    }
1109
1110    /// The v2 signing message framing is pinned byte-for-byte: tag,
1111    /// u64 big-endian length of the canonical bytes, canonical bytes.
1112    /// An external reimplementation (VERIFYING-A-RECEIPT.md §5) relies
1113    /// on exactly this layout.
1114    #[test]
1115    fn v2_signing_message_framing_is_tag_belen_canon() {
1116        let canon = r#"{"a":1}"#;
1117        let message = signing_message(2, canon).unwrap();
1118        let mut expected = b"agentvisor-receipt-v2\0".to_vec();
1119        expected.extend_from_slice(&(canon.len() as u64).to_be_bytes());
1120        expected.extend_from_slice(canon.as_bytes());
1121        assert_eq!(message, expected);
1122        // v1 is the bare canonical bytes.
1123        assert_eq!(signing_message(1, canon).unwrap(), canon.as_bytes());
1124        // Undefined versions have no framing.
1125        assert!(matches!(
1126            signing_message(3, canon),
1127            Err(ReceiptError::SemanticInvariant(_))
1128        ));
1129    }
1130
1131    /// The `AtifTrajectory.retroactive == true`
1132    /// semantic invariant needs a test — a mutation run caught
1133    /// `verify_semantic_invariants -> Ok(())` surviving, meaning a
1134    /// forged non-retroactive promotion receipt verified fine. Pin
1135    /// both verification paths: a `retroactive: false` receipt must
1136    /// fail as SemanticInvariant even though its signature is valid,
1137    /// and the honest `retroactive: true` twin must pass.
1138    #[test]
1139    fn retroactive_false_is_refused_on_both_verify_paths() {
1140        let signer = Ed25519Signer::generate();
1141        let mut ring = Keyring::new();
1142        ring.add_signer(&signer).unwrap();
1143        let subject = |retroactive| ReceiptSubject::AtifTrajectory {
1144            trajectory_digest: "cd".repeat(32),
1145            step_count: 7,
1146            retroactive,
1147        };
1148        let make = |retroactive| {
1149            let mut body = body();
1150            body.subject = subject(retroactive);
1151            Receipt::issue_unchecked(body, &signer).unwrap()
1152        };
1153
1154        let forged = make(false);
1155        for outcome in [forged.verify(&ring), forged.verify_embedded()] {
1156            assert!(
1157                matches!(outcome, Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("retroactive")),
1158                "retroactive=false must be refused, got {outcome:?}"
1159            );
1160        }
1161
1162        let honest = make(true);
1163        honest.verify(&ring).unwrap();
1164        honest.verify_embedded().unwrap();
1165    }
1166
1167    /// The duplicate-key scanner doubles as the nesting-depth guard.
1168    /// Pin the boundary exactly: depth == MAX_NESTED_DEPTH - 1 parses,
1169    /// depth == MAX_NESTED_DEPTH is refused as the
1170    /// DuplicateKey-family class (either our sentinel or serde_json's
1171    /// own recursion cap, both mapped identically). Kills the
1172    /// `>` -> `>=`/`==` boundary mutants in NoDupVisitor's seq and
1173    /// map arms.
1174    #[test]
1175    fn nesting_depth_boundary_is_exact_for_arrays_and_maps() {
1176        // The leaf is `0` (not `null`) because the
1177        // strict receipt scanner refuses explicit JSON null
1178        // everywhere. A number at the leaf preserves the boundary
1179        // test's intent (depth mechanics only) without exercising
1180        // the unrelated null-rejection path.
1181        let nested_arrays = |depth: usize| format!("{}0{}", "[".repeat(depth), "]".repeat(depth));
1182        let nested_maps = |depth: usize| format!("{}0{}", "{\"k\":".repeat(depth), "}".repeat(depth));
1183        for build in [&nested_arrays as &dyn Fn(usize) -> String, &nested_maps] {
1184            // serde_json's own recursion cap fires at 128 containers, so
1185            // the deepest reachable input is 127; the visitor's own
1186            // `> MAX_NESTED_DEPTH` arm is defense-in-depth for
1187            // hypothetical cap-free parsers. Pin the user-visible
1188            // contract: 127 parses, 128 is refused as the DuplicateKey
1189            // family (serde's recursion error is mapped there
1190            // deliberately so hostile-nesting triage has one class).
1191            let deepest_reachable = build(MAX_NESTED_DEPTH - 1);
1192            assert!(
1193                check_no_duplicate_keys(deepest_reachable.as_bytes()).is_ok(),
1194                "depth == MAX_NESTED_DEPTH - 1 must be accepted"
1195            );
1196            let past_limit = build(MAX_NESTED_DEPTH);
1197            let outcome = check_no_duplicate_keys(past_limit.as_bytes());
1198            assert!(
1199                matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
1200                "depth == MAX_NESTED_DEPTH must be refused, got {outcome:?}"
1201            );
1202        }
1203    }
1204
1205    /// `Receipt::from_json_slice` rejects duplicate keys
1206    /// at ANY nesting level (top-level, nested inside `ai_agent`,
1207    /// nested inside `subject`, deep inside an array element). This
1208    /// closes the last-wins vs first-wins auditor split-brain that
1209    /// would otherwise let a hostile issuer sign under one
1210    /// interpretation while an external audit tool showed the other.
1211    #[test]
1212    fn from_json_slice_rejects_explicit_null_option_field() {
1213        // `AgentIdentity.ttl_remaining_s` is
1214        // `Option<u64>` with `skip_serializing_if = Option::is_none`,
1215        // so an honest issuer omits the field. `serde_json::from_slice`
1216        // would then accept an explicit `null` for the same field,
1217        // and `Receipt::verify` would re-canonicalise via
1218        // `serde_json::to_value(&self.body)`, dropping the null. The
1219        // consequence: a valid signature over TWO different wire
1220        // encodings — a "same signature ⇔ same bytes" violation.
1221        // `from_json_slice` must refuse the null variant.
1222        let bytes = br#"{"ai_agent":{"instance_uid":"a","ttl_remaining_s":null}}"#;
1223        let outcome = Receipt::from_json_slice(bytes);
1224        assert!(
1225            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("null")),
1226            "expected null-rejection (DuplicateKey family), got {outcome:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn from_json_slice_rejects_duplicate_top_level_key() {
1232        let bytes = br#"{"receipt_version":1,"receipt_version":2,"receipt_id":"x"}"#;
1233        let outcome = Receipt::from_json_slice(bytes);
1234        assert!(
1235            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("receipt_version")),
1236            "expected DuplicateKey rejection, got {outcome:?}"
1237        );
1238    }
1239
1240    /// `from_json_str` is public API and must not go
1241    /// untested. Prove the roundtrip (an issued
1242    /// receipt re-parses and still verifies) AND that the strict
1243    /// duplicate-key gate applies on the &str path too — a regression
1244    /// that routed it around `check_no_duplicate_keys` would reopen
1245    /// the split-brain the slice path closes.
1246    #[test]
1247    fn from_json_str_roundtrips_and_applies_the_duplicate_key_gate() {
1248        let signer = Ed25519Signer::generate();
1249        let issued = Receipt::issue(body(), &signer).unwrap();
1250        let text = serde_json::to_string(&issued).unwrap();
1251        let reparsed = Receipt::from_json_str(&text).unwrap();
1252        assert_eq!(reparsed, issued);
1253        reparsed.verify_embedded().unwrap();
1254
1255        let outcome = Receipt::from_json_str(r#"{"receipt_version":1,"receipt_version":2}"#);
1256        assert!(
1257            matches!(outcome, Err(ReceiptError::DuplicateKey(_))),
1258            "the &str path must apply the same duplicate-key gate; got {outcome:?}"
1259        );
1260    }
1261
1262    #[test]
1263    fn from_json_slice_rejects_duplicate_key_inside_ai_agent() {
1264        let bytes = br#"{"ai_agent":{"instance_uid":"a","instance_uid":"b"}}"#;
1265        let outcome = Receipt::from_json_slice(bytes);
1266        assert!(
1267            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("instance_uid")),
1268            "expected DuplicateKey rejection for nested field, got {outcome:?}"
1269        );
1270    }
1271
1272    #[test]
1273    fn from_json_slice_rejects_duplicate_key_inside_array_element() {
1274        // Rare shape but valid JSON that would slip past the top-level
1275        // guard historically. Deep nesting → the walker recurses.
1276        let bytes = br#"{"weird":[{"dup":1,"dup":2}]}"#;
1277        let outcome = Receipt::from_json_slice(bytes);
1278        assert!(
1279            matches!(outcome, Err(ReceiptError::DuplicateKey(ref msg)) if msg.contains("dup")),
1280            "expected DuplicateKey rejection deep in an array, got {outcome:?}"
1281        );
1282    }
1283
1284    /// `check_no_duplicate_keys` must distinguish
1285    /// duplicate-key rejections (mapped to `ReceiptError::DuplicateKey`)
1286    /// from ordinary parse failures (mapped to `ReceiptError::Serde`).
1287    /// A blanket `.map_err(DuplicateKey)` folds EOF,
1288    /// "expected value", and recursion-limit hits into the DuplicateKey
1289    /// variant, actively misleading ops during triage.
1290    #[test]
1291    fn from_json_slice_maps_malformed_json_to_serde_not_duplicate_key() {
1292        // Genuine parse failure — no duplicate key involved. Must
1293        // surface as ReceiptError::Serde so tracing sites and CLI
1294        // error text name the actual class.
1295        for garbage in [
1296            &b"not json"[..],
1297            &b""[..],
1298            &b"{\"unterminated"[..],
1299            &b"{\"x\": }"[..],
1300        ] {
1301            let outcome = Receipt::from_json_slice(garbage);
1302            assert!(
1303                matches!(outcome, Err(ReceiptError::Serde(_))),
1304                "expected Serde error for {garbage:?}, got {outcome:?}",
1305            );
1306        }
1307    }
1308
1309    /// The duplicate-key error message must escape any hostile
1310    /// control characters in the key so a `\n`-carrying key does not
1311    /// inject a fake extra log line at tracing sites that render
1312    /// %error into their output.
1313    #[test]
1314    fn from_json_slice_escapes_control_chars_in_duplicate_key_names() {
1315        // JSON allows control chars in keys via \uXXXX escapes.
1316        let bytes = br#"{"a\nb":1,"a\nb":2}"#;
1317        let outcome = Receipt::from_json_slice(bytes);
1318        let msg = match outcome {
1319            Err(ReceiptError::DuplicateKey(msg)) => msg,
1320            other => panic!("expected DuplicateKey, got {other:?}"),
1321        };
1322        // A literal newline in the message would let an operator log
1323        // `%error` inject an unrelated line; escape_debug renders it
1324        // as `\n`.
1325        assert!(!msg.contains('\n'), "unescaped newline in error: {msg:?}");
1326        assert!(msg.contains(r"\n"), "expected escaped newline, got {msg:?}");
1327    }
1328
1329    /// Defense-in-depth against stack overflow via
1330    /// deeply-nested JSON payload. serde_json's own recursion limit
1331    /// is 128 (fires first with "recursion limit exceeded"); our
1332    /// walker enforces the same bound explicitly so a future
1333    /// serde_json change couldn't quietly reintroduce the hazard.
1334    #[test]
1335    fn from_json_slice_rejects_deeply_nested_json() {
1336        let mut deep = String::from("{\"x\":");
1337        for _ in 0..200 {
1338            deep.push('[');
1339        }
1340        for _ in 0..200 {
1341            deep.push(']');
1342        }
1343        deep.push('}');
1344        let outcome = Receipt::from_json_slice(deep.as_bytes());
1345        // Either serde_json fires first ("recursion limit exceeded")
1346        // or our walker fires ("nesting exceeds 128"). Either way,
1347        // the DoS is contained without a stack overflow.
1348        assert!(
1349            matches!(
1350                outcome,
1351                Err(ReceiptError::DuplicateKey(ref msg))
1352                    if msg.contains("nesting exceeds") || msg.contains("recursion limit")
1353            ),
1354            "expected nesting-cap error, got {outcome:?}",
1355        );
1356    }
1357
1358    /// Mutation-run hardening: nothing pinned the *pass* side of the
1359    /// nesting guard. The observable boundary today is serde_json's own
1360    /// recursion limit (fires entering level 128, before our
1361    /// `MAX_NESTED_DEPTH` walk — which is deliberate defense-in-depth
1362    /// behind it): 127 levels must clear the gate (rejection is the
1363    /// schema/parse `Serde` error, not the nesting cap), 128 must be
1364    /// refused by the recursion/nesting guard class.
1365    #[test]
1366    fn nesting_gate_boundary_is_exactly_the_recursion_limit() {
1367        let nested = |levels: usize| {
1368            let mut doc = String::new();
1369            for _ in 0..levels {
1370                doc.push('[');
1371            }
1372            for _ in 0..levels {
1373                doc.push(']');
1374            }
1375            doc
1376        };
1377        // Just under the limit: the gate passes; the failure is the
1378        // schema mismatch (an array is not a Receipt), NOT the cap.
1379        let under_cap = Receipt::from_json_slice(nested(127).as_bytes());
1380        assert!(
1381            matches!(under_cap, Err(ReceiptError::Serde(_))),
1382            "127 levels must clear the nesting gate, got {under_cap:?}",
1383        );
1384        // At the limit: refused by the nesting/recursion guard.
1385        let at_cap = Receipt::from_json_slice(nested(128).as_bytes());
1386        assert!(
1387            matches!(
1388                at_cap,
1389                Err(ReceiptError::DuplicateKey(ref msg))
1390                    if msg.contains("nesting exceeds") || msg.contains("recursion limit")
1391            ),
1392            "128 levels must hit the nesting cap, got {at_cap:?}",
1393        );
1394    }
1395
1396    /// The happy path (well-formed receipt) still deserializes as
1397    /// before — the duplicate-key gate is a strict filter, not a
1398    /// disruption.
1399    #[test]
1400    fn from_json_slice_accepts_a_well_formed_receipt() {
1401        let signer = Ed25519Signer::generate();
1402        let receipt = Receipt::issue(body(), &signer).unwrap();
1403        let bytes = serde_json::to_vec(&receipt).unwrap();
1404        let restored = Receipt::from_json_slice(&bytes).unwrap();
1405        assert_eq!(restored.body.session_id, "sess-77");
1406    }
1407
1408    #[test]
1409    fn verification_survives_json_roundtrip() {
1410        // Receipts travel as JSON; key order may change en route. JCS must
1411        // make verification independent of transport-layer reserialization.
1412        let signer = Ed25519Signer::generate();
1413        let mut ring = Keyring::new();
1414        ring.add_signer(&signer).unwrap();
1415        let receipt = Receipt::issue(body(), &signer).unwrap();
1416        let json = serde_json::to_string(&receipt).unwrap();
1417        let back: Receipt = serde_json::from_str(&json).unwrap();
1418        back.verify(&ring).unwrap();
1419    }
1420
1421    #[test]
1422    fn issued_receipt_matches_shipped_schema() {
1423        let signer = Ed25519Signer::from_seed(&[13; 32]);
1424        let receipt = Receipt::issue(body(), &signer).unwrap();
1425        let schema: serde_json::Value =
1426            serde_json::from_str(include_str!("../../../schemas/receipt-v2.schema.json")).unwrap();
1427        let validator = jsonschema::validator_for(&schema).unwrap();
1428        let value = serde_json::to_value(receipt).unwrap();
1429        let errors: Vec<_> = validator.iter_errors(&value).collect();
1430        assert!(errors.is_empty(), "{errors:?}");
1431    }
1432
1433    /// The v1 schema stays shipped so existing v1 artifacts keep
1434    /// validating: a legacy-signed v1 receipt matches receipt-v1, and
1435    /// the two schemas partition by `receipt_version` (a v2 receipt is
1436    /// refused by the v1 schema and vice versa).
1437    #[test]
1438    fn legacy_v1_receipt_matches_the_shipped_v1_schema() {
1439        let signer = Ed25519Signer::from_seed(&[13; 32]);
1440        let v1_receipt = serde_json::to_value(issue_v1(body(), &signer)).unwrap();
1441        let v2_receipt = serde_json::to_value(Receipt::issue(body(), &signer).unwrap()).unwrap();
1442        let v1_schema: serde_json::Value =
1443            serde_json::from_str(include_str!("../../../schemas/receipt-v1.schema.json")).unwrap();
1444        let v2_schema: serde_json::Value =
1445            serde_json::from_str(include_str!("../../../schemas/receipt-v2.schema.json")).unwrap();
1446        let v1_validator = jsonschema::validator_for(&v1_schema).unwrap();
1447        let v2_validator = jsonschema::validator_for(&v2_schema).unwrap();
1448        let errors: Vec<_> = v1_validator.iter_errors(&v1_receipt).collect();
1449        assert!(errors.is_empty(), "v1 receipt vs v1 schema: {errors:?}");
1450        assert!(
1451            !v1_validator.is_valid(&v2_receipt),
1452            "v2 receipt must not satisfy the v1 schema (receipt_version const)"
1453        );
1454        assert!(
1455            !v2_validator.is_valid(&v1_receipt),
1456            "v1 receipt must not satisfy the v2 schema (receipt_version const)"
1457        );
1458    }
1459
1460    #[test]
1461    fn every_field_tamper_detected() {
1462        let signer = Ed25519Signer::generate();
1463        let mut ring = Keyring::new();
1464        ring.add_signer(&signer).unwrap();
1465        let receipt = Receipt::issue(body(), &signer).unwrap();
1466        let good = serde_json::to_value(&receipt).unwrap();
1467
1468        let tampers: Vec<(&str, serde_json::Value)> = vec![
1469            ("session_id", "sess-OTHER".into()),
1470            ("stop_reason_id", 1.into()),
1471            ("receipt_id", "forged".into()),
1472            ("issued_at", 1.into()),
1473        ];
1474        for (field, val) in tampers {
1475            let mut bad = good.clone();
1476            bad[field] = val;
1477            let parsed: Receipt = serde_json::from_value(bad).unwrap();
1478            assert!(
1479                parsed.verify(&ring).is_err(),
1480                "tampered {field} passed verification"
1481            );
1482        }
1483        // Nested tampers.
1484        let mut bad = good.clone();
1485        bad["cost"]["cost_usd_micros"] = 1.into();
1486        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1487        assert!(parsed.verify(&ring).is_err(), "tampered cost passed");
1488
1489        let mut bad = good.clone();
1490        bad["ai_agent"]["charter"]["name"] = "swapped-charter".into();
1491        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1492        assert!(parsed.verify(&ring).is_err(), "tampered charter passed");
1493
1494        let mut bad = good;
1495        bad["subject"]["chain_head"] = "00".repeat(32).into();
1496        let parsed: Receipt = serde_json::from_value(bad).unwrap();
1497        assert!(parsed.verify(&ring).is_err(), "tampered chain head passed");
1498    }
1499
1500    #[test]
1501    fn key_substitution_detected() {
1502        // Attacker re-signs a modified receipt with their own key but keeps
1503        // the victim's key id.
1504        let victim = Ed25519Signer::generate();
1505        let attacker = Ed25519Signer::generate();
1506        let mut ring = Keyring::new();
1507        ring.add_signer(&victim).unwrap();
1508
1509        let mut receipt = Receipt::issue(body(), &attacker).unwrap();
1510        receipt.body.key_id = victim.key_id().to_owned(); // lie about identity
1511        assert!(matches!(receipt.verify(&ring), Err(ReceiptError::KeyMismatch(_))));
1512
1513        // Variant: also swap in the victim's public key (signature then fails).
1514        receipt.body.public_key_b64 =
1515            base64::engine::general_purpose::STANDARD.encode(victim.public_key_bytes());
1516        assert!(receipt.verify(&ring).is_err());
1517    }
1518
1519    #[test]
1520    fn caller_cannot_forge_key_fields() {
1521        let signer = Ed25519Signer::generate();
1522        let mut b = body();
1523        b.key_id = "attacker-chosen".into();
1524        b.public_key_b64 = "AAAA".into();
1525        let receipt = Receipt::issue(b, &signer).unwrap();
1526        // issue() must have overwritten both.
1527        assert_eq!(receipt.body.key_id, signer.key_id());
1528        receipt.verify_embedded().unwrap();
1529    }
1530
1531    #[test]
1532    fn retroactive_atif_subject() {
1533        let signer = Ed25519Signer::generate();
1534        let mut ring = Keyring::new();
1535        ring.add_signer(&signer).unwrap();
1536        let mut b = body();
1537        b.subject = ReceiptSubject::AtifTrajectory {
1538            trajectory_digest: "cd".repeat(32),
1539            step_count: 18,
1540            retroactive: true,
1541        };
1542        let receipt = Receipt::issue(b, &signer).unwrap();
1543        receipt.verify(&ring).unwrap();
1544        let v = serde_json::to_value(&receipt).unwrap();
1545        assert_eq!(v["subject"]["kind"], "atif_trajectory");
1546        assert_eq!(v["subject"]["retroactive"], true);
1547    }
1548
1549    #[test]
1550    fn unknown_key_id_fails_ring_verification() {
1551        let signer = Ed25519Signer::generate();
1552        let receipt = Receipt::issue(body(), &signer).unwrap();
1553        let ring = Keyring::new(); // empty
1554        assert!(receipt.verify(&ring).is_err());
1555    }
1556
1557    #[test]
1558    fn attacker_receipt_does_not_pass_a_ring_seeded_with_only_a_different_key() {
1559        let attacker = Ed25519Signer::generate();
1560        let honest = Ed25519Signer::generate();
1561        assert_ne!(attacker.key_id(), honest.key_id());
1562        let forged = Receipt::issue(body(), &attacker).unwrap();
1563        let mut ring = Keyring::new();
1564        ring.add_signer(&honest).unwrap();
1565        assert!(matches!(
1566            forged.verify(&ring),
1567            Err(ReceiptError::Key(KeyError::UnknownKeyId(_)))
1568        ));
1569    }
1570
1571    #[test]
1572    fn verify_embedded_rejects_a_tampered_body() {
1573        // verify_embedded MUST NOT be reducible to Ok(()): tamper the
1574        // session_id and require Err. Catches any stub or short-circuit
1575        // that would silently accept every receipt.
1576        let signer = Ed25519Signer::generate();
1577        let receipt = Receipt::issue(body(), &signer).unwrap();
1578        let mut raw = serde_json::to_value(&receipt).unwrap();
1579        raw["session_id"] = serde_json::Value::from("attacker");
1580        let tampered: Receipt = serde_json::from_value(raw).unwrap();
1581        assert!(tampered.verify_embedded().is_err());
1582    }
1583
1584    #[test]
1585    fn verify_embedded_rejects_a_swapped_public_key() {
1586        // Substitute the embedded pubkey for someone else's — the id/key
1587        // binding inside verify_embedded must catch this AS a
1588        // KeyMismatch, not defer to the signature check downstream.
1589        // The previous bare `is_err()` still passed with the whole
1590        // key_id ↔ pubkey binding block deleted (the signature-verify
1591        // arm catches it too), reopening the substitution-attempt
1592        // detection this test names.
1593        let honest = Ed25519Signer::generate();
1594        let attacker = Ed25519Signer::generate();
1595        let mut receipt = Receipt::issue(body(), &honest).unwrap();
1596        receipt.body.public_key_b64 =
1597            base64::engine::general_purpose::STANDARD.encode(attacker.public_key_bytes());
1598        let err = receipt
1599            .verify_embedded()
1600            .expect_err("swapped embedded pubkey must be refused");
1601        assert!(
1602            matches!(err, ReceiptError::KeyMismatch(_)),
1603            "the id/key binding in verify_embedded must recognise the substitution \
1604             as KeyMismatch (not fall through to the signature-verify arm); got {err:?}"
1605        );
1606    }
1607
1608    /// Schema parity: the shipped schema pins `minLength: 1` on these
1609    /// string fields; a keyholder signing a receipt with an empty
1610    /// session_id / instance_uid must be refused by the Rust verifier
1611    /// too, or `avctl receipt-verify` and external schema verifiers
1612    /// return split verdicts on the same document.
1613    #[test]
1614    fn empty_min_length_fields_are_refused_by_verify() {
1615        let signer = Ed25519Signer::generate();
1616        for field in [
1617            "receipt_id",
1618            "session_id",
1619            "ai_agent.version",
1620            "ai_agent.charter.name",
1621            "ai_agent.instance_uid",
1622        ] {
1623            let mut b = body();
1624            match field {
1625                "receipt_id" => b.receipt_id = String::new(),
1626                "session_id" => b.session_id = String::new(),
1627                "ai_agent.version" => b.ai_agent.version = String::new(),
1628                "ai_agent.charter.name" => b.ai_agent.charter.name = String::new(),
1629                _ => b.ai_agent.instance_uid = String::new(),
1630            }
1631            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1632            assert!(
1633                matches!(
1634                    receipt.verify_embedded(),
1635                    Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains(field)
1636                ),
1637                "signed receipt with empty {field} verified"
1638            );
1639        }
1640    }
1641
1642    /// An unknown `receipt_version` must be refused by
1643    /// BOTH verification paths — a future v3 receipt that
1644    /// reinterprets a field must not verify under v1/v2 semantics on a
1645    /// shipped verifier. `receipt_version` sits inside `body`
1646    /// so a version tamper ALSO fails the signature — but the semantic
1647    /// gate must fire first with an actionable message (and protects
1648    /// against a legitimately-signed future-version receipt).
1649    #[test]
1650    fn unknown_receipt_version_is_refused_by_both_verify_paths() {
1651        let signer = Ed25519Signer::generate();
1652        let mut receipt = Receipt::issue(body(), &signer).unwrap();
1653        receipt.body.receipt_version = 3;
1654        let mut ring = Keyring::new();
1655        ring.add_key_bytes(&signer.public_key_bytes()).unwrap();
1656        assert!(
1657            matches!(receipt.verify(&ring), Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("receipt_version 3")),
1658            "ring verify must refuse an unknown receipt_version"
1659        );
1660        assert!(
1661            matches!(receipt.verify_embedded(), Err(ReceiptError::SemanticInvariant(_))),
1662            "embedded verify must refuse an unknown receipt_version"
1663        );
1664    }
1665
1666    /// Tool accounting must be arithmetically possible: `allowed +
1667    /// blocked` can never exceed `total` (journal recovery refuses to
1668    /// recover such totals, and the issuer maintains it by
1669    /// construction). A keyholder-signed receipt violating it must fail
1670    /// verification rather than attest impossible statistics — including
1671    /// the u64-overflow corner where `allowed + blocked` wraps.
1672    #[test]
1673    fn impossible_tool_accounting_is_refused() {
1674        let signer = Ed25519Signer::generate();
1675        let mut ring = Keyring::new();
1676        ring.add_key_bytes(&signer.public_key_bytes()).unwrap();
1677        for (total, allowed, blocked) in [
1678            (1u64, 1_000_000u64, 0u64),
1679            (0, 0, 1),
1680            (5, 3, 3),
1681            // JCS caps every integer at 2^53-1, so the u64 checked_add
1682            // overflow arm is unreachable through an issuable receipt;
1683            // the largest issuable sum is still refused.
1684            (0, (1 << 53) - 1, (1 << 53) - 1),
1685        ] {
1686            let mut b = body();
1687            b.tool_calls = ToolCallSummary {
1688                total,
1689                allowed,
1690                blocked,
1691            };
1692            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1693            assert!(
1694                matches!(
1695                    receipt.verify(&ring),
1696                    Err(ReceiptError::SemanticInvariant(ref msg)) if msg.contains("tool_calls")
1697                ),
1698                "signed receipt with impossible tool accounting ({total}/{allowed}/{blocked}) verified"
1699            );
1700            assert!(
1701                matches!(receipt.verify_embedded(), Err(ReceiptError::SemanticInvariant(_))),
1702                "embedded verify accepted impossible tool accounting ({total}/{allowed}/{blocked})"
1703            );
1704        }
1705        // Boundary: exactly-classified and under-classified totals verify.
1706        for (total, allowed, blocked) in [(2u64, 1u64, 1u64), (3, 1, 1)] {
1707            let mut b = body();
1708            b.tool_calls = ToolCallSummary {
1709                total,
1710                allowed,
1711                blocked,
1712            };
1713            let receipt = Receipt::issue_unchecked(b, &signer).unwrap();
1714            receipt.verify(&ring).unwrap();
1715        }
1716    }
1717
1718    /// Stress: for every wall-clock instant sampled during issuance, the
1719    /// integer `issued_at` and the human-readable `issued_at_iso` must be
1720    /// mutually consistent. If they ever drift, downstream consumers that
1721    /// parse only one field could reconstruct a different timestamp than
1722    /// the signer intended, and the signature's coverage of both fields
1723    /// would still verify (since the signer produced them together).
1724    #[test]
1725    fn issued_at_and_issued_at_iso_are_always_consistent() {
1726        let signer = Ed25519Signer::generate();
1727        for _ in 0..64 {
1728            let receipt = Receipt::issue(body(), &signer).unwrap();
1729            let derived = av_core::time::iso8601_ms(receipt.body.issued_at);
1730            assert_eq!(
1731                derived, receipt.body.issued_at_iso,
1732                "iso/ms mismatch: {} vs {}",
1733                receipt.body.issued_at, receipt.body.issued_at_iso,
1734            );
1735        }
1736    }
1737
1738    /// A keyholding issuer must not be able to sign a receipt whose
1739    /// `stop_reason` caption names a DIFFERENT known variant than
1740    /// `stop_reason_id` — auditors reading the caption and tooling
1741    /// reading the id would extract contradictory outcomes from one
1742    /// verified receipt. Provider-native / free-text captions (which
1743    /// coincide with no canonical caption) remain accepted, matching
1744    /// the events validator's native-caption tolerance.
1745    #[test]
1746    fn subject_digest_conjuncts_are_each_enforced() {
1747        // Pin BOTH conjuncts of the digest shape check (length == 64
1748        // AND lowercase hex): a mutant flipping && to || previously
1749        // survived because no test failed exactly one conjunct.
1750        let signer = Ed25519Signer::generate();
1751        let mut ring = Keyring::new();
1752        ring.add_signer(&signer).unwrap();
1753        for bad_head in [
1754            "zz".repeat(32), // 64 chars, not hex
1755            "ab".repeat(31), // hex, wrong length
1756            "AB".repeat(32), // uppercase hex refused
1757        ] {
1758            let mut body = body();
1759            body.subject = ReceiptSubject::EventChain {
1760                chain_head: bad_head.clone(),
1761                event_count: 1,
1762            };
1763            let error = Receipt::issue_unchecked(body, &signer)
1764                .unwrap()
1765                .verify(&ring)
1766                .unwrap_err();
1767            assert!(
1768                matches!(error, ReceiptError::SemanticInvariant(ref m) if m.contains("chain_head")),
1769                "chain_head {bad_head:?} must fail: {error:?}"
1770            );
1771        }
1772    }
1773
1774    #[test]
1775    fn cross_wired_stop_reason_caption_is_refused_but_native_captions_pass() {
1776        let signer = Ed25519Signer::generate();
1777        let mut ring = Keyring::new();
1778        ring.add_signer(&signer).unwrap();
1779        // Cross-wired: id 92 (Budget Exceeded) with the canonical
1780        // caption of variant 1 (Stop).
1781        let mut wired = body();
1782        wired.stop_reason_id = 92;
1783        wired.stop_reason = "Stop".to_owned();
1784        let error = Receipt::issue_unchecked(wired, &signer)
1785            .unwrap()
1786            .verify(&ring)
1787            .unwrap_err();
1788        assert!(
1789            matches!(error, ReceiptError::SemanticInvariant(ref m) if m.contains("canonical caption")),
1790            "cross-wired caption must fail verify: {error:?}"
1791        );
1792        // Native caption: id 1 (Stop) with OpenAI's native "stop".
1793        let mut native = body();
1794        native.stop_reason_id = 1;
1795        native.stop_reason = "stop".to_owned();
1796        Receipt::issue_unchecked(native, &signer)
1797            .unwrap()
1798            .verify(&ring)
1799            .unwrap();
1800        // Canonical pairing passes.
1801        let mut canonical = body();
1802        canonical.stop_reason_id = 1;
1803        canonical.stop_reason = "Stop".to_owned();
1804        Receipt::issue_unchecked(canonical, &signer)
1805            .unwrap()
1806            .verify(&ring)
1807            .unwrap();
1808    }
1809
1810    /// Cross-machine stress: two independently-issued receipts (simulating
1811    /// two harness replicas whose wall clocks drift within a normal
1812    /// tolerance) must both verify against the same keyring and carry
1813    /// UTC-Z timestamps regardless of what timezone the issuing process
1814    /// was configured with.
1815    #[test]
1816    fn receipts_issued_by_two_machines_both_verify_and_use_utc_zulu() {
1817        let signer_a = Ed25519Signer::generate();
1818        let signer_b = Ed25519Signer::generate();
1819        let mut ring = Keyring::new();
1820        ring.add_signer(&signer_a).unwrap();
1821        ring.add_signer(&signer_b).unwrap();
1822        let ra = Receipt::issue(body(), &signer_a).unwrap();
1823        let rb = Receipt::issue(body(), &signer_b).unwrap();
1824        ra.verify(&ring).unwrap();
1825        rb.verify(&ring).unwrap();
1826        assert!(ra.body.issued_at_iso.ends_with('Z'), "{}", ra.body.issued_at_iso,);
1827        assert!(rb.body.issued_at_iso.ends_with('Z'), "{}", rb.body.issued_at_iso,);
1828        // Both timestamps must be within a minute of each other on the
1829        // same physical host running the test.
1830        let diff_ms = ra.body.issued_at.abs_diff(rb.body.issued_at);
1831        assert!(diff_ms < 60_000, "clocks {diff_ms} ms apart");
1832    }
1833
1834    /// A receipt with an extra top-level field that the signer did not
1835    /// include in the signed body would appear in a raw-JSON audit
1836    /// reader but be invisible to `verify()` (the field silently drops
1837    /// during the ReceiptBody round-trip inside canonicalize). Our
1838    /// custom `Deserialize` rejects it at parse time — a corrupted /
1839    /// tampered receipt fails before it can be trusted.
1840    #[test]
1841    fn receipt_with_unknown_top_level_field_is_rejected_on_parse() {
1842        let signer = Ed25519Signer::generate();
1843        let receipt = Receipt::issue(body(), &signer).unwrap();
1844        let mut value: serde_json::Value =
1845            serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
1846        value
1847            .as_object_mut()
1848            .unwrap()
1849            .insert("claim_extra".to_owned(), serde_json::json!("grant admin"));
1850        let tampered = serde_json::to_string(&value).unwrap();
1851        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1852        assert!(
1853            error.to_string().contains("claim_extra"),
1854            "error should name the offending field: {error}"
1855        );
1856    }
1857
1858    /// The manual whitelist must stay in lockstep with the field set of
1859    /// [`ReceiptBody`]. If a new field is added there without updating
1860    /// [`ALLOWED_RECEIPT_TOP_LEVEL_KEYS`], parsing that field silently
1861    /// fails (or worse, drops it back into the same class of bug we
1862    /// fixed). Serializing a canonical body and cross-checking is the
1863    /// mechanical guard.
1864    #[test]
1865    fn allowed_receipt_top_level_keys_cover_body_fields_exactly() {
1866        let value = serde_json::to_value(body()).unwrap();
1867        let object = value.as_object().unwrap();
1868        let mut body_keys: Vec<&str> = object.keys().map(String::as_str).collect();
1869        body_keys.push("signature_b64");
1870        body_keys.sort_unstable();
1871        let mut allowed: Vec<&str> = ALLOWED_RECEIPT_TOP_LEVEL_KEYS.to_vec();
1872        allowed.sort_unstable();
1873        assert_eq!(
1874            body_keys, allowed,
1875            "ALLOWED_RECEIPT_TOP_LEVEL_KEYS drift vs ReceiptBody serialization"
1876        );
1877    }
1878
1879    /// A hostile issuer could sign under one JSON parser's
1880    /// duplicate-key semantics (usually last-wins) while an auditor
1881    /// reads the same wire bytes under a different parser
1882    /// (first-wins). Both parses "succeed" but see different receipt
1883    /// contents. Our custom Deserialize now rejects duplicates
1884    /// outright, closing the parser-disagreement audit-spoofing gap.
1885    #[test]
1886    fn receipt_with_duplicate_top_level_key_is_rejected_on_parse() {
1887        // Hand-craft the JSON so both `session_id` entries land at the
1888        // top level and are seen in order by the map visitor. Rust's
1889        // `format!` guarantees a fixed byte sequence.
1890        let signer = Ed25519Signer::generate();
1891        let receipt = Receipt::issue(body(), &signer).unwrap();
1892        let good = serde_json::to_string(&receipt).unwrap();
1893        // Inject a second `session_id` right before the closing `}`.
1894        assert!(good.ends_with('}'));
1895        let tampered = format!(
1896            "{},\"session_id\":\"forged-second-copy\"}}",
1897            &good[..good.len() - 1]
1898        );
1899        let error = serde_json::from_str::<Receipt>(&tampered).unwrap_err();
1900        assert!(
1901            error.to_string().contains("duplicate field"),
1902            "expected duplicate-key rejection, got: {error}"
1903        );
1904        assert!(
1905            error.to_string().contains("session_id"),
1906            "error should name the offending field, got: {error}"
1907        );
1908    }
1909}