Skip to main content

aph_core/
envelope.rs

1//! NotarizationEnvelope — the W3C VC 2.0-shaped credential carrying an
2//! APH notarization. This is the canonical on-wire shape.
3//!
4//! The envelope is a JSON-LD compatible W3C Verifiable Credential 2.0
5//! payload. The `@context` field carries the JSON-LD contexts; the `type`
6//! field MUST include `"VerifiableCredential"` plus
7//! `"AgentSendAuthorizationCredential"`. All struct field names use
8//! snake_case in Rust and camelCase on the wire (via
9//! `#[serde(rename_all = "camelCase")]`), except `@context` (JSON-LD
10//! convention) and `type` (Rust reserved keyword routed through `r#type`
11//! + explicit `#[serde(rename = "type")]` for defense in depth).
12//!
13//! This module is shape-only — `proof.proof_value` is a String; no
14//! cryptographic validation occurs in this module. The STRUCTURAL rules of
15//! spec §7.1.11 (chain length, proof purposes, `previousProof` linkage,
16//! label/structure agreement) live in [`crate::verification`]; signature
17//! checking lives in [`crate::crypto`].
18
19/// Top-level APH envelope. JSON-LD compatible W3C VC 2.0 credential.
20#[derive(
21  std::fmt::Debug,
22  std::clone::Clone,
23  std::cmp::PartialEq,
24  std::cmp::Eq,
25  serde::Serialize,
26  serde::Deserialize,
27)]
28#[serde(rename_all = "camelCase", deny_unknown_fields)]
29pub struct NotarizationEnvelope {
30  /// APH version pin (`"0.1"`).
31  pub aph_version: String,
32  /// JSON-LD `@context` array. Always begins with W3C VC 2.0 context.
33  #[serde(rename = "@context")]
34  pub context: Vec<String>,
35  /// JSON-LD `type` array; MUST include `"VerifiableCredential"` and
36  /// `"AgentSendAuthorizationCredential"`.
37  #[serde(rename = "type")]
38  pub r#type: Vec<String>,
39  /// `urn:uuid:...` envelope identifier.
40  pub id: String,
41  /// DID of the notary service.
42  pub issuer: String,
43  /// RFC 3339 issuance timestamp.
44  pub valid_from: String,
45  /// RFC 3339 expiry timestamp.
46  pub valid_until: String,
47  /// Inner credential subject (the notarized claim).
48  pub credential_subject: CredentialSubject,
49  /// Optional link to an AP2 IntentMandate (for cross-protocol mandates).
50  #[serde(default)]
51  pub linked_mandate: std::option::Option<LinkedMandate>,
52  /// Revocation status reference for the **parent Delegation Mandate** named
53  /// by `credentialSubject.policy.delegationMandateId` — NOT for this
54  /// envelope (spec §6.3.3.1 narrows the W3C reading, and
55  /// [`crate::credential_status`] carries the argument).
56  ///
57  /// **`skip_serializing_if` is load-bearing, not tidiness.** Unlike
58  /// `linked_mandate` directly above — which §7.1.1 permits to appear as an
59  /// explicit `null` — this field is OMITTED when absent, so an envelope
60  /// carrying no status reference is BYTE-IDENTICAL to one written before
61  /// the field existed. That identity is what keeps every published example,
62  /// every golden fixture and all four real Ed25519 signatures valid without
63  /// regeneration: the signing base is this struct serialized and
64  /// canonicalized (`crate::crypto::proof_base::signing_base`), so a bare
65  /// `#[serde(default)]` here would emit `"credentialStatus":null`, change
66  /// those bytes, and invalidate every signature over them.
67  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
68  pub credential_status:
69    std::option::Option<crate::credential_status::CredentialStatusEntry>,
70  /// Cryptographic proof: a single notary proof, or a two-element proof
71  /// chain (spec §7.1.11). See [`EnvelopeProofs`].
72  pub proof: EnvelopeProofs,
73}
74
75/// One proof, or a two-element chain (spec §7.1.11).
76///
77/// Untagged because the wire carries either a JSON object or a JSON array
78/// under the same `proof` key; the shape IS the discriminator.
79#[derive(
80  std::fmt::Debug,
81  std::clone::Clone,
82  std::cmp::PartialEq,
83  std::cmp::Eq,
84  serde::Serialize,
85  serde::Deserialize,
86)]
87#[serde(untagged)]
88pub enum EnvelopeProofs {
89  /// A single notary proof. The envelope is `NotaryAttested` (§7.1.11).
90  Single(EnvelopeProof),
91  /// An ordered proof chain: principal proof, then notary countersignature.
92  Chain(std::vec::Vec<EnvelopeProof>),
93}
94
95impl EnvelopeProofs {
96  /// Every proof, in wire order.
97  pub fn all(&self) -> &[EnvelopeProof] {
98    match self {
99      // `from_ref` gives the single form the same slice shape as the chain
100      // form, so callers that iterate never branch on the variant.
101      Self::Single(proof) => std::slice::from_ref(proof),
102      Self::Chain(proofs) => proofs.as_slice(),
103    }
104  }
105
106  /// The principal proof — the head of a well-formed two-element chain.
107  /// `None` for a single proof or a chain of any other length.
108  ///
109  /// A chain of any other length is malformed under §7.1.11, so refusing to
110  /// name a "principal proof" inside one is deliberate: a caller that got a
111  /// proof back from a three-element array would be reading a structure the
112  /// spec rejects as though it were valid.
113  pub fn principal(&self) -> std::option::Option<&EnvelopeProof> {
114    match self {
115      Self::Single(_) => std::option::Option::None,
116      Self::Chain(proofs) => {
117        if proofs.len() == 2 {
118          proofs.first()
119        } else {
120          std::option::Option::None
121        }
122      }
123    }
124  }
125
126  /// The notary proof: the lone proof, or the tail of a two-element chain.
127  pub fn notary(&self) -> std::option::Option<&EnvelopeProof> {
128    match self {
129      Self::Single(proof) => std::option::Option::Some(proof),
130      Self::Chain(proofs) => {
131        if proofs.len() == 2 {
132          proofs.get(1)
133        } else {
134          std::option::Option::None
135        }
136      }
137    }
138  }
139
140  /// True when this is the array form, whatever its length.
141  pub fn is_chain(&self) -> bool {
142    std::matches!(self, Self::Chain(_))
143  }
144}
145
146/// Which of the two attestation modes an envelope declares (spec §7.1.7).
147///
148/// The wire spells these exactly as written, so no serde rename is needed.
149/// ABSENT means `NotaryAttested` — see [`PolicyDescriptor::effective_attestation_mode`].
150#[derive(
151  std::fmt::Debug,
152  std::clone::Clone,
153  std::marker::Copy,
154  std::cmp::PartialEq,
155  std::cmp::Eq,
156  serde::Serialize,
157  serde::Deserialize,
158)]
159pub enum AttestationMode {
160  /// The human's own key signed this envelope; `proof` is a chain.
161  PrincipalSigned,
162  /// A notary asserts the human authorized this. Strictly weaker.
163  NotaryAttested,
164}
165
166impl AttestationMode {
167  /// The exact wire spelling, for error messages and logs.
168  ///
169  /// Returned as `&'static str` rather than via `Display` so an error
170  /// constructor can name a mode without allocating, and so the string an
171  /// operator reads in a log is byte-identical to the one on the wire.
172  pub fn label(&self) -> &'static str {
173    match self {
174      Self::PrincipalSigned => "PrincipalSigned",
175      Self::NotaryAttested => "NotaryAttested",
176    }
177  }
178}
179
180impl std::fmt::Display for AttestationMode {
181  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182    std::write!(f, "{}", self.label())
183  }
184}
185
186/// The notarized claim: who authorized what, on which channel, under
187/// which policy, attested by which notary.
188#[derive(
189  std::fmt::Debug,
190  std::clone::Clone,
191  std::cmp::PartialEq,
192  std::cmp::Eq,
193  serde::Serialize,
194  serde::Deserialize,
195)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct CredentialSubject {
198  /// The human on whose behalf the agent acted.
199  pub human_principal: HumanPrincipalRef,
200  /// The agent that produced the communication.
201  pub agent: AgentRef,
202  /// Delivery channel and recipient addressing.
203  pub channel: ChannelDescriptor,
204  /// What was sent: content class, body hash, size, preview.
205  pub communication: CommunicationDescriptor,
206  /// The authorization decision and the scope it matched.
207  pub policy: PolicyDescriptor,
208  /// Which notary decided, when, and how long it took.
209  pub notarization: NotarizationMetadata,
210  /// Last-position additive field. Optional Apple Foundation Models AUR
211  /// acceptance claim per `(user_id, device_id, aur_version_hash)`.
212  /// `#[serde(default, skip_serializing_if = "Option::is_none")]` preserves
213  /// wire back-compat (legacy envelopes omit the field and continue to
214  /// deserialize cleanly).
215  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
216  pub apple_aur_acceptance: std::option::Option<AppleAurAcceptanceClaim>,
217}
218
219/// Reference to the human on whose behalf the agent acted.
220#[derive(
221  std::fmt::Debug,
222  std::clone::Clone,
223  std::cmp::PartialEq,
224  std::cmp::Eq,
225  serde::Serialize,
226  serde::Deserialize,
227)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct HumanPrincipalRef {
230  /// DID of the human principal.
231  pub id: String,
232  /// Human-readable name, for display in consent UIs and audit logs.
233  pub display_name: String,
234}
235
236/// Reference to the agent that produced the communication.
237#[derive(
238  std::fmt::Debug,
239  std::clone::Clone,
240  std::cmp::PartialEq,
241  std::cmp::Eq,
242  serde::Serialize,
243  serde::Deserialize,
244)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct AgentRef {
247  /// DID of the agent (typically `did:web:...`).
248  pub id: String,
249  /// Optional URI of the agent's A2A Agent Card.
250  #[serde(default)]
251  pub agent_card_uri: std::option::Option<String>,
252  /// Human-readable agent name.
253  pub display_name: String,
254  /// Agent version string, so a recipient can tell releases apart.
255  pub version: String,
256}
257
258/// Delivery channel and its channel-shaped recipient addressing.
259#[derive(
260  std::fmt::Debug,
261  std::clone::Clone,
262  std::cmp::PartialEq,
263  std::cmp::Eq,
264  serde::Serialize,
265  serde::Deserialize,
266)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct ChannelDescriptor {
269  /// Channel kind: `"slack" | "email" | "discord" | ...`.
270  pub kind: String,
271  /// Channel-shaped opaque blob (opaque to APH core).
272  pub recipient_addressing: serde_json::Value,
273}
274
275/// What was sent: classification plus the hash that binds this credential
276/// to a specific message body.
277#[derive(
278  std::fmt::Debug,
279  std::clone::Clone,
280  std::cmp::PartialEq,
281  std::cmp::Eq,
282  serde::Serialize,
283  serde::Deserialize,
284)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct CommunicationDescriptor {
287  /// Content classification (`"Reply"`, `"New"`, `"DM"`, ...).
288  pub content_class: String,
289  /// SHA-256 of the message body, 64 lowercase hex characters.
290  pub body_sha256: String,
291  /// Body length in bytes.
292  pub body_size: u64,
293  /// Number of body lines included in `preview`.
294  pub preview_lines: u32,
295  /// Truncated body excerpt for human review at decision time.
296  pub preview: String,
297}
298
299/// The authorization decision and the scope that produced it.
300#[derive(
301  std::fmt::Debug,
302  std::clone::Clone,
303  std::cmp::PartialEq,
304  std::cmp::Eq,
305  serde::Serialize,
306  serde::Deserialize,
307)]
308#[serde(rename_all = "camelCase", deny_unknown_fields)]
309pub struct PolicyDescriptor {
310  /// `"AlwaysAllow" | "AskEveryTime" | "NeverAllow"`.
311  pub decision: String,
312  /// e.g., `"per-channel" | "per-recipient" | "global"`.
313  pub matched_scope: String,
314  /// Parent delegation mandate, absent for one-shot AskEveryTime grants.
315  #[serde(default)]
316  pub delegation_mandate_id: std::option::Option<String>,
317  /// OAuth 2.0 Token Exchange `act` chain (RFC 8693) — optional cross-system
318  /// principal chain. Each element is a DID string.
319  #[serde(default)]
320  pub act_chain: Vec<String>,
321  /// Who proved the authorization (§7.1.7). ABSENT means `NotaryAttested`.
322  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
323  pub attestation_mode: std::option::Option<AttestationMode>,
324  /// The complete parent mandate, embedded so the human's signature on it
325  /// verifies offline (§7.1.7.1).
326  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
327  pub delegation_mandate: std::option::Option<crate::delegation_mandate::DelegationMandate>,
328}
329
330impl PolicyDescriptor {
331  /// The declared mode, resolving ABSENT to `NotaryAttested` per §7.1.7.
332  ///
333  /// Absence is not "unknown": §7.1.7 fixes it as `NotaryAttested` so that
334  /// every envelope written before `attestationMode` existed keeps a single,
335  /// unambiguous meaning — the weaker one. Defaulting the other way would
336  /// silently promote every legacy envelope to a claim no one made.
337  pub fn effective_attestation_mode(&self) -> AttestationMode {
338    match self.attestation_mode {
339      std::option::Option::Some(mode) => mode,
340      std::option::Option::None => AttestationMode::NotaryAttested,
341    }
342  }
343}
344
345/// Which notary made the decision, when, and how long it took.
346#[derive(
347  std::fmt::Debug,
348  std::clone::Clone,
349  std::cmp::PartialEq,
350  std::cmp::Eq,
351  serde::Serialize,
352  serde::Deserialize,
353)]
354#[serde(rename_all = "camelCase", deny_unknown_fields)]
355pub struct NotarizationMetadata {
356  /// The notary service that decided.
357  pub notary_service: NotaryServiceRef,
358  /// RFC 3339 decision timestamp.
359  pub decision_timestamp: String,
360  /// Decision latency in milliseconds — audit evidence for whether a human
361  /// was plausibly in the loop.
362  pub decision_latency_ms: u64,
363}
364
365/// Identity of the notary service, used for key discovery (spec §8.4).
366#[derive(
367  std::fmt::Debug,
368  std::clone::Clone,
369  std::cmp::PartialEq,
370  std::cmp::Eq,
371  serde::Serialize,
372  serde::Deserialize,
373)]
374#[serde(rename_all = "camelCase", deny_unknown_fields)]
375pub struct NotaryServiceRef {
376  /// DID of the notary.
377  pub id: String,
378  /// Human-readable notary service name.
379  pub name: String,
380  /// Notary implementation version.
381  pub version: String,
382  /// Content digest of the attested release this notary reports running
383  /// (spec §7.1.9, §15.3). Declared here because parsing is strict: a
384  /// field a conformant notary may send must exist in the shape a
385  /// conformant verifier parses. `#[serde(default)]` keeps every envelope
386  /// written before this field byte-identical.
387  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
388  pub attested_digest: std::option::Option<String>,
389  /// Where the k-of-3 attestation for `attested_digest` may be fetched
390  /// (spec §15.3). Carrying it proves nothing on its own — an attestation
391  /// attests what was PUBLISHED, never what is RUNNING (§15.7).
392  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
393  pub attestation_uri: std::option::Option<String>,
394}
395
396/// Cross-protocol links carried alongside the send authorization.
397#[derive(
398  std::fmt::Debug,
399  std::clone::Clone,
400  std::cmp::PartialEq,
401  std::cmp::Eq,
402  serde::Serialize,
403  serde::Deserialize,
404)]
405#[serde(rename_all = "camelCase", deny_unknown_fields)]
406pub struct LinkedMandate {
407  /// URI of an AP2 IntentMandate cross-linking payment authorization.
408  #[serde(default)]
409  pub ap2_intent_mandate_uri: std::option::Option<String>,
410  /// Optional base64-encoded AP2 SignedPayload for self-contained
411  /// verification when the verifier cannot dereference
412  /// `ap2_intent_mandate_uri`. `#[serde(default)]` preserves wire
413  /// back-compat for envelopes written before this field was added.
414  #[serde(default)]
415  pub ap2_signed_payload_b64: std::option::Option<String>,
416  /// Optional cross-vault mutation mandate for LinkedMandates issued by a
417  /// cross-vault federation engine.
418  /// `#[serde(default, skip_serializing_if = "...")]` preserves
419  /// wire back-compat (legacy envelopes omit the field).
420  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
421  pub vault_mutation: std::option::Option<
422    crate::vault_mutation::VaultMutationMandate,
423  >,
424}
425
426#[derive(
427  std::fmt::Debug,
428  std::clone::Clone,
429  std::cmp::PartialEq,
430  std::cmp::Eq,
431  serde::Serialize,
432  serde::Deserialize,
433)]
434/// The cryptographic proof block: either a W3C Data Integrity Proof or a
435/// detached JWS, per spec §8.2.
436#[serde(rename_all = "camelCase", deny_unknown_fields)]
437pub struct EnvelopeProof {
438  /// Always `"DataIntegrityProof"` for the JCS-canonicalized cryptosuites,
439  /// or `"JsonWebSignature2020"` for compact JWS detached.
440  #[serde(rename = "type")]
441  pub r#type: String,
442  /// Required for DataIntegrityProof. e.g., `"eddsa-jcs-2022"` or
443  /// `"ecdsa-jcs-2019"`.
444  #[serde(default)]
445  pub cryptosuite: std::option::Option<String>,
446  /// DID URL referencing the verifying key
447  /// (e.g., `did:key:z6Mk...#z6Mk...`).
448  pub verification_method: String,
449  /// RFC 3339.
450  pub created: String,
451  /// `"assertionMethod"` for a principal proof or a lone notary proof;
452  /// `"authentication"` for the notary countersignature of a chain (§7.1.11).
453  pub proof_purpose: String,
454  /// Multibase or base64url-encoded signature bytes.
455  pub proof_value: String,
456  /// Identifier for this proof, unique within the envelope. REQUIRED when
457  /// `proof` is a chain, absent for a lone proof, which links to nothing.
458  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
459  pub id: std::option::Option<String>,
460  /// The `id` of the proof this one countersigns. Present on the notary
461  /// proof of a chain; absent on the principal proof, its head.
462  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
463  pub previous_proof: std::option::Option<String>,
464}
465
466/// Last-position sibling struct. Carries Apple Foundation Models AUR
467/// acceptance per `(user_id, device_id, aur_version_hash)` tuple.
468/// Embedded as `Option<AppleAurAcceptanceClaim>` on
469/// `CredentialSubject.apple_aur_acceptance`. The field is
470/// `#[serde(default, skip_serializing_if = "...")]` so legacy envelopes that
471/// predate this claim continue to deserialize cleanly.
472#[derive(
473  std::fmt::Debug,
474  std::clone::Clone,
475  std::cmp::PartialEq,
476  std::cmp::Eq,
477  serde::Serialize,
478  serde::Deserialize,
479)]
480#[serde(rename_all = "camelCase", deny_unknown_fields)]
481pub struct AppleAurAcceptanceClaim {
482  /// User-scoped DID for whom acceptance was recorded.
483  pub user_id: String,
484  /// Device-scoped opaque identifier (acceptance is recorded per
485  /// `(user_id, device_id)` pair).
486  pub device_id: String,
487  /// SHA-256 hex of the Apple AUR snapshot text accepted.
488  pub aur_version_hash: String,
489  /// RFC 3339 acceptance timestamp.
490  pub accepted_at: String,
491  /// `"foundation_models_framework_aur"` for forward-compat with future Apple legal documents.
492  pub document_kind: String,
493}
494
495#[cfg(test)]
496mod tests {
497  // -------- helpers --------
498
499  fn sample_human_principal() -> super::HumanPrincipalRef {
500    super::HumanPrincipalRef {
501      id: "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy".to_string(),
502      display_name: "Scott Wyatt".to_string(),
503    }
504  }
505
506  fn sample_agent() -> super::AgentRef {
507    super::AgentRef {
508      id: "did:web:agent.squillo.com".to_string(),
509      agent_card_uri: std::option::Option::Some(
510        "https://agent.squillo.com/.well-known/agent-card.json".to_string(),
511      ),
512      display_name: "Squillo Concierge".to_string(),
513      version: "1.0".to_string(),
514    }
515  }
516
517  fn sample_channel() -> super::ChannelDescriptor {
518    super::ChannelDescriptor {
519      kind: "slack".to_string(),
520      recipient_addressing: serde_json::json!({
521        "teamId": "T01234567",
522        "channelId": "C01234567",
523        "parentTs": "1716249600.000100"
524      }),
525    }
526  }
527
528  fn sample_communication() -> super::CommunicationDescriptor {
529    super::CommunicationDescriptor {
530      content_class: "Reply".to_string(),
531      body_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
532      body_size: 1842,
533      preview_lines: 3,
534      preview: "hello world".to_string(),
535    }
536  }
537
538  fn sample_policy() -> super::PolicyDescriptor {
539    super::PolicyDescriptor {
540      decision: "AskEveryTime".to_string(),
541      matched_scope: "per-channel".to_string(),
542      delegation_mandate_id: std::option::Option::None,
543      act_chain: std::vec::Vec::new(),
544      attestation_mode: std::option::Option::None,
545      delegation_mandate: std::option::Option::None,
546    }
547  }
548
549  #[test]
550  fn attestation_fields_are_omitted_when_absent_and_accepted_when_present() {
551    // The two §15.3 attestation fields were added to a struct that parses
552    // with `deny_unknown_fields` and whose output is signed. Two things must
553    // both hold: a notary that makes no attestation claim serializes
554    // byte-identically to before the fields existed (or every signature over
555    // an existing envelope breaks), and a notary that does make one is not
556    // rejected as carrying unknown fields (or conformant verifiers would
557    // refuse conformant notaries).
558    let bare = sample_notary_service();
559    let json = serde_json::to_string(&bare).unwrap();
560    std::assert!(
561      !json.contains("attestedDigest") && !json.contains("attestationUri"),
562      "absent attestation fields must not appear on the wire: {}",
563      json
564    );
565
566    let with_claim: super::NotaryServiceRef = serde_json::from_str(
567      r#"{"id":"did:web:notary.squillo.com","name":"Squillo Notary Service","version":"0.1.0","attestedDigest":"sha256:abc","attestationUri":"https://notary.squillo.com/.well-known/aph-attestation.json"}"#,
568    )
569    .expect("a notary advertising an attestation must parse");
570    std::assert_eq!(
571      with_claim.attested_digest.as_deref(),
572      std::option::Option::Some("sha256:abc")
573    );
574  }
575
576  fn sample_notary_service() -> super::NotaryServiceRef {
577    super::NotaryServiceRef {
578      id: "did:web:notary.squillo.com".to_string(),
579      name: "Squillo Notary Service".to_string(),
580      version: "0.1.0".to_string(),
581      attested_digest: std::option::Option::None,
582      attestation_uri: std::option::Option::None,
583    }
584  }
585
586  fn sample_notarization_metadata() -> super::NotarizationMetadata {
587    super::NotarizationMetadata {
588      notary_service: sample_notary_service(),
589      decision_timestamp: "2026-05-21T00:00:01Z".to_string(),
590      decision_latency_ms: 1834,
591    }
592  }
593
594  fn sample_credential_subject() -> super::CredentialSubject {
595    super::CredentialSubject {
596      human_principal: sample_human_principal(),
597      agent: sample_agent(),
598      channel: sample_channel(),
599      communication: sample_communication(),
600      policy: sample_policy(),
601      notarization: sample_notarization_metadata(),
602      apple_aur_acceptance: std::option::Option::None,
603    }
604  }
605
606  fn sample_linked_mandate() -> super::LinkedMandate {
607    super::LinkedMandate {
608      ap2_intent_mandate_uri: std::option::Option::Some(
609        "urn:uuid:11111111-1111-4111-8111-111111111111".to_string(),
610      ),
611      ap2_signed_payload_b64: std::option::Option::None,
612      vault_mutation: std::option::Option::None,
613    }
614  }
615
616  fn sample_proof() -> super::EnvelopeProof {
617    super::EnvelopeProof {
618      r#type: "DataIntegrityProof".to_string(),
619      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
620      verification_method:
621        "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
622          .to_string(),
623      created: "2026-05-21T00:00:01Z".to_string(),
624      proof_purpose: "assertionMethod".to_string(),
625      proof_value:
626        "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
627          .to_string(),
628      id: std::option::Option::None,
629      previous_proof: std::option::Option::None,
630    }
631  }
632
633  fn sample_envelope() -> super::NotarizationEnvelope {
634    super::NotarizationEnvelope {
635      aph_version: "0.1".to_string(),
636      context: std::vec![
637        "https://www.w3.org/ns/credentials/v2".to_string(),
638        "https://w3id.org/aph/v1".to_string(),
639      ],
640      r#type: std::vec![
641        "VerifiableCredential".to_string(),
642        "AgentSendAuthorizationCredential".to_string(),
643      ],
644      id: "urn:uuid:00000000-0000-4000-8000-000000000001".to_string(),
645      issuer: "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV".to_string(),
646      valid_from: "2026-05-21T00:00:00Z".to_string(),
647      valid_until: "2026-05-22T00:00:00Z".to_string(),
648      credential_subject: sample_credential_subject(),
649      linked_mandate: std::option::Option::None,
650      // Pattern A (§7.1.1): absent here means NO `credentialStatus` key on
651      // the wire, which is what keeps this fixture byte-identical to the
652      // pre-revocation shape its signatures were made over.
653      credential_status: std::option::Option::None,
654      proof: super::EnvelopeProofs::Single(sample_proof()),
655    }
656  }
657
658  fn sample_principal_proof() -> super::EnvelopeProof {
659    super::EnvelopeProof {
660      r#type: "DataIntegrityProof".to_string(),
661      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
662      // The human principal's own DID URL — the same DID
663      // `sample_human_principal()` carries, which is what §7.1.11 binds.
664      verification_method:
665        "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy#z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy"
666          .to_string(),
667      created: "2026-05-21T00:00:02Z".to_string(),
668      proof_purpose: "assertionMethod".to_string(),
669      proof_value: "z-illustrative-principal-proof-value".to_string(),
670      id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string()),
671      previous_proof: std::option::Option::None,
672    }
673  }
674
675  fn sample_notary_countersignature() -> super::EnvelopeProof {
676    super::EnvelopeProof {
677      r#type: "DataIntegrityProof".to_string(),
678      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
679      verification_method:
680        "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
681          .to_string(),
682      created: "2026-05-21T00:00:03Z".to_string(),
683      proof_purpose: "authentication".to_string(),
684      proof_value: "z-illustrative-notary-countersignature".to_string(),
685      id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000b1".to_string()),
686      previous_proof: std::option::Option::Some(
687        "urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string(),
688      ),
689    }
690  }
691
692  // -------- Test 1: per-struct round-trip --------
693
694  #[test]
695  fn round_trip_human_principal_ref() {
696    // Per-struct round-trips exist because deny_unknown_fields makes every
697    // sub-struct independently strict: a field lost or renamed here would
698    // fail only when that specific object is parsed. This one carries the
699    // human's DID — the identity the whole credential attests.
700    let v = sample_human_principal();
701    let s = serde_json::to_string(&v).unwrap();
702    let v2: super::HumanPrincipalRef = serde_json::from_str(&s).unwrap();
703    std::assert_eq!(v, v2);
704  }
705
706  #[test]
707  fn round_trip_agent_ref() {
708    // The agent identity a recipient checks against the delegation; its
709    // optional agentCardUri must survive round-tripping alongside the
710    // required fields.
711    let v = sample_agent();
712    let s = serde_json::to_string(&v).unwrap();
713    let v2: super::AgentRef = serde_json::from_str(&s).unwrap();
714    std::assert_eq!(v, v2);
715  }
716
717  #[test]
718  fn round_trip_channel_descriptor() {
719    // Holds the opaque recipientAddressing blob (§7.4), the one place
720    // arbitrary JSON is preserved verbatim — this pins that it survives
721    // untouched rather than being normalized or dropped.
722    let v = sample_channel();
723    let s = serde_json::to_string(&v).unwrap();
724    let v2: super::ChannelDescriptor = serde_json::from_str(&s).unwrap();
725    std::assert_eq!(v, v2);
726  }
727
728  #[test]
729  fn round_trip_communication_descriptor() {
730    // Carries bodySha256 and bodySize — the binding between the credential
731    // and the actual message. Any loss here breaks the APH_E009 body-hash
732    // check that stops a signature being reused for a different body.
733    let v = sample_communication();
734    let s = serde_json::to_string(&v).unwrap();
735    let v2: super::CommunicationDescriptor = serde_json::from_str(&s).unwrap();
736    std::assert_eq!(v, v2);
737  }
738
739  #[test]
740  fn round_trip_policy_descriptor() {
741    // The authorization record itself (decision, matched scope, delegation
742    // id, act chain). Dropping actChain or delegationMandateId would erase
743    // the evidence a verifier uses to trace authority back to the human.
744    let v = sample_policy();
745    let s = serde_json::to_string(&v).unwrap();
746    let v2: super::PolicyDescriptor = serde_json::from_str(&s).unwrap();
747    std::assert_eq!(v, v2);
748  }
749
750  #[test]
751  fn round_trip_notary_service_ref() {
752    // Identifies which notary made the decision — needed for key discovery
753    // (§8.4) and for auditing which service to hold accountable.
754    let v = sample_notary_service();
755    let s = serde_json::to_string(&v).unwrap();
756    let v2: super::NotaryServiceRef = serde_json::from_str(&s).unwrap();
757    std::assert_eq!(v, v2);
758  }
759
760  #[test]
761  fn round_trip_notarization_metadata() {
762    // Decision timestamp and latency: audit evidence for whether a human
763    // was plausibly in the loop, so it must survive intact.
764    let v = sample_notarization_metadata();
765    let s = serde_json::to_string(&v).unwrap();
766    let v2: super::NotarizationMetadata = serde_json::from_str(&s).unwrap();
767    std::assert_eq!(v, v2);
768  }
769
770  #[test]
771  fn round_trip_credential_subject() {
772    // Composition test: the six sub-objects must nest correctly together.
773    // Per-struct round-trips above cannot catch a broken assembly (e.g. a
774    // field attached at the wrong level).
775    let v = sample_credential_subject();
776    let s = serde_json::to_string(&v).unwrap();
777    let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
778    std::assert_eq!(v, v2);
779  }
780
781  #[test]
782  fn round_trip_linked_mandate() {
783    // Cross-protocol link (AP2 payment authorization, vault mutation). It
784    // holds three independently-optional fields, so round-tripping proves
785    // none of them is lost when the others are absent.
786    let v = sample_linked_mandate();
787    let s = serde_json::to_string(&v).unwrap();
788    let v2: super::LinkedMandate = serde_json::from_str(&s).unwrap();
789    std::assert_eq!(v, v2);
790  }
791
792  #[test]
793  fn round_trip_envelope_proof() {
794    // The signature block itself. If any proof field failed to round-trip,
795    // a re-serialized envelope would carry an unverifiable proof.
796    let v = sample_proof();
797    let s = serde_json::to_string(&v).unwrap();
798    let v2: super::EnvelopeProof = serde_json::from_str(&s).unwrap();
799    std::assert_eq!(v, v2);
800  }
801
802  #[test]
803  fn round_trip_notarization_envelope() {
804    // The whole credential end to end — the exact operation a relaying
805    // verifier performs. This is the last line of defense against any
806    // field being silently dropped in transit.
807    let v = sample_envelope();
808    let s = serde_json::to_string(&v).unwrap();
809    let v2: super::NotarizationEnvelope = serde_json::from_str(&s).unwrap();
810    std::assert_eq!(v, v2);
811  }
812
813  // -------- Test 2: wire-key shape for reserved/JSON-LD names --------
814
815  #[test]
816  fn envelope_serializes_type_as_type_and_context_as_at_context() {
817    // Two JSON-LD keys Rust cannot name directly: "@context" (illegal
818    // identifier) and "type" (keyword, written r#type). Both depend on
819    // explicit #[serde(rename)] attributes — drop one and the envelope
820    // stops being a valid W3C Verifiable Credential.
821    let v = sample_envelope();
822    let s = serde_json::to_string(&v).unwrap();
823    std::assert!(
824      s.contains("\"type\":"),
825      "envelope must serialize r#type as \"type\": {}",
826      s
827    );
828    std::assert!(
829      s.contains("\"@context\":"),
830      "envelope must serialize context as \"@context\": {}",
831      s
832    );
833    std::assert!(
834      !s.contains("\"rType\""),
835      "envelope must NOT serialize as \"rType\": {}",
836      s
837    );
838    std::assert!(
839      !s.contains("\"r#type\""),
840      "envelope must NOT serialize as \"r#type\": {}",
841      s
842    );
843  }
844
845  #[test]
846  fn proof_serializes_type_as_type() {
847    // Same r#type rename, applied on the nested proof block — pinned
848    // separately because the attribute must be repeated per struct and is
849    // easy to omit on a newly added one.
850    let v = sample_proof();
851    let s = serde_json::to_string(&v).unwrap();
852    std::assert!(
853      s.contains("\"type\":"),
854      "proof must serialize r#type as \"type\": {}",
855      s
856    );
857    std::assert!(
858      !s.contains("\"rType\""),
859      "proof must NOT serialize as \"rType\": {}",
860      s
861    );
862  }
863
864  // -------- Test 3: deny_unknown_fields rejection --------
865
866  #[test]
867  fn envelope_rejects_unknown_field() {
868    // Strict parsing (§7.1, §8.3 step 1) is normative: an unknown envelope
869    // field must be a hard error. Accepting-and-ignoring would let a
870    // producer smuggle a claim the verifier never evaluates.
871    let s = serde_json::json!({
872      "aphVersion": "0.1",
873      "@context": [
874        "https://www.w3.org/ns/credentials/v2",
875        "https://w3id.org/aph/v1"
876      ],
877      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
878      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
879      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
880      "validFrom": "2026-05-21T00:00:00Z",
881      "validUntil": "2026-05-22T00:00:00Z",
882      "credentialSubject": {
883        "humanPrincipal": {
884          "id": "did:key:abc",
885          "displayName": "X"
886        },
887        "agent": {
888          "id": "did:web:agent.squillo.com",
889          "displayName": "X",
890          "version": "1.0"
891        },
892        "channel": {
893          "kind": "slack",
894          "recipientAddressing": {}
895        },
896        "communication": {
897          "contentClass": "Reply",
898          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
899          "bodySize": 0,
900          "previewLines": 0,
901          "preview": ""
902        },
903        "policy": {
904          "decision": "AskEveryTime",
905          "matchedScope": "per-channel"
906        },
907        "notarization": {
908          "notaryService": {
909            "id": "did:web:notary.squillo.com",
910            "name": "Squillo Notary Service",
911            "version": "0.1.0"
912          },
913          "decisionTimestamp": "2026-05-21T00:00:01Z",
914          "decisionLatencyMs": 0
915        }
916      },
917      "proof": {
918        "type": "DataIntegrityProof",
919        "verificationMethod": "did:key:abc#abc",
920        "created": "2026-05-21T00:00:01Z",
921        "proofPurpose": "assertionMethod",
922        "proofValue": "z..."
923      },
924      "extraKey": "x"
925    })
926    .to_string();
927    let r: std::result::Result<super::NotarizationEnvelope, _> = serde_json::from_str(&s);
928    std::assert!(
929      r.is_err(),
930      "deny_unknown_fields must reject extraKey: {:?}",
931      r
932    );
933  }
934
935  #[test]
936  fn human_principal_ref_rejects_unknown_field() {
937    // deny_unknown_fields does NOT cascade to nested structs — each one
938    // needs its own attribute. This pins that the nested case is covered,
939    // not merely the top level.
940    let s = serde_json::json!({
941      "id": "did:key:abc",
942      "displayName": "X",
943      "rogue": true
944    })
945    .to_string();
946    let r: std::result::Result<super::HumanPrincipalRef, _> = serde_json::from_str(&s);
947    std::assert!(
948      r.is_err(),
949      "deny_unknown_fields must reject rogue field: {:?}",
950      r
951    );
952  }
953
954  // -------- Test 4: defaults on optional fields --------
955
956  #[test]
957  fn envelope_deserializes_without_linked_mandate() {
958    // Most envelopes carry no cross-protocol link, so the common case must
959    // parse with the key absent — under deny_unknown_fields this works
960    // only while #[serde(default)] is present.
961    let s = serde_json::json!({
962      "aphVersion": "0.1",
963      "@context": [
964        "https://www.w3.org/ns/credentials/v2",
965        "https://w3id.org/aph/v1"
966      ],
967      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
968      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
969      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
970      "validFrom": "2026-05-21T00:00:00Z",
971      "validUntil": "2026-05-22T00:00:00Z",
972      "credentialSubject": {
973        "humanPrincipal": {
974          "id": "did:key:abc",
975          "displayName": "X"
976        },
977        "agent": {
978          "id": "did:web:agent.squillo.com",
979          "displayName": "X",
980          "version": "1.0"
981        },
982        "channel": {
983          "kind": "slack",
984          "recipientAddressing": {}
985        },
986        "communication": {
987          "contentClass": "Reply",
988          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
989          "bodySize": 0,
990          "previewLines": 0,
991          "preview": ""
992        },
993        "policy": {
994          "decision": "AskEveryTime",
995          "matchedScope": "per-channel"
996        },
997        "notarization": {
998          "notaryService": {
999            "id": "did:web:notary.squillo.com",
1000            "name": "Squillo Notary Service",
1001            "version": "0.1.0"
1002          },
1003          "decisionTimestamp": "2026-05-21T00:00:01Z",
1004          "decisionLatencyMs": 0
1005        }
1006      },
1007      "proof": {
1008        "type": "DataIntegrityProof",
1009        "verificationMethod": "did:key:abc#abc",
1010        "created": "2026-05-21T00:00:01Z",
1011        "proofPurpose": "assertionMethod",
1012        "proofValue": "z..."
1013      }
1014    })
1015    .to_string();
1016    let v: super::NotarizationEnvelope =
1017      serde_json::from_str(&s).expect("must deserialize with linkedMandate omitted");
1018    std::assert!(v.linked_mandate.is_none());
1019  }
1020
1021  #[test]
1022  fn envelope_round_trips_a_credential_status_under_the_camel_case_wire_key() {
1023    // Why this exists: until it did, NOTHING in the repo parsed or emitted a
1024    // `credentialStatus` ON THE WIRE. The §6.3.3 tests set the field in Rust
1025    // and the golden test only asserts the key is ABSENT, so the camelCase
1026    // spelling the field exists to produce — and `deny_unknown_fields`
1027    // accepting it on `NotarizationEnvelope` at all — were pinned by nothing.
1028    // A rename to `credential_status`, or the field being dropped from the
1029    // struct, would have passed the entire suite while making every
1030    // status-bearing envelope from a conformant notary unparseable.
1031    //
1032    // What it pins, in order: the key is ACCEPTED by the strict parser; the
1033    // nested entry lands in the typed field with its closed enums resolved;
1034    // and serializing re-emits under exactly `credentialStatus` with exactly
1035    // the four required members (Pattern A drops the absent `id` — a `null`
1036    // there would change the JCS bytes a proof covers).
1037    let s = serde_json::json!({
1038      "aphVersion": "0.1",
1039      "@context": [
1040        "https://www.w3.org/ns/credentials/v2",
1041        "https://w3id.org/aph/v1"
1042      ],
1043      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
1044      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
1045      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
1046      "validFrom": "2026-05-21T00:00:00Z",
1047      "validUntil": "2026-05-22T00:00:00Z",
1048      "credentialSubject": {
1049        "humanPrincipal": {
1050          "id": "did:key:abc",
1051          "displayName": "X"
1052        },
1053        "agent": {
1054          "id": "did:web:agent.squillo.com",
1055          "displayName": "X",
1056          "version": "1.0"
1057        },
1058        "channel": {
1059          "kind": "slack",
1060          "recipientAddressing": {}
1061        },
1062        "communication": {
1063          "contentClass": "Reply",
1064          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1065          "bodySize": 0,
1066          "previewLines": 0,
1067          "preview": ""
1068        },
1069        "policy": {
1070          "decision": "AskEveryTime",
1071          "matchedScope": "per-channel",
1072          // §6.3.3.1 forbids a status reference with no parent mandate to be
1073          // the status OF, so the wire sample carries both or it is not a
1074          // sample of anything a notary may emit.
1075          "delegationMandateId": "urn:uuid:00000000-0000-4000-8000-0000000000d1"
1076        },
1077        "notarization": {
1078          "notaryService": {
1079            "id": "did:web:aph-notary.squillo.com",
1080            "name": "Squillo Notary Service",
1081            "version": "0.1.0"
1082          },
1083          "decisionTimestamp": "2026-05-21T00:00:01Z",
1084          "decisionLatencyMs": 0
1085        }
1086      },
1087      "credentialStatus": {
1088        "type": "BitstringStatusListEntry",
1089        "statusPurpose": "revocation",
1090        "statusListIndex": "94567",
1091        "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
1092      },
1093      "proof": {
1094        "type": "DataIntegrityProof",
1095        "verificationMethod": "did:key:abc#abc",
1096        "created": "2026-05-21T00:00:01Z",
1097        "proofPurpose": "assertionMethod",
1098        "proofValue": "z..."
1099      }
1100    })
1101    .to_string();
1102    let v: super::NotarizationEnvelope = serde_json::from_str(&s)
1103      .expect("deny_unknown_fields must ACCEPT the credentialStatus key");
1104    let entry = v
1105      .credential_status
1106      .as_ref()
1107      .expect("the wire key must land in the typed field");
1108    std::assert_eq!(
1109      entry.r#type,
1110      crate::credential_status::StatusEntryType::BitstringStatusListEntry
1111    );
1112    std::assert_eq!(
1113      entry.status_purpose,
1114      crate::credential_status::StatusPurpose::Revocation
1115    );
1116    std::assert_eq!(entry.index().expect("the index is readable"), 94567u64);
1117
1118    // Re-emitted under the SAME key: compared as parsed JSON, not as a
1119    // substring, so a serializer that wrote the right characters in the
1120    // wrong place could not pass.
1121    let back = serde_json::to_string(&v).expect("envelope serializes");
1122    let reparsed: serde_json::Value = serde_json::from_str(&back).expect("round-trip is JSON");
1123    let emitted = reparsed
1124      .get("credentialStatus")
1125      .expect("the member must be re-emitted as `credentialStatus`");
1126    std::assert_eq!(
1127      emitted,
1128      &serde_json::json!({
1129        "type": "BitstringStatusListEntry",
1130        "statusPurpose": "revocation",
1131        "statusListIndex": "94567",
1132        "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
1133      }),
1134      "the absent optional `id` must not appear, as a null or otherwise"
1135    );
1136    // And the member is INSIDE the bytes the notary proof covers. That is
1137    // what makes the reference unstrippable in flight: an attacker who
1138    // deleted `credentialStatus` to force §6.3.3.4 case 1 — the skip — would
1139    // be altering the signing base, and the proof would stop verifying.
1140    // Asserted on the canonical base rather than on the struct because the
1141    // base is the only place the property is actually true or false.
1142    let base = crate::crypto::proof_base::signing_base(
1143      &v,
1144      crate::crypto::proof_base::ProofRole::Notary,
1145    )
1146    .expect("a single notary proof builds a notary base");
1147    std::assert!(
1148      base.contains("\"credentialStatus\""),
1149      "the status reference must be covered by the notary signature"
1150    );
1151  }
1152
1153  #[test]
1154  fn agent_ref_deserializes_without_agent_card_uri() {
1155    // Not every agent publishes an A2A AgentCard; omitting the URI must
1156    // stay legal rather than making those agents unable to be notarized.
1157    let s = serde_json::json!({
1158      "id": "did:web:agent.squillo.com",
1159      "displayName": "X",
1160      "version": "1.0"
1161    })
1162    .to_string();
1163    let v: super::AgentRef =
1164      serde_json::from_str(&s).expect("must deserialize with agentCardUri omitted");
1165    std::assert!(v.agent_card_uri.is_none());
1166  }
1167
1168  #[test]
1169  fn policy_descriptor_deserializes_without_optionals() {
1170    // The one-shot AskEveryTime shape has no delegation and no act chain,
1171    // so both optional fields must be omissible together — the defaults
1172    // that make a human-present decision representable at all.
1173    let s = serde_json::json!({
1174      "decision": "AskEveryTime",
1175      "matchedScope": "per-channel"
1176    })
1177    .to_string();
1178    let v: super::PolicyDescriptor =
1179      serde_json::from_str(&s).expect("must deserialize with optionals omitted");
1180    std::assert!(v.delegation_mandate_id.is_none());
1181    std::assert!(v.act_chain.is_empty());
1182    std::assert!(v.attestation_mode.is_none());
1183    std::assert!(v.delegation_mandate.is_none());
1184  }
1185
1186  #[test]
1187  fn linked_mandate_deserializes_without_ap2_uri() {
1188    // A linkedMandate may carry a vault mutation with no payment link, so
1189    // its fields must be independently optional rather than all-or-nothing.
1190    let s = serde_json::json!({}).to_string();
1191    let v: super::LinkedMandate =
1192      serde_json::from_str(&s).expect("must deserialize with ap2IntentMandateUri omitted");
1193    std::assert!(v.ap2_intent_mandate_uri.is_none());
1194  }
1195
1196  #[test]
1197  fn envelope_proof_deserializes_without_cryptosuite() {
1198    // cryptosuite applies to DataIntegrityProof but not to the
1199    // JsonWebSignature2020 form (§8.2), so a JWS-style proof must parse
1200    // without it instead of being rejected as malformed.
1201    let s = serde_json::json!({
1202      "type": "JsonWebSignature2020",
1203      "verificationMethod": "did:key:abc#abc",
1204      "created": "2026-05-21T00:00:01Z",
1205      "proofPurpose": "assertionMethod",
1206      "proofValue": "z..."
1207    })
1208    .to_string();
1209    let v: super::EnvelopeProof =
1210      serde_json::from_str(&s).expect("must deserialize with cryptosuite omitted");
1211    std::assert!(v.cryptosuite.is_none());
1212  }
1213
1214  // -------- Test 5: camelCase wire form --------
1215
1216  #[test]
1217  fn human_principal_ref_serializes_camel_case() {
1218    // Rust field names are snake_case; the wire is camelCase. This pins
1219    // that the rename_all attribute is actually applied — without it every
1220    // key would silently change and no other implementation could parse it.
1221    let v = super::HumanPrincipalRef {
1222      id: "did:key:abc".to_string(),
1223      display_name: "Scott".to_string(),
1224    };
1225    let s = serde_json::to_string(&v).unwrap();
1226    std::assert!(
1227      s.contains("\"displayName\""),
1228      "must serialize display_name as displayName: {}",
1229      s
1230    );
1231    std::assert!(
1232      !s.contains("\"display_name\""),
1233      "must NOT serialize as display_name: {}",
1234      s
1235    );
1236  }
1237
1238  // -------- Test 6: AppleAurAcceptanceClaim round-trip --------
1239
1240  #[test]
1241  fn apple_aur_acceptance_claim_round_trip() {
1242    // Registered optional extension (spec §7.5.1). It must round-trip
1243    // fully when present while staying absent-by-default, so that
1244    // extension-free envelopes keep their exact pre-extension bytes.
1245    let claim = super::AppleAurAcceptanceClaim {
1246      user_id: "did:key:z6MkUserAbc123".to_string(),
1247      device_id: "device-opaque-id-001".to_string(),
1248      aur_version_hash:
1249        "a3b4c5d6e7f8091011121314151617181920212223242526272829303132333435".to_string(),
1250      accepted_at: "2026-06-09T00:00:00Z".to_string(),
1251      document_kind: "foundation_models_framework_aur".to_string(),
1252    };
1253    let subject = super::CredentialSubject {
1254      human_principal: sample_human_principal(),
1255      agent: sample_agent(),
1256      channel: sample_channel(),
1257      communication: sample_communication(),
1258      policy: sample_policy(),
1259      notarization: sample_notarization_metadata(),
1260      apple_aur_acceptance: std::option::Option::Some(claim.clone()),
1261    };
1262    let s = serde_json::to_string(&subject).unwrap();
1263    // wire form must use camelCase key
1264    std::assert!(
1265      s.contains("\"appleAurAcceptance\""),
1266      "must serialize apple_aur_acceptance as appleAurAcceptance: {}",
1267      s
1268    );
1269    let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
1270    std::assert_eq!(subject, v2);
1271    let recovered = v2.apple_aur_acceptance.expect("must be Some after round-trip");
1272    std::assert_eq!(recovered.document_kind, "foundation_models_framework_aur");
1273    std::assert_eq!(recovered.user_id, claim.user_id);
1274    std::assert_eq!(recovered.device_id, claim.device_id);
1275    std::assert_eq!(recovered.aur_version_hash, claim.aur_version_hash);
1276    std::assert_eq!(recovered.accepted_at, claim.accepted_at);
1277  }
1278
1279  // -------- Test 7: legacy wire back-compat (field absent) --------
1280
1281  #[test]
1282  fn credential_subject_legacy_omit_apple_aur_acceptance() {
1283    // A legacy CredentialSubject JSON payload that does NOT contain
1284    // `appleAurAcceptance` must still deserialize cleanly with
1285    // `apple_aur_acceptance == None` (wire back-compat).
1286    let s = serde_json::json!({
1287      "humanPrincipal": {
1288        "id": "did:key:z6MkLegacyUser",
1289        "displayName": "Legacy User"
1290      },
1291      "agent": {
1292        "id": "did:web:agent.squillo.com",
1293        "displayName": "Squillo Concierge",
1294        "version": "1.0"
1295      },
1296      "channel": {
1297        "kind": "slack",
1298        "recipientAddressing": {}
1299      },
1300      "communication": {
1301        "contentClass": "Reply",
1302        "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1303        "bodySize": 0,
1304        "previewLines": 0,
1305        "preview": ""
1306      },
1307      "policy": {
1308        "decision": "AskEveryTime",
1309        "matchedScope": "per-channel"
1310      },
1311      "notarization": {
1312        "notaryService": {
1313          "id": "did:web:notary.squillo.com",
1314          "name": "Squillo Notary Service",
1315          "version": "0.1.0"
1316        },
1317        "decisionTimestamp": "2026-05-21T00:00:01Z",
1318        "decisionLatencyMs": 0
1319      }
1320    })
1321    .to_string();
1322    let v: super::CredentialSubject = serde_json::from_str(&s)
1323      .expect("legacy payload without appleAurAcceptance must deserialize cleanly");
1324    std::assert!(
1325      v.apple_aur_acceptance.is_none(),
1326      "apple_aur_acceptance must be None when absent from legacy wire payload"
1327    );
1328  }
1329
1330  // -------- Test 8: proof-chain wire forms (§7.1.11) --------
1331
1332  #[test]
1333  fn envelope_proofs_round_trips_the_single_object_wire_form() {
1334    // `proof` is untagged: the JSON SHAPE is the only discriminator between
1335    // a lone notary proof and a chain. If the object form ever deserialized
1336    // into `Chain` (or failed), every one of the eight published envelopes
1337    // — all of which carry a single object — would stop parsing.
1338    let single = super::EnvelopeProofs::Single(sample_proof());
1339    let s = serde_json::to_string(&single).unwrap();
1340    std::assert!(
1341      s.starts_with('{'),
1342      "the single form must serialize as a JSON OBJECT, not an array: {}",
1343      s
1344    );
1345    let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
1346    std::assert_eq!(single, back);
1347  }
1348
1349  #[test]
1350  fn envelope_proofs_round_trips_the_array_wire_form() {
1351    // The other half of the untagged discrimination. §7.2.1 makes the array
1352    // form load-bearing: `[{…}]` and `{…}` canonicalize to different bytes,
1353    // which is what domain-separates a principal proof from a lone notary
1354    // proof. Collapsing the array to an object on re-serialization would
1355    // silently forge that domain separation away.
1356    let chain = super::EnvelopeProofs::Chain(std::vec![
1357      sample_principal_proof(),
1358      sample_notary_countersignature(),
1359    ]);
1360    let s = serde_json::to_string(&chain).unwrap();
1361    std::assert!(
1362      s.starts_with('['),
1363      "the chain form must serialize as a JSON ARRAY: {}",
1364      s
1365    );
1366    let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
1367    std::assert_eq!(chain, back);
1368  }
1369
1370  #[test]
1371  fn envelope_with_absent_additive_fields_emits_none_of_their_keys() {
1372    // `attestationMode`, `id` and `previousProof` were added to structs
1373    // whose serialized bytes are what signatures cover. An envelope that
1374    // sets none of them MUST serialize byte-identically to one written
1375    // before the fields existed, or every already-issued signature breaks.
1376    let json = serde_json::to_string(&sample_envelope()).unwrap();
1377    std::assert!(
1378      !json.contains("attestationMode"),
1379      "absent attestationMode must not appear on the wire: {}",
1380      json
1381    );
1382    std::assert!(
1383      !json.contains("delegationMandate\""),
1384      "absent embedded delegationMandate must not appear on the wire: {}",
1385      json
1386    );
1387    std::assert!(
1388      !json.contains("previousProof"),
1389      "absent previousProof must not appear on the wire: {}",
1390      json
1391    );
1392    std::assert!(
1393      !json.contains("\"id\":null"),
1394      "absent proof id must be omitted, never emitted as null: {}",
1395      json
1396    );
1397  }
1398
1399  #[test]
1400  fn all_returns_every_proof_for_both_wire_forms() {
1401    // `all()` is what lets a caller iterate proofs without branching on the
1402    // variant. If the single form returned an empty slice, a verifier
1403    // looping over `all()` would verify NOTHING and report success.
1404    std::assert_eq!(
1405      super::EnvelopeProofs::Single(sample_proof()).all().len(),
1406      1
1407    );
1408    std::assert_eq!(
1409      super::EnvelopeProofs::Chain(std::vec![
1410        sample_principal_proof(),
1411        sample_notary_countersignature(),
1412      ])
1413      .all()
1414      .len(),
1415      2
1416    );
1417  }
1418
1419  #[test]
1420  fn principal_is_none_for_a_single_proof() {
1421    // A lone proof is the NOTARY's (§7.1.11). Reporting it as the principal
1422    // proof would let a caller present a notary attestation as the human's
1423    // own signature — the exact confusion `attestationMode` exists to stop.
1424    std::assert!(
1425      super::EnvelopeProofs::Single(sample_proof())
1426        .principal()
1427        .is_none()
1428    );
1429  }
1430
1431  #[test]
1432  fn notary_is_the_lone_proof_and_the_tail_of_a_chain() {
1433    // Both wire forms carry a notary proof, and it is the proof steps 2-9 of
1434    // §8.3 verify. A caller must be able to reach it identically either way.
1435    let single = super::EnvelopeProofs::Single(sample_proof());
1436    std::assert_eq!(
1437      single.notary().map(|p| p.proof_purpose.as_str()),
1438      std::option::Option::Some("assertionMethod")
1439    );
1440    let chain = super::EnvelopeProofs::Chain(std::vec![
1441      sample_principal_proof(),
1442      sample_notary_countersignature(),
1443    ]);
1444    std::assert_eq!(
1445      chain.notary().map(|p| p.proof_purpose.as_str()),
1446      std::option::Option::Some("authentication")
1447    );
1448  }
1449
1450  #[test]
1451  fn accessors_refuse_to_name_proofs_inside_a_malformed_chain() {
1452    // §7.1.11 fixes chain length at exactly two. A three-element array is
1453    // rejected there, but these accessors are called by code that has not
1454    // run that check yet, so they must not hand back a "principal proof" or
1455    // "notary proof" from a structure the spec refuses — a caller would
1456    // verify one signature out of three and believe the chain sound.
1457    let over_long = super::EnvelopeProofs::Chain(std::vec![
1458      sample_principal_proof(),
1459      sample_notary_countersignature(),
1460      sample_notary_countersignature(),
1461    ]);
1462    std::assert!(over_long.principal().is_none());
1463    std::assert!(over_long.notary().is_none());
1464  }
1465
1466  #[test]
1467  fn is_chain_reports_the_array_form_at_any_length() {
1468    // The distinction is the WIRE form, not validity: a one-element array is
1469    // still a chain (an invalid one). If `is_chain()` reported false for it,
1470    // §7.2.1's stripped-notary-proof attack would slip past — the whole
1471    // point is that a one-element array is recognised and then rejected.
1472    std::assert!(
1473      super::EnvelopeProofs::Chain(std::vec![sample_principal_proof()]).is_chain()
1474    );
1475    std::assert!(!super::EnvelopeProofs::Single(sample_proof()).is_chain());
1476  }
1477
1478  #[test]
1479  fn absent_attestation_mode_resolves_to_notary_attested() {
1480    // §7.1.7 fixes ABSENT as `NotaryAttested`, the WEAKER claim. All eight
1481    // published envelopes omit the field; defaulting the other way would
1482    // silently promote every one of them to asserting the human personally
1483    // signed, which no party ever claimed.
1484    std::assert_eq!(
1485      sample_policy().effective_attestation_mode(),
1486      super::AttestationMode::NotaryAttested
1487    );
1488  }
1489
1490  #[test]
1491  fn declared_attestation_mode_is_returned_verbatim() {
1492    // The default must apply only to absence. If it overrode a present
1493    // value, a `PrincipalSigned` envelope would be downgraded to the notary's
1494    // assertion and the human's signature would go unchecked.
1495    let mut policy = sample_policy();
1496    policy.attestation_mode = std::option::Option::Some(super::AttestationMode::PrincipalSigned);
1497    std::assert_eq!(
1498      policy.effective_attestation_mode(),
1499      super::AttestationMode::PrincipalSigned
1500    );
1501  }
1502
1503  #[test]
1504  fn attestation_mode_wire_spelling_matches_the_closed_enum() {
1505    // §7.1.7 fixes a CLOSED enum with these exact spellings, and other
1506    // implementations compare the string. A serde rename or a variant
1507    // rename would make our envelopes unreadable to them, and `label()` is
1508    // what error messages show an operator, so both must agree.
1509    std::assert_eq!(
1510      serde_json::to_string(&super::AttestationMode::PrincipalSigned).unwrap(),
1511      "\"PrincipalSigned\""
1512    );
1513    std::assert_eq!(
1514      serde_json::to_string(&super::AttestationMode::NotaryAttested).unwrap(),
1515      "\"NotaryAttested\""
1516    );
1517    std::assert_eq!(super::AttestationMode::PrincipalSigned.label(), "PrincipalSigned");
1518    std::assert_eq!(super::AttestationMode::NotaryAttested.label(), "NotaryAttested");
1519  }
1520
1521  #[test]
1522  fn envelope_parses_a_chain_and_the_principal_signed_label_from_the_wire() {
1523    // End-to-end wire acceptance of the §7.1.11 shape: a verifier written
1524    // against this crate must be able to receive a real `PrincipalSigned`
1525    // envelope. Both additive fields sit under `deny_unknown_fields`, so
1526    // without the declarations a conformant producer would be rejected.
1527    let mut envelope = sample_envelope();
1528    envelope.credential_subject.policy.attestation_mode =
1529      std::option::Option::Some(super::AttestationMode::PrincipalSigned);
1530    envelope.proof = super::EnvelopeProofs::Chain(std::vec![
1531      sample_principal_proof(),
1532      sample_notary_countersignature(),
1533    ]);
1534    let s = serde_json::to_string(&envelope).unwrap();
1535    let back: super::NotarizationEnvelope =
1536      serde_json::from_str(&s).expect("a PrincipalSigned chain envelope must parse");
1537    std::assert_eq!(envelope, back);
1538  }
1539}