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
186impl std::str::FromStr for AttestationMode {
187  type Err = std::string::String;
188
189  /// The inverse of [`AttestationMode::label`], and the ONE place the wire
190  /// spellings are matched. Every binding's `require_attestation_mode` takes
191  /// the required mode as a caller-supplied string, and until this impl
192  /// existed each binding matched the spellings itself — four identical
193  /// copies of one meaning, each a site where a defaulting typo would BE the
194  /// downgrade that gate exists to refuse. The bindings now call this and
195  /// stay glue.
196  ///
197  /// The error is a plain message rather than an [`crate::errors::AphError`]
198  /// on purpose: an unknown label here is a CALLER's programming mistake
199  /// (nothing from the wire is involved), so it must not dress itself in a
200  /// protocol code a caller might route on.
201  fn from_str(label: &str) -> std::result::Result<Self, Self::Err> {
202    match label {
203      "PrincipalSigned" => std::result::Result::Ok(Self::PrincipalSigned),
204      "NotaryAttested" => std::result::Result::Ok(Self::NotaryAttested),
205      other => std::result::Result::Err(std::format!(
206        "unknown attestation mode `{}`: expected `PrincipalSigned` or `NotaryAttested`",
207        other
208      )),
209    }
210  }
211}
212
213/// The closed channel-kind vocabulary (§7.1.5), as a TYPE.
214///
215/// **Why this exists.** `ChannelDescriptor.kind` reached v0.1 as a bare
216/// `String`, so nothing in this crate refused a value outside the closed
217/// set — while the independent TypeScript implementation refused it at
218/// parse. Two implementations of one specification reached opposite
219/// verdicts on the same bytes, which is the defect class a second
220/// implementation exists to surface. This enum is the same repair already
221/// applied to `AttestationMode` above and `StatusPurpose` in
222/// `credential_status`: model the closed set as a closed type, so an
223/// unrecognized value is a constructible failure instead of a silent pass.
224///
225/// The wire field itself stays `String` in this revision (adopting the enum
226/// as the field type is a deliberate, separate breaking change); verifiers
227/// enforce the set via [`crate::verification::require_closed_vocabulary`].
228#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
229pub enum ChannelKind {
230  /// Wire value `slack`.
231  Slack,
232  /// Wire value `email`.
233  Email,
234  /// Wire value `discord`.
235  Discord,
236  /// Wire value `teams`.
237  Teams,
238  /// Wire value `whatsapp`.
239  Whatsapp,
240  /// Wire value `google_chat` — snake_case by erratum; every published
241  /// example and signed fixture emits this spelling.
242  GoogleChat,
243  /// Wire value `imessage`.
244  Imessage,
245  /// Wire value `service` — a service endpoint an agent delivers a
246  /// state-changing act to (RFC 0002). The endpoint IS the end-delivery
247  /// medium, which is why this is a channel kind and not a name for the
248  /// agent-to-agent rail that carried it (§1.1.1).
249  Service,
250  /// Wire value `squillo` — an in-application messaging surface where a
251  /// human reads the message in that application's client (RFC 0007). A
252  /// peer of [`Self::Slack`] and [`Self::Discord`], not a layer across
253  /// them. NOT the membership-scope value of the same spelling that
254  /// RFC 0004 refused: this names the DELIVERY MEDIUM.
255  Squillo,
256}
257
258impl ChannelKind {
259  /// Every member of the closed set, in §7.1.5 order. The ONE enumerable
260  /// other surfaces (docs, tests, bindings) derive from.
261  pub const ALL: [Self; 9] = [
262    Self::Slack,
263    Self::Email,
264    Self::Discord,
265    Self::Teams,
266    Self::Whatsapp,
267    Self::GoogleChat,
268    Self::Imessage,
269    Self::Service,
270    Self::Squillo,
271  ];
272
273  /// The exact wire spelling. Exhaustive on purpose: adding a channel kind
274  /// without deciding its wire spelling must not compile.
275  pub fn label(&self) -> &'static str {
276    match self {
277      Self::Slack => "slack",
278      Self::Email => "email",
279      Self::Discord => "discord",
280      Self::Teams => "teams",
281      Self::Whatsapp => "whatsapp",
282      Self::GoogleChat => "google_chat",
283      Self::Imessage => "imessage",
284      Self::Service => "service",
285      Self::Squillo => "squillo",
286    }
287  }
288
289  /// The whole closed set, comma-joined, for the strict-parse refusal
290  /// message. DERIVED from [`Self::ALL`] rather than written out beside it:
291  /// a hand-maintained second copy of a closed vocabulary is precisely the
292  /// defect closing the set exists to prevent, and this message is read by
293  /// implementers deciding what to emit — it going stale would teach them
294  /// the wrong set.
295  fn labels_for_error() -> std::string::String {
296    Self::ALL
297      .iter()
298      .map(Self::label)
299      .collect::<std::vec::Vec<&'static str>>()
300      .join(", ")
301  }
302}
303
304impl std::fmt::Display for ChannelKind {
305  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306    std::write!(f, "{}", self.label())
307  }
308}
309
310impl std::str::FromStr for ChannelKind {
311  type Err = std::string::String;
312
313  /// The inverse of [`ChannelKind::label`], and the ONE place the wire
314  /// spellings are matched. The error is a plain message, not an
315  /// [`crate::errors::AphError`]: §8.3 step 1 classifies an unrecognized
316  /// value as a STRICT-PARSE rejection, the layer below the protocol's
317  /// closed set of error codes — exactly as the TypeScript implementation
318  /// models it with `AphParseError`. The message mirrors that
319  /// implementation's shape so the two report the same failure the same way.
320  fn from_str(label: &str) -> std::result::Result<Self, Self::Err> {
321    match label {
322      "slack" => std::result::Result::Ok(Self::Slack),
323      "email" => std::result::Result::Ok(Self::Email),
324      "discord" => std::result::Result::Ok(Self::Discord),
325      "teams" => std::result::Result::Ok(Self::Teams),
326      "whatsapp" => std::result::Result::Ok(Self::Whatsapp),
327      "google_chat" => std::result::Result::Ok(Self::GoogleChat),
328      "imessage" => std::result::Result::Ok(Self::Imessage),
329      "service" => std::result::Result::Ok(Self::Service),
330      "squillo" => std::result::Result::Ok(Self::Squillo),
331      other => std::result::Result::Err(std::format!(
332        "`{}` is not in the closed set {{{}}}",
333        other,
334        Self::labels_for_error()
335      )),
336    }
337  }
338}
339
340// serde for ChannelKind DELEGATES to `label()` / `from_str` rather than
341// restating the wire spellings in attributes. A `rename_all` or a set of
342// `#[serde(rename = ...)]` lines would be a SECOND mapping of the same
343// vocabulary, free to drift from the first — and the spellings here are
344// exactly the irregular ones a second copy gets wrong (`google_chat` is
345// snake_case among single words; `DM` is upper among PascalCase). One
346// mapping, two directions.
347impl serde::Serialize for ChannelKind {
348  fn serialize<S: serde::Serializer>(
349    &self,
350    serializer: S,
351  ) -> std::result::Result<S::Ok, S::Error> {
352    serializer.serialize_str(self.label())
353  }
354}
355
356impl<'de> serde::Deserialize<'de> for ChannelKind {
357  fn deserialize<D: serde::Deserializer<'de>>(
358    deserializer: D,
359  ) -> std::result::Result<Self, D::Error> {
360    let raw = <std::string::String as serde::Deserialize>::deserialize(deserializer)?;
361    <Self as std::str::FromStr>::from_str(&raw).map_err(serde::de::Error::custom)
362  }
363}
364
365/// The closed content-class vocabulary (§7.1.6), as a TYPE.
366///
367/// Same repair, same reasons as [`ChannelKind`] above: the wire field is a
368/// `String` and nothing refused values outside the closed set. See that
369/// type's documentation for the full account.
370#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
371pub enum ContentClass {
372  /// Wire value `Reply`.
373  Reply,
374  /// Wire value `New`.
375  New,
376  /// Wire value `Mention`.
377  Mention,
378  /// Wire value `DM` — both letters uppercase on the wire.
379  Dm,
380  /// Wire value `Channel`.
381  Channel,
382  /// Wire value `BulkSend`.
383  BulkSend,
384  /// Wire value `Broadcast`.
385  Broadcast,
386  /// Wire value `Mutation` — this act CHANGES STATE rather than carrying a
387  /// message (RFC 0002). It is required to match at both the mandate and
388  /// envelope layer (§6.2), so a notarized mutation is recorded as one
389  /// inside the signature.
390  Mutation,
391}
392
393impl ContentClass {
394  /// Every member of the closed set, in §7.1.6 order.
395  pub const ALL: [Self; 8] = [
396    Self::Reply,
397    Self::New,
398    Self::Mention,
399    Self::Dm,
400    Self::Channel,
401    Self::BulkSend,
402    Self::Broadcast,
403    Self::Mutation,
404  ];
405
406  /// The exact wire spelling. Exhaustive on purpose.
407  pub fn label(&self) -> &'static str {
408    match self {
409      Self::Reply => "Reply",
410      Self::New => "New",
411      Self::Mention => "Mention",
412      Self::Dm => "DM",
413      Self::Channel => "Channel",
414      Self::BulkSend => "BulkSend",
415      Self::Broadcast => "Broadcast",
416      Self::Mutation => "Mutation",
417    }
418  }
419
420  /// The whole closed set, comma-joined, for the strict-parse refusal
421  /// message. DERIVED from [`Self::ALL`] rather than written out beside it:
422  /// a hand-maintained second copy of a closed vocabulary is precisely the
423  /// defect closing the set exists to prevent, and this message is read by
424  /// implementers deciding what to emit — it going stale would teach them
425  /// the wrong set.
426  fn labels_for_error() -> std::string::String {
427    Self::ALL
428      .iter()
429      .map(Self::label)
430      .collect::<std::vec::Vec<&'static str>>()
431      .join(", ")
432  }
433}
434
435impl std::fmt::Display for ContentClass {
436  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437    std::write!(f, "{}", self.label())
438  }
439}
440
441impl std::str::FromStr for ContentClass {
442  type Err = std::string::String;
443
444  /// The inverse of [`ContentClass::label`]. Error shape per
445  /// [`ChannelKind::from_str`]'s documentation.
446  fn from_str(label: &str) -> std::result::Result<Self, Self::Err> {
447    match label {
448      "Reply" => std::result::Result::Ok(Self::Reply),
449      "New" => std::result::Result::Ok(Self::New),
450      "Mention" => std::result::Result::Ok(Self::Mention),
451      "DM" => std::result::Result::Ok(Self::Dm),
452      "Channel" => std::result::Result::Ok(Self::Channel),
453      "BulkSend" => std::result::Result::Ok(Self::BulkSend),
454      "Broadcast" => std::result::Result::Ok(Self::Broadcast),
455      "Mutation" => std::result::Result::Ok(Self::Mutation),
456      other => std::result::Result::Err(std::format!(
457        "`{}` is not in the closed set {{{}}}",
458        other,
459        Self::labels_for_error()
460      )),
461    }
462  }
463}
464
465// serde for ContentClass DELEGATES to `label()` / `from_str` rather than
466// restating the wire spellings in attributes. A `rename_all` or a set of
467// `#[serde(rename = ...)]` lines would be a SECOND mapping of the same
468// vocabulary, free to drift from the first — and the spellings here are
469// exactly the irregular ones a second copy gets wrong (`google_chat` is
470// snake_case among single words; `DM` is upper among PascalCase). One
471// mapping, two directions.
472impl serde::Serialize for ContentClass {
473  fn serialize<S: serde::Serializer>(
474    &self,
475    serializer: S,
476  ) -> std::result::Result<S::Ok, S::Error> {
477    serializer.serialize_str(self.label())
478  }
479}
480
481impl<'de> serde::Deserialize<'de> for ContentClass {
482  fn deserialize<D: serde::Deserializer<'de>>(
483    deserializer: D,
484  ) -> std::result::Result<Self, D::Error> {
485    let raw = <std::string::String as serde::Deserialize>::deserialize(deserializer)?;
486    <Self as std::str::FromStr>::from_str(&raw).map_err(serde::de::Error::custom)
487  }
488}
489
490/// The notarized claim: who authorized what, on which channel, under
491/// which policy, attested by which notary.
492#[derive(
493  std::fmt::Debug,
494  std::clone::Clone,
495  std::cmp::PartialEq,
496  std::cmp::Eq,
497  serde::Serialize,
498  serde::Deserialize,
499)]
500#[serde(rename_all = "camelCase", deny_unknown_fields)]
501pub struct CredentialSubject {
502  /// The human on whose behalf the agent acted.
503  pub human_principal: HumanPrincipalRef,
504  /// The agent that produced the communication.
505  pub agent: AgentRef,
506  /// Delivery channel and recipient addressing.
507  pub channel: ChannelDescriptor,
508  /// What was sent: content class, body hash, size, preview.
509  pub communication: CommunicationDescriptor,
510  /// The authorization decision and the scope it matched.
511  pub policy: PolicyDescriptor,
512  /// Which notary decided, when, and how long it took.
513  pub notarization: NotarizationMetadata,
514  /// Last-position additive field. Optional Apple Foundation Models AUR
515  /// acceptance claim per `(user_id, device_id, aur_version_hash)`.
516  /// `#[serde(default, skip_serializing_if = "Option::is_none")]` preserves
517  /// wire back-compat (legacy envelopes omit the field and continue to
518  /// deserialize cleanly).
519  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
520  pub apple_aur_acceptance: std::option::Option<AppleAurAcceptanceClaim>,
521  /// Who may accept this envelope (RFC 0003 §1). OPTIONAL, omitted when
522  /// absent: an envelope without it is byte-identical to one written before
523  /// the field existed, every signature over those bytes stays valid, and
524  /// absence is the producer's DECISION to issue a bearer credential rather
525  /// than an oversight the verifier forgives.
526  ///
527  /// ⚠ EMITTING THIS IS VERSION-GATED for the same structural reason as
528  /// `act_classification` below: every wire struct here carries
529  /// `deny_unknown_fields`, so a pre-RFC-0003 verifier fails an envelope
530  /// carrying this at STRICT PARSE. A producer MUST NOT emit it until it has
531  /// reason to believe the recipient understands it (§10.1).
532  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
533  pub audience: std::option::Option<Audience>,
534  /// A payload only its named reader can open (spec/aph-0.2.md,
535  /// RFC 0008). OPTIONAL, omitted when absent — and DECLARED ONLY FROM
536  /// aphVersion 0.2: [`sealed_payload_is_declared`] is the rule's teeth,
537  /// and a v0.1.0 verifier refuses the member at strict parse, correctly.
538  /// ⚠ EMITTING is version-gated like every optional sibling here (§10.1).
539  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
540  pub sealed_payload: std::option::Option<SealedPayload>,
541  /// Last-position additive field. What the sender says this act MEANS, in
542  /// terms of one or more independently published vocabularies (RFC 0006).
543  ///
544  /// Omitted when absent, so an envelope making no such claim is
545  /// byte-identical to one written before the field existed and every
546  /// signature over those bytes stays valid.
547  ///
548  /// ⚠ EMITTING THIS IS VERSION-GATED, and the reason is structural rather
549  /// than stylistic: every wire struct in this module carries
550  /// `deny_unknown_fields`, so a verifier built before this field existed
551  /// does not ignore it — it fails at STRICT PARSE, below the protocol's own
552  /// error vocabulary. A producer MUST NOT emit this until it has reason to
553  /// believe the recipient understands it; the AgentCard extension
554  /// declaration (§10.1) is the existing mechanism for forming that belief.
555  /// Adding the field to this type is safe for everyone. Putting it on the
556  /// wire is not, and that asymmetry is the whole of the rule.
557  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
558  pub act_classification: std::option::Option<ActClassification>,
559}
560
561/// Reference to the human on whose behalf the agent acted.
562#[derive(
563  std::fmt::Debug,
564  std::clone::Clone,
565  std::cmp::PartialEq,
566  std::cmp::Eq,
567  serde::Serialize,
568  serde::Deserialize,
569)]
570#[serde(rename_all = "camelCase", deny_unknown_fields)]
571pub struct HumanPrincipalRef {
572  /// DID of the human principal.
573  pub id: String,
574  /// Human-readable name, for display in consent UIs and audit logs.
575  pub display_name: String,
576}
577
578/// Reference to the agent that produced the communication.
579#[derive(
580  std::fmt::Debug,
581  std::clone::Clone,
582  std::cmp::PartialEq,
583  std::cmp::Eq,
584  serde::Serialize,
585  serde::Deserialize,
586)]
587#[serde(rename_all = "camelCase", deny_unknown_fields)]
588pub struct AgentRef {
589  /// DID of the agent (typically `did:web:...`).
590  pub id: String,
591  /// Optional URI of the agent's A2A Agent Card.
592  #[serde(default)]
593  pub agent_card_uri: std::option::Option<String>,
594  /// Human-readable agent name.
595  pub display_name: String,
596  /// Agent version string, so a recipient can tell releases apart.
597  pub version: String,
598}
599
600/// Delivery channel and its channel-shaped recipient addressing.
601#[derive(
602  std::fmt::Debug,
603  std::clone::Clone,
604  std::cmp::PartialEq,
605  std::cmp::Eq,
606  serde::Serialize,
607  serde::Deserialize,
608)]
609#[serde(rename_all = "camelCase", deny_unknown_fields)]
610pub struct ChannelDescriptor {
611  /// Channel kind, drawn from the closed set (§7.1.5).
612  ///
613  /// Typed rather than `String`: an unrecognized value is now a parse
614  /// failure, which is what §8.3 step 1 has always required and what the
615  /// independent TypeScript implementation already did.
616  pub kind: ChannelKind,
617  /// Channel-shaped opaque blob (opaque to APH core).
618  pub recipient_addressing: serde_json::Value,
619  /// Who consumes what lands there (§7.1.5, RFC 0005). OPTIONAL, omitted
620  /// when absent: absence means the producer makes no claim, and every
621  /// envelope minted before the field existed stays byte-identical.
622  ///
623  /// ⚠ EMITTING THIS IS VERSION-GATED, same structural rule as
624  /// `audience` and `act_classification`: strict parse fails an unknown
625  /// field BELOW the error vocabulary, so a producer MUST NOT emit it
626  /// until it has reason to believe the recipient understands it (§10.1).
627  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
628  pub recipient_class: std::option::Option<RecipientClass>,
629}
630
631/// What was sent: classification plus the hash that binds this credential
632/// to a specific message body.
633#[derive(
634  std::fmt::Debug,
635  std::clone::Clone,
636  std::cmp::PartialEq,
637  std::cmp::Eq,
638  serde::Serialize,
639  serde::Deserialize,
640)]
641#[serde(rename_all = "camelCase", deny_unknown_fields)]
642pub struct CommunicationDescriptor {
643  /// Content classification, drawn from the closed set (§7.1.6).
644  ///
645  /// Typed for the same reason as `ChannelDescriptor.kind`: the closed set
646  /// lived in prose while the field admitted any string.
647  pub content_class: ContentClass,
648  /// SHA-256 of the message body, 64 lowercase hex characters.
649  pub body_sha256: String,
650  /// Body length in bytes.
651  pub body_size: u64,
652  /// Number of body lines included in `preview`.
653  pub preview_lines: u32,
654  /// Truncated body excerpt for human review at decision time.
655  pub preview: String,
656}
657
658/// The authorization decision and the scope that produced it.
659#[derive(
660  std::fmt::Debug,
661  std::clone::Clone,
662  std::cmp::PartialEq,
663  std::cmp::Eq,
664  serde::Serialize,
665  serde::Deserialize,
666)]
667#[serde(rename_all = "camelCase", deny_unknown_fields)]
668pub struct PolicyDescriptor {
669  /// `"AlwaysAllow" | "AskEveryTime" | "NeverAllow"`.
670  pub decision: PolicyDecision,
671  /// e.g., `"per-channel" | "per-recipient" | "global"`.
672  pub matched_scope: String,
673  /// Parent delegation mandate, absent for one-shot AskEveryTime grants.
674  #[serde(default)]
675  pub delegation_mandate_id: std::option::Option<String>,
676  /// OAuth 2.0 Token Exchange `act` chain (RFC 8693) — optional cross-system
677  /// principal chain. Each element is a DID string.
678  #[serde(default)]
679  pub act_chain: Vec<String>,
680  /// Who proved the authorization (§7.1.7). ABSENT means `NotaryAttested`.
681  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
682  pub attestation_mode: std::option::Option<AttestationMode>,
683  /// The complete parent mandate, embedded so the human's signature on it
684  /// verifies offline (§7.1.7.1).
685  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
686  pub delegation_mandate: std::option::Option<crate::delegation_mandate::DelegationMandate>,
687}
688
689impl PolicyDescriptor {
690  /// The declared mode, resolving ABSENT to `NotaryAttested` per §7.1.7.
691  ///
692  /// Absence is not "unknown": §7.1.7 fixes it as `NotaryAttested` so that
693  /// every envelope written before `attestationMode` existed keeps a single,
694  /// unambiguous meaning — the weaker one. Defaulting the other way would
695  /// silently promote every legacy envelope to a claim no one made.
696  pub fn effective_attestation_mode(&self) -> AttestationMode {
697    match self.attestation_mode {
698      std::option::Option::Some(mode) => mode,
699      std::option::Option::None => AttestationMode::NotaryAttested,
700    }
701  }
702}
703
704/// Which notary made the decision, when, and how long it took.
705#[derive(
706  std::fmt::Debug,
707  std::clone::Clone,
708  std::cmp::PartialEq,
709  std::cmp::Eq,
710  serde::Serialize,
711  serde::Deserialize,
712)]
713#[serde(rename_all = "camelCase", deny_unknown_fields)]
714pub struct NotarizationMetadata {
715  /// The notary service that decided.
716  pub notary_service: NotaryServiceRef,
717  /// RFC 3339 decision timestamp.
718  pub decision_timestamp: String,
719  /// Decision latency in milliseconds — audit evidence for whether a human
720  /// was plausibly in the loop.
721  pub decision_latency_ms: u64,
722}
723
724/// Identity of the notary service, used for key discovery (spec §8.4).
725#[derive(
726  std::fmt::Debug,
727  std::clone::Clone,
728  std::cmp::PartialEq,
729  std::cmp::Eq,
730  serde::Serialize,
731  serde::Deserialize,
732)]
733#[serde(rename_all = "camelCase", deny_unknown_fields)]
734pub struct NotaryServiceRef {
735  /// DID of the notary.
736  pub id: String,
737  /// Human-readable notary service name.
738  pub name: String,
739  /// Notary implementation version.
740  pub version: String,
741  /// Content digest of the attested release this notary reports running
742  /// (spec §7.1.9, §15.3). Declared here because parsing is strict: a
743  /// field a conformant notary may send must exist in the shape a
744  /// conformant verifier parses. `#[serde(default)]` keeps every envelope
745  /// written before this field byte-identical.
746  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
747  pub attested_digest: std::option::Option<String>,
748  /// Where the k-of-3 attestation for `attested_digest` may be fetched
749  /// (spec §15.3). Carrying it proves nothing on its own — an attestation
750  /// attests what was PUBLISHED, never what is RUNNING (§15.7).
751  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
752  pub attestation_uri: std::option::Option<String>,
753}
754
755/// Cross-protocol links carried alongside the send authorization.
756#[derive(
757  std::fmt::Debug,
758  std::clone::Clone,
759  std::cmp::PartialEq,
760  std::cmp::Eq,
761  serde::Serialize,
762  serde::Deserialize,
763)]
764#[serde(rename_all = "camelCase", deny_unknown_fields)]
765pub struct LinkedMandate {
766  /// URI of an AP2 IntentMandate cross-linking payment authorization.
767  #[serde(default)]
768  pub ap2_intent_mandate_uri: std::option::Option<String>,
769  /// Optional base64-encoded AP2 SignedPayload for self-contained
770  /// verification when the verifier cannot dereference
771  /// `ap2_intent_mandate_uri`. `#[serde(default)]` preserves wire
772  /// back-compat for envelopes written before this field was added.
773  #[serde(default)]
774  pub ap2_signed_payload_b64: std::option::Option<String>,
775  /// Optional cross-vault mutation mandate for LinkedMandates issued by a
776  /// cross-vault federation engine.
777  /// `#[serde(default, skip_serializing_if = "...")]` preserves
778  /// wire back-compat (legacy envelopes omit the field).
779  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
780  pub vault_mutation: std::option::Option<
781    crate::vault_mutation::VaultMutationMandate,
782  >,
783}
784
785#[derive(
786  std::fmt::Debug,
787  std::clone::Clone,
788  std::cmp::PartialEq,
789  std::cmp::Eq,
790  serde::Serialize,
791  serde::Deserialize,
792)]
793/// The cryptographic proof block: either a W3C Data Integrity Proof or a
794/// detached JWS, per spec §8.2.
795#[serde(rename_all = "camelCase", deny_unknown_fields)]
796pub struct EnvelopeProof {
797  /// Always `"DataIntegrityProof"` for the JCS-canonicalized cryptosuites,
798  /// or `"JsonWebSignature2020"` for compact JWS detached.
799  #[serde(rename = "type")]
800  pub r#type: String,
801  /// Required for DataIntegrityProof — `"eddsa-jcs-2022"` or
802  /// `"ecdsa-jcs-2019"` — and OMITTED, not nulled, for
803  /// `JsonWebSignature2020` (§7.1.11).
804  ///
805  /// `skip_serializing_if` is load-bearing rather than cosmetic: this member
806  /// sits inside the §7.2.1 canonicalization base, so emitting
807  /// `"cryptosuite": null` would put a member in the SIGNED bytes that an
808  /// implementer following the spec's proof table never builds, and no JWS
809  /// envelope this crate minted would verify anywhere else.
810  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
811  pub cryptosuite: std::option::Option<String>,
812  /// DID URL referencing the verifying key
813  /// (e.g., `did:key:z6Mk...#z6Mk...`).
814  pub verification_method: String,
815  /// RFC 3339.
816  pub created: String,
817  /// `"assertionMethod"` for a principal proof or a lone notary proof;
818  /// `"authentication"` for the notary countersignature of a chain (§7.1.11).
819  pub proof_purpose: String,
820  /// Multibase or base64url-encoded signature bytes.
821  pub proof_value: String,
822  /// Identifier for this proof, unique within the envelope. REQUIRED when
823  /// `proof` is a chain, absent for a lone proof, which links to nothing.
824  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
825  pub id: std::option::Option<String>,
826  /// The `id` of the proof this one countersigns. Present on the notary
827  /// proof of a chain; absent on the principal proof, its head.
828  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
829  pub previous_proof: std::option::Option<String>,
830}
831
832/// Last-position sibling struct. Carries Apple Foundation Models AUR
833/// acceptance per `(user_id, device_id, aur_version_hash)` tuple.
834/// Embedded as `Option<AppleAurAcceptanceClaim>` on
835/// `CredentialSubject.apple_aur_acceptance`. The field is
836/// `#[serde(default, skip_serializing_if = "...")]` so legacy envelopes that
837/// predate this claim continue to deserialize cleanly.
838#[derive(
839  std::fmt::Debug,
840  std::clone::Clone,
841  std::cmp::PartialEq,
842  std::cmp::Eq,
843  serde::Serialize,
844  serde::Deserialize,
845)]
846#[serde(rename_all = "camelCase", deny_unknown_fields)]
847pub struct AppleAurAcceptanceClaim {
848  /// User-scoped DID for whom acceptance was recorded.
849  pub user_id: String,
850  /// Device-scoped opaque identifier (acceptance is recorded per
851  /// `(user_id, device_id)` pair).
852  pub device_id: String,
853  /// SHA-256 hex of the Apple AUR snapshot text accepted.
854  pub aur_version_hash: String,
855  /// RFC 3339 acceptance timestamp.
856  pub accepted_at: String,
857  /// `"foundation_models_framework_aur"` for forward-compat with future Apple legal documents.
858  pub document_kind: String,
859}
860
861/// The closed §7.1.7 policy-decision vocabulary, as a type.
862///
863/// Same repair, same reasons as [`ChannelKind`] above, found by the same
864/// audit discipline one field over: the wire field was a bare `String` the
865/// reference validated NOWHERE, while the independent TypeScript
866/// implementation refused an unrecognized value at parse — two
867/// conformant-claiming implementations reaching opposite verdicts on the
868/// same bytes. The reference was the permissive surface, and per the
869/// standing guardrail the permissive surface is the wrong one. The
870/// published protocol bundle had declared this vocabulary as an enum all
871/// along; the reference was the LAST populator without the type.
872///
873/// The doc-comment on the wire field (§7.1.7) carries the semantic warning:
874/// this records the human's standing CONFIGURATION, never the verdict on
875/// this act.
876#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
877pub enum PolicyDecision {
878  /// Wire value `AlwaysAllow`.
879  AlwaysAllow,
880  /// Wire value `AskEveryTime` — and it stays `AskEveryTime` after the
881  /// human approves; the mandate's existence is what asserts the approval
882  /// (§9.1 admits no path to issuance that bypasses `Approved`).
883  AskEveryTime,
884  /// Wire value `NeverAllow` — recorded, but never yields an envelope.
885  NeverAllow,
886}
887
888impl PolicyDecision {
889  /// Every member of the closed set, in §7.1.7 order.
890  pub const ALL: [Self; 3] = [Self::AlwaysAllow, Self::AskEveryTime, Self::NeverAllow];
891
892  /// The exact wire spelling. Exhaustive on purpose.
893  pub fn label(&self) -> &'static str {
894    match self {
895      Self::AlwaysAllow => "AlwaysAllow",
896      Self::AskEveryTime => "AskEveryTime",
897      Self::NeverAllow => "NeverAllow",
898    }
899  }
900
901  /// The whole closed set, comma-joined, for the strict-parse refusal
902  /// message — DERIVED from [`Self::ALL`], per the siblings' reasoning.
903  fn labels_for_error() -> std::string::String {
904    Self::ALL
905      .iter()
906      .map(Self::label)
907      .collect::<std::vec::Vec<&'static str>>()
908      .join(", ")
909  }
910}
911
912impl std::fmt::Display for PolicyDecision {
913  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
914    std::write!(f, "{}", self.label())
915  }
916}
917
918impl std::str::FromStr for PolicyDecision {
919  type Err = std::string::String;
920
921  /// The inverse of [`PolicyDecision::label`]. Error shape per
922  /// [`ChannelKind::from_str`]'s documentation: a plain message, because
923  /// §8.3 step 1 classifies an unrecognized value as a strict-parse
924  /// rejection, below the protocol's closed set of error codes.
925  fn from_str(label: &str) -> std::result::Result<Self, Self::Err> {
926    match label {
927      "AlwaysAllow" => std::result::Result::Ok(Self::AlwaysAllow),
928      "AskEveryTime" => std::result::Result::Ok(Self::AskEveryTime),
929      "NeverAllow" => std::result::Result::Ok(Self::NeverAllow),
930      other => std::result::Result::Err(std::format!(
931        "`{}` is not in the closed set {{{}}}",
932        other,
933        Self::labels_for_error()
934      )),
935    }
936  }
937}
938
939impl serde::Serialize for PolicyDecision {
940  fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
941    serializer.serialize_str(self.label())
942  }
943}
944
945impl<'de> serde::Deserialize<'de> for PolicyDecision {
946  fn deserialize<D: serde::Deserializer<'de>>(
947    deserializer: D,
948  ) -> std::result::Result<Self, D::Error> {
949    let raw = <std::string::String as serde::Deserialize>::deserialize(deserializer)?;
950    <Self as std::str::FromStr>::from_str(&raw).map_err(serde::de::Error::custom)
951  }
952}
953
954/// Who consumes what lands on the channel: the closed recipient-class set
955/// (§7.1.5, RFC 0005).
956///
957/// The SECOND DIMENSION the a2a_email request was really asking for: a
958/// refinement that must be applied to every member of a set is not a member
959/// of that set, so "agent mail" is `kind: email` + `recipientClass: agent`
960/// rather than an eighth kind that doubles the vocabulary. Two values,
961/// closed at two DELIBERATELY — grown by amendment like every closed set
962/// here, not by a producer minting one.
963///
964/// A recipient class asserted by the sender is a CLAIM, not a proof: it
965/// constrains an honest-but-over-broad agent — the actual threat, one's own
966/// agent doing more than one meant — and does not constrain a hostile one.
967/// No caller should present it as the latter.
968#[derive(std::fmt::Debug, std::clone::Clone, std::marker::Copy, std::cmp::PartialEq, std::cmp::Eq)]
969pub enum RecipientClass {
970  /// Wire value `human`: a person reads what lands.
971  Human,
972  /// Wire value `agent`: unattended machine consumption at machine rate.
973  Agent,
974}
975
976impl RecipientClass {
977  /// Every member of the closed set, in §7.1.5 order.
978  pub const ALL: [Self; 2] = [Self::Human, Self::Agent];
979
980  /// The exact wire spelling. Exhaustive on purpose.
981  pub fn label(&self) -> &'static str {
982    match self {
983      Self::Human => "human",
984      Self::Agent => "agent",
985    }
986  }
987
988  /// The whole closed set, comma-joined, for the strict-parse refusal
989  /// message — DERIVED from [`Self::ALL`], per the siblings' reasoning.
990  fn labels_for_error() -> std::string::String {
991    Self::ALL
992      .iter()
993      .map(Self::label)
994      .collect::<std::vec::Vec<&'static str>>()
995      .join(", ")
996  }
997}
998
999impl std::fmt::Display for RecipientClass {
1000  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1001    std::write!(f, "{}", self.label())
1002  }
1003}
1004
1005impl std::str::FromStr for RecipientClass {
1006  type Err = std::string::String;
1007
1008  /// The inverse of [`RecipientClass::label`]. Error shape per
1009  /// [`ChannelKind::from_str`]'s documentation: a plain message, because
1010  /// §8.3 step 1 classifies an unrecognized value as a strict-parse
1011  /// rejection, below the protocol's closed set of error codes.
1012  fn from_str(label: &str) -> std::result::Result<Self, Self::Err> {
1013    match label {
1014      "human" => std::result::Result::Ok(Self::Human),
1015      "agent" => std::result::Result::Ok(Self::Agent),
1016      other => std::result::Result::Err(std::format!(
1017        "`{}` is not in the closed set {{{}}}",
1018        other,
1019        Self::labels_for_error()
1020      )),
1021    }
1022  }
1023}
1024
1025impl serde::Serialize for RecipientClass {
1026  fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
1027    serializer.serialize_str(self.label())
1028  }
1029}
1030
1031impl<'de> serde::Deserialize<'de> for RecipientClass {
1032  fn deserialize<D: serde::Deserializer<'de>>(
1033    deserializer: D,
1034  ) -> std::result::Result<Self, D::Error> {
1035    let raw = <std::string::String as serde::Deserialize>::deserialize(deserializer)?;
1036    <Self as std::str::FromStr>::from_str(&raw).map_err(serde::de::Error::custom)
1037  }
1038}
1039
1040/// What a sender says an act MEANS, against vocabularies both parties can
1041/// resolve independently (RFC 0006).
1042///
1043/// # What this proves, and what it does not
1044///
1045/// It proves WHICH VOCABULARY THE SENDER CITED, inside the signature. It does
1046/// NOT prove the sender classified correctly: a sender can label a funds
1047/// transfer as a scheduling proposal and sign it. What the binding buys is
1048/// that a DISAGREEMENT becomes visible where a MISUNDERSTANDING was
1049/// invisible — two parties now mean the same thing by a word, and a recipient
1050/// can check the claim against the payload it accompanies. That is worth
1051/// having, it is not integrity of classification, and no caller should
1052/// present it as the latter.
1053#[derive(
1054  std::fmt::Debug,
1055  std::clone::Clone,
1056  std::cmp::PartialEq,
1057  std::cmp::Eq,
1058  serde::Serialize,
1059  serde::Deserialize,
1060)]
1061#[serde(rename_all = "camelCase", deny_unknown_fields)]
1062pub struct ActClassification {
1063  /// Every vocabulary the verdict depended on, in fold order.
1064  ///
1065  /// A LIST because an overlay is a separate published artifact with its own
1066  /// digest: a classifier that ran against a base and a tightening overlay
1067  /// was produced by TWO artifacts, and naming only one would put a false
1068  /// statement inside a signature.
1069  ///
1070  /// NON-EMPTY, refused at parse: labels resolve against the vocabularies
1071  /// that define them, so a claim citing nothing is labels nobody can look
1072  /// up — meaningless rather than merely sparse. The independent TypeScript
1073  /// implementation refused this from its first draft; this constraint is
1074  /// what keeps the reference from silently accepting bytes it refuses.
1075  #[serde(deserialize_with = "non_empty_vocabularies")]
1076  pub vocabularies: std::vec::Vec<VocabularyRef>,
1077  /// The family-qualified labels this act carries.
1078  ///
1079  /// A LIST because one act carries verdicts from several families at once —
1080  /// what kind of act it is, how reversible, how wide its blast radius, what
1081  /// routing it implies. Carrying one would discard the verdicts a
1082  /// recipient's policy most wants.
1083  pub labels: std::vec::Vec<ActLabel>,
1084}
1085
1086/// Who may accept this envelope, and — optionally — on which delivery
1087/// coordinates (§7.1.13, RFC 0003).
1088///
1089/// The check this feeds is a COMPARISON, not cryptography: §8.3's audience
1090/// step compares `id` against the verifier's own identity and refuses on
1091/// difference (`APH_E017`). What that buys is that a captured envelope stops
1092/// being a reusable authorization against ANY recipient; what it does not
1093/// buy is anything about the one recipient it names.
1094#[derive(
1095  std::fmt::Debug,
1096  std::clone::Clone,
1097  std::cmp::PartialEq,
1098  std::cmp::Eq,
1099  serde::Serialize,
1100  serde::Deserialize,
1101)]
1102#[serde(rename_all = "camelCase", deny_unknown_fields)]
1103pub struct Audience {
1104  /// DID of the endpoint entitled to accept this envelope.
1105  pub id: String,
1106  /// Restates the delivery coordinates the envelope authorizes, so an
1107  /// envelope addressed to one channel cannot be spent on another. OPTIONAL:
1108  /// audience binding without coordinate binding is a coarser but still
1109  /// meaningful restriction.
1110  #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
1111  pub channel_binding: std::option::Option<AudienceChannelBinding>,
1112}
1113
1114/// The delivery coordinates an audience-bound envelope may be spent on
1115/// (§7.1.13). `kind` is drawn from the same closed set as the channel block;
1116/// every OTHER member is channel-shaped and flattened, mirroring
1117/// `ChannelDescriptor.recipient_addressing`'s opacity — APH core compares
1118/// these members for equality against the coordinates of the act being
1119/// performed and forms no other opinion about them.
1120///
1121/// The ONE wire struct in this module without `deny_unknown_fields`, and
1122/// deliberately: its open members ARE the payload. `serde(flatten)` makes
1123/// every unrecognized member a coordinate rather than an error, which is
1124/// exactly the semantics §8.3's audience step assigns them ("compare each
1125/// member").
1126#[derive(
1127  std::fmt::Debug,
1128  std::clone::Clone,
1129  std::cmp::PartialEq,
1130  std::cmp::Eq,
1131  serde::Serialize,
1132  serde::Deserialize,
1133)]
1134#[serde(rename_all = "camelCase")]
1135pub struct AudienceChannelBinding {
1136  /// Channel kind, drawn from the closed set (§7.1.5).
1137  pub kind: ChannelKind,
1138  /// Every other member: the channel-shaped coordinates themselves
1139  /// (`teamId`, `channelId`, an address — whatever the kind's addressing
1140  /// uses). Compared member-by-member; a member the act's coordinates lack
1141  /// is a mismatch, because a constraint that cannot be checked is refused
1142  /// rather than skipped.
1143  #[serde(flatten)]
1144  pub coordinates: std::collections::BTreeMap<String, serde_json::Value>,
1145}
1146
1147/// Who may open a sealed payload: a DID and which of its `keyAgreement`
1148/// keys (spec/aph-0.2.md §1, RFC 0008 §2). Signing keys are never
1149/// converted to encryption keys; the reader publishes a distinct
1150/// `keyAgreement` entry through the §8.4 surfaces.
1151#[derive(
1152  std::fmt::Debug,
1153  std::clone::Clone,
1154  std::cmp::PartialEq,
1155  std::cmp::Eq,
1156  serde::Serialize,
1157  serde::Deserialize,
1158)]
1159#[serde(rename_all = "camelCase", deny_unknown_fields)]
1160pub struct SealedReader {
1161  /// DID whose key opens the seal — the final recipient, the sender itself,
1162  /// or a designated third party; that choice IS RFC 0008's two scenarios.
1163  pub id: String,
1164  /// Which `keyAgreement` key of that DID.
1165  pub kid: String,
1166}
1167
1168/// A payload the envelope authorizes but only [`SealedReader`] can read
1169/// (spec/aph-0.2.md §1, RFC 0008): verification and readership as
1170/// independent capabilities. Every hop runs §8.3 in full — the signature
1171/// covers this ciphertext, `bodySha256` MAY bind its raw decoded octets —
1172/// and none but the reader learns the plaintext.
1173///
1174/// The cryptography lives in the `aph-sealed` crate (RFC 9180 HPKE, one
1175/// pinned suite, context-authenticated AAD); this type is the WIRE shape
1176/// alone, so the core keeps its dependency discipline.
1177#[derive(
1178  std::fmt::Debug,
1179  std::clone::Clone,
1180  std::cmp::PartialEq,
1181  std::cmp::Eq,
1182  serde::Serialize,
1183  serde::Deserialize,
1184)]
1185#[serde(rename_all = "camelCase", deny_unknown_fields)]
1186pub struct SealedPayload {
1187  /// The pinned ciphersuite identifier (`APH-SEAL-1` today). Closed-set
1188  /// discipline for ciphersuites: an opener refuses a value it does not
1189  /// compile (`APH_E022`).
1190  pub suite: String,
1191  /// Who can open it.
1192  pub reader: SealedReader,
1193  /// base64url (unpadded): the HPKE encapsulated key.
1194  pub enc: String,
1195  /// base64url (unpadded): AEAD ciphertext, tag included.
1196  pub ciphertext: String,
1197}
1198
1199/// The wire-version rule spec/aph-0.2.md §1 states: `sealedPayload`
1200/// is declared by aphVersion `0.2` and NOTHING EARLIER. A v0.1.0 verifier
1201/// refuses the member at strict parse (its structs never learned it); THIS
1202/// build's structs have, so the same refusal must come from somewhere —
1203/// and it is strict-parse CLASS, a plain message below the error
1204/// vocabulary, exactly like an unrecognized closed-set value: the wire is
1205/// malformed for the version it claims, which no protocol code describes.
1206pub fn sealed_payload_is_declared(
1207  envelope: &NotarizationEnvelope,
1208) -> std::result::Result<(), String> {
1209  if envelope.credential_subject.sealed_payload.is_some() && envelope.aph_version != "0.2" {
1210    return std::result::Result::Err(std::format!(
1211      "`sealedPayload` is not declared by aphVersion `{}`: the member exists        from aphVersion 0.2 (spec/aph-0.2.md), and an earlier version        carrying it is malformed for the version it claims",
1212      envelope.aph_version
1213    ));
1214  }
1215  std::result::Result::Ok(())
1216}
1217
1218/// THE strict-parse entry every text boundary shares: serde's
1219/// member-strict parse plus the wire-version rules that are strict-parse
1220/// CLASS but cross-member — today, [`sealed_payload_is_declared`]. Hoisted
1221/// here because four bindings each carried an identical local copy of the
1222/// serde half, and a rule added to one copy is a rule the other three
1223/// silently lack; the hoist is what makes "strict parse" mean ONE thing
1224/// at every boundary.
1225pub fn parse_envelope_json(
1226  json: &str,
1227) -> std::result::Result<NotarizationEnvelope, String> {
1228  let envelope: NotarizationEnvelope =
1229    serde_json::from_str(json).map_err(|e| std::format!("{}", e))?;
1230  sealed_payload_is_declared(&envelope)?;
1231  std::result::Result::Ok(envelope)
1232}
1233
1234/// Refuses an empty `vocabularies` array at parse (§7.1.12: the member is
1235/// required AND non-empty). Absent-vs-empty is the §8.4.6 distinction one
1236/// field down: an envelope with no claim omits the whole object, and an
1237/// object present but citing nothing is malformed, not minimal.
1238fn non_empty_vocabularies<'de, D: serde::Deserializer<'de>>(
1239  deserializer: D,
1240) -> std::result::Result<std::vec::Vec<VocabularyRef>, D::Error> {
1241  let refs = <std::vec::Vec<VocabularyRef> as serde::Deserialize>::deserialize(deserializer)?;
1242  if refs.is_empty() {
1243    return std::result::Result::Err(serde::de::Error::custom(
1244      "`vocabularies` is empty; a classification must name every vocabulary \
1245       the verdict depended on, and one that names none claims nothing",
1246    ));
1247  }
1248  std::result::Result::Ok(refs)
1249}
1250
1251/// One family-qualified label: `FAMILY/LABEL`.
1252///
1253/// A TYPE rather than a `String` with a comment, for the reason this whole
1254/// wave exists: a rule that lives only in prose is open in every
1255/// implementation that never read the prose. An unqualified label is
1256/// ambiguous — `ACCESS_GRANT` means nothing without the family that scopes
1257/// it, and two vocabularies may both define a bare word — so an unqualified
1258/// one is refused at parse rather than carried and disambiguated later by
1259/// whoever guesses.
1260///
1261/// # What is validated, and what deliberately is not
1262///
1263/// The STRUCTURE is ours: exactly one separator, neither side empty. The
1264/// SPELLING is the vocabulary's — no character set is imposed, because a
1265/// third-party vocabulary may name its families in a convention this project
1266/// has not thought of, and refusing those would make the extension model in
1267/// RFC 0006 a formality. Refuse what is meaningless; do not legislate taste.
1268#[derive(std::fmt::Debug, std::clone::Clone, std::cmp::PartialEq, std::cmp::Eq)]
1269pub struct ActLabel {
1270  family: String,
1271  label: String,
1272}
1273
1274impl ActLabel {
1275  /// The family that scopes this label.
1276  pub fn family(&self) -> &str {
1277    &self.family
1278  }
1279
1280  /// The label within that family.
1281  pub fn label(&self) -> &str {
1282    &self.label
1283  }
1284
1285  /// The wire spelling, `FAMILY/LABEL`.
1286  pub fn qualified(&self) -> std::string::String {
1287    std::format!("{}/{}", self.family, self.label)
1288  }
1289}
1290
1291impl std::fmt::Display for ActLabel {
1292  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1293    std::write!(f, "{}", self.qualified())
1294  }
1295}
1296
1297impl std::str::FromStr for ActLabel {
1298  type Err = std::string::String;
1299
1300  /// The ONE place the qualified form is matched. The error is a plain
1301  /// message rather than an [`crate::errors::AphError`], for the same reason
1302  /// the closed vocabularies' is: §8.3 step 1 classifies a malformed value as
1303  /// a STRICT-PARSE rejection, the layer below the protocol's closed set of
1304  /// error codes.
1305  fn from_str(raw: &str) -> std::result::Result<Self, Self::Err> {
1306    let (family, label) = match raw.split_once('/') {
1307      std::option::Option::Some(parts) => parts,
1308      std::option::Option::None => {
1309        return std::result::Result::Err(std::format!(
1310          "`{}` is not family-qualified: a label is written `FAMILY/LABEL`",
1311          raw
1312        ));
1313      }
1314    };
1315    if family.is_empty() || label.is_empty() {
1316      return std::result::Result::Err(std::format!(
1317        "`{}` has an empty family or label; both sides of the `/` are required",
1318        raw
1319      ));
1320    }
1321    if label.contains('/') {
1322      return std::result::Result::Err(std::format!(
1323        "`{}` has more than one `/`; a label names exactly one family",
1324        raw
1325      ));
1326    }
1327    std::result::Result::Ok(Self {
1328      family: family.to_string(),
1329      label: label.to_string(),
1330    })
1331  }
1332}
1333
1334impl serde::Serialize for ActLabel {
1335  fn serialize<S: serde::Serializer>(
1336    &self,
1337    serializer: S,
1338  ) -> std::result::Result<S::Ok, S::Error> {
1339    serializer.serialize_str(&self.qualified())
1340  }
1341}
1342
1343impl<'de> serde::Deserialize<'de> for ActLabel {
1344  fn deserialize<D: serde::Deserializer<'de>>(
1345    deserializer: D,
1346  ) -> std::result::Result<Self, D::Error> {
1347    let raw = <std::string::String as serde::Deserialize>::deserialize(deserializer)?;
1348    <Self as std::str::FromStr>::from_str(&raw).map_err(serde::de::Error::custom)
1349  }
1350}
1351
1352/// A published vocabulary, named and pinned.
1353///
1354/// Deliberately NOT a path into a serialization. The name identifies the
1355/// thing; a path identifies one encoding of it, and encodings move — this
1356/// project watched a bundle's blocks change nesting under a toolchain bump
1357/// with no source change at all. A signature cannot be re-pointed afterward,
1358/// so a reference must name the vocabulary rather than its current shape.
1359#[derive(
1360  std::fmt::Debug,
1361  std::clone::Clone,
1362  std::cmp::PartialEq,
1363  std::cmp::Eq,
1364  serde::Serialize,
1365  serde::Deserialize,
1366)]
1367#[serde(rename_all = "camelCase", deny_unknown_fields)]
1368pub struct VocabularyRef {
1369  /// The vocabulary's name, as its bundle declares it.
1370  pub name: String,
1371  /// The version, as its bundle declares it.
1372  pub version: String,
1373  /// The bundle's integrity digest, VERBATIM — the same `sha256-…` string
1374  /// the compiled bundle already carries, never a re-encoding of it.
1375  ///
1376  /// Carrying it verbatim keeps ONE spelling of a digest across the whole
1377  /// system. A re-encoding is a second derivation of one fact, and two
1378  /// derivations of one fact drift. The digest is also what makes the
1379  /// reference checkable at all: without it a citation points at whatever
1380  /// the publisher serves today, and a publisher — or anyone who compromises
1381  /// their origin — could change what a signed envelope meant after it was
1382  /// signed.
1383  pub digest: String,
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388  // -------- helpers --------
1389
1390  fn sample_human_principal() -> super::HumanPrincipalRef {
1391    super::HumanPrincipalRef {
1392      id: "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy".to_string(),
1393      display_name: "Scott Wyatt".to_string(),
1394    }
1395  }
1396
1397  fn sample_agent() -> super::AgentRef {
1398    super::AgentRef {
1399      id: "did:web:agent.squillo.com".to_string(),
1400      agent_card_uri: std::option::Option::Some(
1401        "https://agent.squillo.com/.well-known/agent-card.json".to_string(),
1402      ),
1403      display_name: "Squillo Concierge".to_string(),
1404      version: "1.0".to_string(),
1405    }
1406  }
1407
1408  fn sample_channel() -> super::ChannelDescriptor {
1409    super::ChannelDescriptor {
1410      kind: super::ChannelKind::Slack,
1411      recipient_addressing: serde_json::json!({
1412        "teamId": "T01234567",
1413        "channelId": "C01234567",
1414        "parentTs": "1716249600.000100"
1415      }),
1416      recipient_class: std::option::Option::None,
1417    }
1418  }
1419
1420  fn sample_communication() -> super::CommunicationDescriptor {
1421    super::CommunicationDescriptor {
1422      content_class: super::ContentClass::Reply,
1423      body_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
1424      body_size: 1842,
1425      preview_lines: 3,
1426      preview: "hello world".to_string(),
1427    }
1428  }
1429
1430  fn sample_policy() -> super::PolicyDescriptor {
1431    super::PolicyDescriptor {
1432      decision: super::PolicyDecision::AskEveryTime,
1433      matched_scope: "per-channel".to_string(),
1434      delegation_mandate_id: std::option::Option::None,
1435      act_chain: std::vec::Vec::new(),
1436      attestation_mode: std::option::Option::None,
1437      delegation_mandate: std::option::Option::None,
1438    }
1439  }
1440
1441  #[test]
1442  fn attestation_fields_are_omitted_when_absent_and_accepted_when_present() {
1443    // The two §15.3 attestation fields were added to a struct that parses
1444    // with `deny_unknown_fields` and whose output is signed. Two things must
1445    // both hold: a notary that makes no attestation claim serializes
1446    // byte-identically to before the fields existed (or every signature over
1447    // an existing envelope breaks), and a notary that does make one is not
1448    // rejected as carrying unknown fields (or conformant verifiers would
1449    // refuse conformant notaries).
1450    let bare = sample_notary_service();
1451    let json = serde_json::to_string(&bare).unwrap();
1452    std::assert!(
1453      !json.contains("attestedDigest") && !json.contains("attestationUri"),
1454      "absent attestation fields must not appear on the wire: {}",
1455      json
1456    );
1457
1458    let with_claim: super::NotaryServiceRef = serde_json::from_str(
1459      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"}"#,
1460    )
1461    .expect("a notary advertising an attestation must parse");
1462    std::assert_eq!(
1463      with_claim.attested_digest.as_deref(),
1464      std::option::Option::Some("sha256:abc")
1465    );
1466  }
1467
1468  fn sample_notary_service() -> super::NotaryServiceRef {
1469    super::NotaryServiceRef {
1470      id: "did:web:notary.squillo.com".to_string(),
1471      name: "Squillo Notary Service".to_string(),
1472      version: "0.1.0".to_string(),
1473      attested_digest: std::option::Option::None,
1474      attestation_uri: std::option::Option::None,
1475    }
1476  }
1477
1478  fn sample_notarization_metadata() -> super::NotarizationMetadata {
1479    super::NotarizationMetadata {
1480      notary_service: sample_notary_service(),
1481      decision_timestamp: "2026-05-21T00:00:01Z".to_string(),
1482      decision_latency_ms: 1834,
1483    }
1484  }
1485
1486  fn sample_credential_subject() -> super::CredentialSubject {
1487    super::CredentialSubject {
1488      human_principal: sample_human_principal(),
1489      agent: sample_agent(),
1490      channel: sample_channel(),
1491      communication: sample_communication(),
1492      policy: sample_policy(),
1493      notarization: sample_notarization_metadata(),
1494      apple_aur_acceptance: std::option::Option::None,
1495      audience: std::option::Option::None,
1496      sealed_payload: std::option::Option::None,
1497      act_classification: std::option::Option::None,
1498    }
1499  }
1500
1501  fn sample_linked_mandate() -> super::LinkedMandate {
1502    super::LinkedMandate {
1503      ap2_intent_mandate_uri: std::option::Option::Some(
1504        "urn:uuid:11111111-1111-4111-8111-111111111111".to_string(),
1505      ),
1506      ap2_signed_payload_b64: std::option::Option::None,
1507      vault_mutation: std::option::Option::None,
1508    }
1509  }
1510
1511  fn sample_proof() -> super::EnvelopeProof {
1512    super::EnvelopeProof {
1513      r#type: "DataIntegrityProof".to_string(),
1514      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
1515      verification_method:
1516        "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
1517          .to_string(),
1518      created: "2026-05-21T00:00:01Z".to_string(),
1519      proof_purpose: "assertionMethod".to_string(),
1520      proof_value:
1521        "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
1522          .to_string(),
1523      id: std::option::Option::None,
1524      previous_proof: std::option::Option::None,
1525    }
1526  }
1527
1528  fn sample_envelope() -> super::NotarizationEnvelope {
1529    super::NotarizationEnvelope {
1530      aph_version: "0.1".to_string(),
1531      context: std::vec![
1532        "https://www.w3.org/ns/credentials/v2".to_string(),
1533        "https://w3id.org/aph/v1".to_string(),
1534      ],
1535      r#type: std::vec![
1536        "VerifiableCredential".to_string(),
1537        "AgentSendAuthorizationCredential".to_string(),
1538      ],
1539      id: "urn:uuid:00000000-0000-4000-8000-000000000001".to_string(),
1540      issuer: "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV".to_string(),
1541      valid_from: "2026-05-21T00:00:00Z".to_string(),
1542      valid_until: "2026-05-22T00:00:00Z".to_string(),
1543      credential_subject: sample_credential_subject(),
1544      linked_mandate: std::option::Option::None,
1545      // Pattern A (§7.1.1): absent here means NO `credentialStatus` key on
1546      // the wire, which is what keeps this fixture byte-identical to the
1547      // pre-revocation shape its signatures were made over.
1548      credential_status: std::option::Option::None,
1549      proof: super::EnvelopeProofs::Single(sample_proof()),
1550    }
1551  }
1552
1553  fn sample_principal_proof() -> super::EnvelopeProof {
1554    super::EnvelopeProof {
1555      r#type: "DataIntegrityProof".to_string(),
1556      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
1557      // The human principal's own DID URL — the same DID
1558      // `sample_human_principal()` carries, which is what §7.1.11 binds.
1559      verification_method:
1560        "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy#z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy"
1561          .to_string(),
1562      created: "2026-05-21T00:00:02Z".to_string(),
1563      proof_purpose: "assertionMethod".to_string(),
1564      proof_value: "z-illustrative-principal-proof-value".to_string(),
1565      id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string()),
1566      previous_proof: std::option::Option::None,
1567    }
1568  }
1569
1570  fn sample_notary_countersignature() -> super::EnvelopeProof {
1571    super::EnvelopeProof {
1572      r#type: "DataIntegrityProof".to_string(),
1573      cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
1574      verification_method:
1575        "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
1576          .to_string(),
1577      created: "2026-05-21T00:00:03Z".to_string(),
1578      proof_purpose: "authentication".to_string(),
1579      proof_value: "z-illustrative-notary-countersignature".to_string(),
1580      id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000b1".to_string()),
1581      previous_proof: std::option::Option::Some(
1582        "urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string(),
1583      ),
1584    }
1585  }
1586
1587  // -------- Test 1: per-struct round-trip --------
1588
1589  #[test]
1590  fn round_trip_human_principal_ref() {
1591    // Per-struct round-trips exist because deny_unknown_fields makes every
1592    // sub-struct independently strict: a field lost or renamed here would
1593    // fail only when that specific object is parsed. This one carries the
1594    // human's DID — the identity the whole credential attests.
1595    let v = sample_human_principal();
1596    let s = serde_json::to_string(&v).unwrap();
1597    let v2: super::HumanPrincipalRef = serde_json::from_str(&s).unwrap();
1598    std::assert_eq!(v, v2);
1599  }
1600
1601  #[test]
1602  fn round_trip_agent_ref() {
1603    // The agent identity a recipient checks against the delegation; its
1604    // optional agentCardUri must survive round-tripping alongside the
1605    // required fields.
1606    let v = sample_agent();
1607    let s = serde_json::to_string(&v).unwrap();
1608    let v2: super::AgentRef = serde_json::from_str(&s).unwrap();
1609    std::assert_eq!(v, v2);
1610  }
1611
1612  #[test]
1613  fn round_trip_channel_descriptor() {
1614    // Holds the opaque recipientAddressing blob (§7.4), the one place
1615    // arbitrary JSON is preserved verbatim — this pins that it survives
1616    // untouched rather than being normalized or dropped.
1617    let v = sample_channel();
1618    let s = serde_json::to_string(&v).unwrap();
1619    let v2: super::ChannelDescriptor = serde_json::from_str(&s).unwrap();
1620    std::assert_eq!(v, v2);
1621  }
1622
1623  #[test]
1624  fn round_trip_communication_descriptor() {
1625    // Carries bodySha256 and bodySize — the binding between the credential
1626    // and the actual message. Any loss here breaks the APH_E009 body-hash
1627    // check that stops a signature being reused for a different body.
1628    let v = sample_communication();
1629    let s = serde_json::to_string(&v).unwrap();
1630    let v2: super::CommunicationDescriptor = serde_json::from_str(&s).unwrap();
1631    std::assert_eq!(v, v2);
1632  }
1633
1634  #[test]
1635  fn round_trip_policy_descriptor() {
1636    // The authorization record itself (decision, matched scope, delegation
1637    // id, act chain). Dropping actChain or delegationMandateId would erase
1638    // the evidence a verifier uses to trace authority back to the human.
1639    let v = sample_policy();
1640    let s = serde_json::to_string(&v).unwrap();
1641    let v2: super::PolicyDescriptor = serde_json::from_str(&s).unwrap();
1642    std::assert_eq!(v, v2);
1643  }
1644
1645  #[test]
1646  fn round_trip_notary_service_ref() {
1647    // Identifies which notary made the decision — needed for key discovery
1648    // (§8.4) and for auditing which service to hold accountable.
1649    let v = sample_notary_service();
1650    let s = serde_json::to_string(&v).unwrap();
1651    let v2: super::NotaryServiceRef = serde_json::from_str(&s).unwrap();
1652    std::assert_eq!(v, v2);
1653  }
1654
1655  #[test]
1656  fn round_trip_notarization_metadata() {
1657    // Decision timestamp and latency: audit evidence for whether a human
1658    // was plausibly in the loop, so it must survive intact.
1659    let v = sample_notarization_metadata();
1660    let s = serde_json::to_string(&v).unwrap();
1661    let v2: super::NotarizationMetadata = serde_json::from_str(&s).unwrap();
1662    std::assert_eq!(v, v2);
1663  }
1664
1665  #[test]
1666  fn round_trip_credential_subject() {
1667    // Composition test: the six sub-objects must nest correctly together.
1668    // Per-struct round-trips above cannot catch a broken assembly (e.g. a
1669    // field attached at the wrong level).
1670    let v = sample_credential_subject();
1671    let s = serde_json::to_string(&v).unwrap();
1672    let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
1673    std::assert_eq!(v, v2);
1674  }
1675
1676  #[test]
1677  fn round_trip_linked_mandate() {
1678    // Cross-protocol link (AP2 payment authorization, vault mutation). It
1679    // holds three independently-optional fields, so round-tripping proves
1680    // none of them is lost when the others are absent.
1681    let v = sample_linked_mandate();
1682    let s = serde_json::to_string(&v).unwrap();
1683    let v2: super::LinkedMandate = serde_json::from_str(&s).unwrap();
1684    std::assert_eq!(v, v2);
1685  }
1686
1687  #[test]
1688  fn round_trip_envelope_proof() {
1689    // The signature block itself. If any proof field failed to round-trip,
1690    // a re-serialized envelope would carry an unverifiable proof.
1691    let v = sample_proof();
1692    let s = serde_json::to_string(&v).unwrap();
1693    let v2: super::EnvelopeProof = serde_json::from_str(&s).unwrap();
1694    std::assert_eq!(v, v2);
1695  }
1696
1697  #[test]
1698  fn round_trip_notarization_envelope() {
1699    // The whole credential end to end — the exact operation a relaying
1700    // verifier performs. This is the last line of defense against any
1701    // field being silently dropped in transit.
1702    let v = sample_envelope();
1703    let s = serde_json::to_string(&v).unwrap();
1704    let v2: super::NotarizationEnvelope = serde_json::from_str(&s).unwrap();
1705    std::assert_eq!(v, v2);
1706  }
1707
1708  // -------- Test 2: wire-key shape for reserved/JSON-LD names --------
1709
1710  #[test]
1711  fn envelope_serializes_type_as_type_and_context_as_at_context() {
1712    // Two JSON-LD keys Rust cannot name directly: "@context" (illegal
1713    // identifier) and "type" (keyword, written r#type). Both depend on
1714    // explicit #[serde(rename)] attributes — drop one and the envelope
1715    // stops being a valid W3C Verifiable Credential.
1716    let v = sample_envelope();
1717    let s = serde_json::to_string(&v).unwrap();
1718    std::assert!(
1719      s.contains("\"type\":"),
1720      "envelope must serialize r#type as \"type\": {}",
1721      s
1722    );
1723    std::assert!(
1724      s.contains("\"@context\":"),
1725      "envelope must serialize context as \"@context\": {}",
1726      s
1727    );
1728    std::assert!(
1729      !s.contains("\"rType\""),
1730      "envelope must NOT serialize as \"rType\": {}",
1731      s
1732    );
1733    std::assert!(
1734      !s.contains("\"r#type\""),
1735      "envelope must NOT serialize as \"r#type\": {}",
1736      s
1737    );
1738  }
1739
1740  #[test]
1741  fn proof_serializes_type_as_type() {
1742    // Same r#type rename, applied on the nested proof block — pinned
1743    // separately because the attribute must be repeated per struct and is
1744    // easy to omit on a newly added one.
1745    let v = sample_proof();
1746    let s = serde_json::to_string(&v).unwrap();
1747    std::assert!(
1748      s.contains("\"type\":"),
1749      "proof must serialize r#type as \"type\": {}",
1750      s
1751    );
1752    std::assert!(
1753      !s.contains("\"rType\""),
1754      "proof must NOT serialize as \"rType\": {}",
1755      s
1756    );
1757  }
1758
1759  // -------- Test 3: deny_unknown_fields rejection --------
1760
1761  #[test]
1762  fn envelope_rejects_unknown_field() {
1763    // Strict parsing (§7.1, §8.3 step 1) is normative: an unknown envelope
1764    // field must be a hard error. Accepting-and-ignoring would let a
1765    // producer smuggle a claim the verifier never evaluates.
1766    let s = serde_json::json!({
1767      "aphVersion": "0.1",
1768      "@context": [
1769        "https://www.w3.org/ns/credentials/v2",
1770        "https://w3id.org/aph/v1"
1771      ],
1772      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
1773      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
1774      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
1775      "validFrom": "2026-05-21T00:00:00Z",
1776      "validUntil": "2026-05-22T00:00:00Z",
1777      "credentialSubject": {
1778        "humanPrincipal": {
1779          "id": "did:key:abc",
1780          "displayName": "X"
1781        },
1782        "agent": {
1783          "id": "did:web:agent.squillo.com",
1784          "displayName": "X",
1785          "version": "1.0"
1786        },
1787        "channel": {
1788          "kind": "slack",
1789          "recipientAddressing": {}
1790        },
1791        "communication": {
1792          "contentClass": "Reply",
1793          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1794          "bodySize": 0,
1795          "previewLines": 0,
1796          "preview": ""
1797        },
1798        "policy": {
1799          "decision": "AskEveryTime",
1800          "matchedScope": "per-channel"
1801        },
1802        "notarization": {
1803          "notaryService": {
1804            "id": "did:web:notary.squillo.com",
1805            "name": "Squillo Notary Service",
1806            "version": "0.1.0"
1807          },
1808          "decisionTimestamp": "2026-05-21T00:00:01Z",
1809          "decisionLatencyMs": 0
1810        }
1811      },
1812      "proof": {
1813        "type": "DataIntegrityProof",
1814        "verificationMethod": "did:key:abc#abc",
1815        "created": "2026-05-21T00:00:01Z",
1816        "proofPurpose": "assertionMethod",
1817        "proofValue": "z..."
1818      },
1819      "extraKey": "x"
1820    })
1821    .to_string();
1822    let r: std::result::Result<super::NotarizationEnvelope, _> = serde_json::from_str(&s);
1823    std::assert!(
1824      r.is_err(),
1825      "deny_unknown_fields must reject extraKey: {:?}",
1826      r
1827    );
1828  }
1829
1830  #[test]
1831  fn human_principal_ref_rejects_unknown_field() {
1832    // deny_unknown_fields does NOT cascade to nested structs — each one
1833    // needs its own attribute. This pins that the nested case is covered,
1834    // not merely the top level.
1835    let s = serde_json::json!({
1836      "id": "did:key:abc",
1837      "displayName": "X",
1838      "rogue": true
1839    })
1840    .to_string();
1841    let r: std::result::Result<super::HumanPrincipalRef, _> = serde_json::from_str(&s);
1842    std::assert!(
1843      r.is_err(),
1844      "deny_unknown_fields must reject rogue field: {:?}",
1845      r
1846    );
1847  }
1848
1849  // -------- Test 4: defaults on optional fields --------
1850
1851  #[test]
1852  fn envelope_deserializes_without_linked_mandate() {
1853    // Most envelopes carry no cross-protocol link, so the common case must
1854    // parse with the key absent — under deny_unknown_fields this works
1855    // only while #[serde(default)] is present.
1856    let s = serde_json::json!({
1857      "aphVersion": "0.1",
1858      "@context": [
1859        "https://www.w3.org/ns/credentials/v2",
1860        "https://w3id.org/aph/v1"
1861      ],
1862      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
1863      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
1864      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
1865      "validFrom": "2026-05-21T00:00:00Z",
1866      "validUntil": "2026-05-22T00:00:00Z",
1867      "credentialSubject": {
1868        "humanPrincipal": {
1869          "id": "did:key:abc",
1870          "displayName": "X"
1871        },
1872        "agent": {
1873          "id": "did:web:agent.squillo.com",
1874          "displayName": "X",
1875          "version": "1.0"
1876        },
1877        "channel": {
1878          "kind": "slack",
1879          "recipientAddressing": {}
1880        },
1881        "communication": {
1882          "contentClass": "Reply",
1883          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1884          "bodySize": 0,
1885          "previewLines": 0,
1886          "preview": ""
1887        },
1888        "policy": {
1889          "decision": "AskEveryTime",
1890          "matchedScope": "per-channel"
1891        },
1892        "notarization": {
1893          "notaryService": {
1894            "id": "did:web:notary.squillo.com",
1895            "name": "Squillo Notary Service",
1896            "version": "0.1.0"
1897          },
1898          "decisionTimestamp": "2026-05-21T00:00:01Z",
1899          "decisionLatencyMs": 0
1900        }
1901      },
1902      "proof": {
1903        "type": "DataIntegrityProof",
1904        "verificationMethod": "did:key:abc#abc",
1905        "created": "2026-05-21T00:00:01Z",
1906        "proofPurpose": "assertionMethod",
1907        "proofValue": "z..."
1908      }
1909    })
1910    .to_string();
1911    let v: super::NotarizationEnvelope =
1912      serde_json::from_str(&s).expect("must deserialize with linkedMandate omitted");
1913    std::assert!(v.linked_mandate.is_none());
1914  }
1915
1916  #[test]
1917  fn envelope_round_trips_a_credential_status_under_the_camel_case_wire_key() {
1918    // Why this exists: until it did, NOTHING in the repo parsed or emitted a
1919    // `credentialStatus` ON THE WIRE. The §6.3.3 tests set the field in Rust
1920    // and the golden test only asserts the key is ABSENT, so the camelCase
1921    // spelling the field exists to produce — and `deny_unknown_fields`
1922    // accepting it on `NotarizationEnvelope` at all — were pinned by nothing.
1923    // A rename to `credential_status`, or the field being dropped from the
1924    // struct, would have passed the entire suite while making every
1925    // status-bearing envelope from a conformant notary unparseable.
1926    //
1927    // What it pins, in order: the key is ACCEPTED by the strict parser; the
1928    // nested entry lands in the typed field with its closed enums resolved;
1929    // and serializing re-emits under exactly `credentialStatus` with exactly
1930    // the four required members (Pattern A drops the absent `id` — a `null`
1931    // there would change the JCS bytes a proof covers).
1932    let s = serde_json::json!({
1933      "aphVersion": "0.1",
1934      "@context": [
1935        "https://www.w3.org/ns/credentials/v2",
1936        "https://w3id.org/aph/v1"
1937      ],
1938      "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
1939      "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
1940      "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
1941      "validFrom": "2026-05-21T00:00:00Z",
1942      "validUntil": "2026-05-22T00:00:00Z",
1943      "credentialSubject": {
1944        "humanPrincipal": {
1945          "id": "did:key:abc",
1946          "displayName": "X"
1947        },
1948        "agent": {
1949          "id": "did:web:agent.squillo.com",
1950          "displayName": "X",
1951          "version": "1.0"
1952        },
1953        "channel": {
1954          "kind": "slack",
1955          "recipientAddressing": {}
1956        },
1957        "communication": {
1958          "contentClass": "Reply",
1959          "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1960          "bodySize": 0,
1961          "previewLines": 0,
1962          "preview": ""
1963        },
1964        "policy": {
1965          "decision": "AskEveryTime",
1966          "matchedScope": "per-channel",
1967          // §6.3.3.1 forbids a status reference with no parent mandate to be
1968          // the status OF, so the wire sample carries both or it is not a
1969          // sample of anything a notary may emit.
1970          "delegationMandateId": "urn:uuid:00000000-0000-4000-8000-0000000000d1"
1971        },
1972        "notarization": {
1973          "notaryService": {
1974            "id": "did:web:aph-notary.squillo.com",
1975            "name": "Squillo Notary Service",
1976            "version": "0.1.0"
1977          },
1978          "decisionTimestamp": "2026-05-21T00:00:01Z",
1979          "decisionLatencyMs": 0
1980        }
1981      },
1982      "credentialStatus": {
1983        "type": "BitstringStatusListEntry",
1984        "statusPurpose": "revocation",
1985        "statusListIndex": "94567",
1986        "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
1987      },
1988      "proof": {
1989        "type": "DataIntegrityProof",
1990        "verificationMethod": "did:key:abc#abc",
1991        "created": "2026-05-21T00:00:01Z",
1992        "proofPurpose": "assertionMethod",
1993        "proofValue": "z..."
1994      }
1995    })
1996    .to_string();
1997    let v: super::NotarizationEnvelope = serde_json::from_str(&s)
1998      .expect("deny_unknown_fields must ACCEPT the credentialStatus key");
1999    let entry = v
2000      .credential_status
2001      .as_ref()
2002      .expect("the wire key must land in the typed field");
2003    std::assert_eq!(
2004      entry.r#type,
2005      crate::credential_status::StatusEntryType::BitstringStatusListEntry
2006    );
2007    std::assert_eq!(
2008      entry.status_purpose,
2009      crate::credential_status::StatusPurpose::Revocation
2010    );
2011    std::assert_eq!(entry.index().expect("the index is readable"), 94567u64);
2012
2013    // Re-emitted under the SAME key: compared as parsed JSON, not as a
2014    // substring, so a serializer that wrote the right characters in the
2015    // wrong place could not pass.
2016    let back = serde_json::to_string(&v).expect("envelope serializes");
2017    let reparsed: serde_json::Value = serde_json::from_str(&back).expect("round-trip is JSON");
2018    let emitted = reparsed
2019      .get("credentialStatus")
2020      .expect("the member must be re-emitted as `credentialStatus`");
2021    std::assert_eq!(
2022      emitted,
2023      &serde_json::json!({
2024        "type": "BitstringStatusListEntry",
2025        "statusPurpose": "revocation",
2026        "statusListIndex": "94567",
2027        "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
2028      }),
2029      "the absent optional `id` must not appear, as a null or otherwise"
2030    );
2031    // And the member is INSIDE the bytes the notary proof covers. That is
2032    // what makes the reference unstrippable in flight: an attacker who
2033    // deleted `credentialStatus` to force §6.3.3.4 case 1 — the skip — would
2034    // be altering the signing base, and the proof would stop verifying.
2035    // Asserted on the canonical base rather than on the struct because the
2036    // base is the only place the property is actually true or false.
2037    let base = crate::crypto::proof_base::signing_base(
2038      &v,
2039      crate::crypto::proof_base::ProofRole::Notary,
2040    )
2041    .expect("a single notary proof builds a notary base");
2042    std::assert!(
2043      base.contains("\"credentialStatus\""),
2044      "the status reference must be covered by the notary signature"
2045    );
2046  }
2047
2048  #[test]
2049  fn agent_ref_deserializes_without_agent_card_uri() {
2050    // Not every agent publishes an A2A AgentCard; omitting the URI must
2051    // stay legal rather than making those agents unable to be notarized.
2052    let s = serde_json::json!({
2053      "id": "did:web:agent.squillo.com",
2054      "displayName": "X",
2055      "version": "1.0"
2056    })
2057    .to_string();
2058    let v: super::AgentRef =
2059      serde_json::from_str(&s).expect("must deserialize with agentCardUri omitted");
2060    std::assert!(v.agent_card_uri.is_none());
2061  }
2062
2063  #[test]
2064  fn policy_descriptor_deserializes_without_optionals() {
2065    // The one-shot AskEveryTime shape has no delegation and no act chain,
2066    // so both optional fields must be omissible together — the defaults
2067    // that make a human-present decision representable at all.
2068    let s = serde_json::json!({
2069      "decision": "AskEveryTime",
2070      "matchedScope": "per-channel"
2071    })
2072    .to_string();
2073    let v: super::PolicyDescriptor =
2074      serde_json::from_str(&s).expect("must deserialize with optionals omitted");
2075    std::assert!(v.delegation_mandate_id.is_none());
2076    std::assert!(v.act_chain.is_empty());
2077    std::assert!(v.attestation_mode.is_none());
2078    std::assert!(v.delegation_mandate.is_none());
2079  }
2080
2081  #[test]
2082  fn linked_mandate_deserializes_without_ap2_uri() {
2083    // A linkedMandate may carry a vault mutation with no payment link, so
2084    // its fields must be independently optional rather than all-or-nothing.
2085    let s = serde_json::json!({}).to_string();
2086    let v: super::LinkedMandate =
2087      serde_json::from_str(&s).expect("must deserialize with ap2IntentMandateUri omitted");
2088    std::assert!(v.ap2_intent_mandate_uri.is_none());
2089  }
2090
2091  #[test]
2092  fn envelope_proof_deserializes_without_cryptosuite() {
2093    // cryptosuite applies to DataIntegrityProof but not to the
2094    // JsonWebSignature2020 form (§8.2), so a JWS-style proof must parse
2095    // without it instead of being rejected as malformed.
2096    let s = serde_json::json!({
2097      "type": "JsonWebSignature2020",
2098      "verificationMethod": "did:key:abc#abc",
2099      "created": "2026-05-21T00:00:01Z",
2100      "proofPurpose": "assertionMethod",
2101      "proofValue": "z..."
2102    })
2103    .to_string();
2104    let v: super::EnvelopeProof =
2105      serde_json::from_str(&s).expect("must deserialize with cryptosuite omitted");
2106    std::assert!(v.cryptosuite.is_none());
2107  }
2108
2109  // -------- Test 5: camelCase wire form --------
2110
2111  #[test]
2112  fn human_principal_ref_serializes_camel_case() {
2113    // Rust field names are snake_case; the wire is camelCase. This pins
2114    // that the rename_all attribute is actually applied — without it every
2115    // key would silently change and no other implementation could parse it.
2116    let v = super::HumanPrincipalRef {
2117      id: "did:key:abc".to_string(),
2118      display_name: "Scott".to_string(),
2119    };
2120    let s = serde_json::to_string(&v).unwrap();
2121    std::assert!(
2122      s.contains("\"displayName\""),
2123      "must serialize display_name as displayName: {}",
2124      s
2125    );
2126    std::assert!(
2127      !s.contains("\"display_name\""),
2128      "must NOT serialize as display_name: {}",
2129      s
2130    );
2131  }
2132
2133  // -------- Test 6: AppleAurAcceptanceClaim round-trip --------
2134
2135  #[test]
2136  fn apple_aur_acceptance_claim_round_trip() {
2137    // Registered optional extension (spec §7.5.1). It must round-trip
2138    // fully when present while staying absent-by-default, so that
2139    // extension-free envelopes keep their exact pre-extension bytes.
2140    let claim = super::AppleAurAcceptanceClaim {
2141      user_id: "did:key:z6MkUserAbc123".to_string(),
2142      device_id: "device-opaque-id-001".to_string(),
2143      aur_version_hash:
2144        "a3b4c5d6e7f8091011121314151617181920212223242526272829303132333435".to_string(),
2145      accepted_at: "2026-06-09T00:00:00Z".to_string(),
2146      document_kind: "foundation_models_framework_aur".to_string(),
2147    };
2148    let subject = super::CredentialSubject {
2149      human_principal: sample_human_principal(),
2150      agent: sample_agent(),
2151      channel: sample_channel(),
2152      communication: sample_communication(),
2153      policy: sample_policy(),
2154      notarization: sample_notarization_metadata(),
2155      apple_aur_acceptance: std::option::Option::Some(claim.clone()),
2156      audience: std::option::Option::None,
2157      sealed_payload: std::option::Option::None,
2158      act_classification: std::option::Option::None,
2159    };
2160    let s = serde_json::to_string(&subject).unwrap();
2161    // wire form must use camelCase key
2162    std::assert!(
2163      s.contains("\"appleAurAcceptance\""),
2164      "must serialize apple_aur_acceptance as appleAurAcceptance: {}",
2165      s
2166    );
2167    let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
2168    std::assert_eq!(subject, v2);
2169    let recovered = v2.apple_aur_acceptance.expect("must be Some after round-trip");
2170    std::assert_eq!(recovered.document_kind, "foundation_models_framework_aur");
2171    std::assert_eq!(recovered.user_id, claim.user_id);
2172    std::assert_eq!(recovered.device_id, claim.device_id);
2173    std::assert_eq!(recovered.aur_version_hash, claim.aur_version_hash);
2174    std::assert_eq!(recovered.accepted_at, claim.accepted_at);
2175  }
2176
2177  // -------- Test 7: legacy wire back-compat (field absent) --------
2178
2179  #[test]
2180  fn credential_subject_legacy_omit_apple_aur_acceptance() {
2181    // A legacy CredentialSubject JSON payload that does NOT contain
2182    // `appleAurAcceptance` must still deserialize cleanly with
2183    // `apple_aur_acceptance == None` (wire back-compat).
2184    let s = serde_json::json!({
2185      "humanPrincipal": {
2186        "id": "did:key:z6MkLegacyUser",
2187        "displayName": "Legacy User"
2188      },
2189      "agent": {
2190        "id": "did:web:agent.squillo.com",
2191        "displayName": "Squillo Concierge",
2192        "version": "1.0"
2193      },
2194      "channel": {
2195        "kind": "slack",
2196        "recipientAddressing": {}
2197      },
2198      "communication": {
2199        "contentClass": "Reply",
2200        "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
2201        "bodySize": 0,
2202        "previewLines": 0,
2203        "preview": ""
2204      },
2205      "policy": {
2206        "decision": "AskEveryTime",
2207        "matchedScope": "per-channel"
2208      },
2209      "notarization": {
2210        "notaryService": {
2211          "id": "did:web:notary.squillo.com",
2212          "name": "Squillo Notary Service",
2213          "version": "0.1.0"
2214        },
2215        "decisionTimestamp": "2026-05-21T00:00:01Z",
2216        "decisionLatencyMs": 0
2217      }
2218    })
2219    .to_string();
2220    let v: super::CredentialSubject = serde_json::from_str(&s)
2221      .expect("legacy payload without appleAurAcceptance must deserialize cleanly");
2222    std::assert!(
2223      v.apple_aur_acceptance.is_none(),
2224      "apple_aur_acceptance must be None when absent from legacy wire payload"
2225    );
2226  }
2227
2228  // -------- Test 8: proof-chain wire forms (§7.1.11) --------
2229
2230  #[test]
2231  fn envelope_proofs_round_trips_the_single_object_wire_form() {
2232    // `proof` is untagged: the JSON SHAPE is the only discriminator between
2233    // a lone notary proof and a chain. If the object form ever deserialized
2234    // into `Chain` (or failed), every one of the eight published envelopes
2235    // — all of which carry a single object — would stop parsing.
2236    let single = super::EnvelopeProofs::Single(sample_proof());
2237    let s = serde_json::to_string(&single).unwrap();
2238    std::assert!(
2239      s.starts_with('{'),
2240      "the single form must serialize as a JSON OBJECT, not an array: {}",
2241      s
2242    );
2243    let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
2244    std::assert_eq!(single, back);
2245  }
2246
2247  #[test]
2248  fn envelope_proofs_round_trips_the_array_wire_form() {
2249    // The other half of the untagged discrimination. §7.2.1 makes the array
2250    // form load-bearing: `[{…}]` and `{…}` canonicalize to different bytes,
2251    // which is what domain-separates a principal proof from a lone notary
2252    // proof. Collapsing the array to an object on re-serialization would
2253    // silently forge that domain separation away.
2254    let chain = super::EnvelopeProofs::Chain(std::vec![
2255      sample_principal_proof(),
2256      sample_notary_countersignature(),
2257    ]);
2258    let s = serde_json::to_string(&chain).unwrap();
2259    std::assert!(
2260      s.starts_with('['),
2261      "the chain form must serialize as a JSON ARRAY: {}",
2262      s
2263    );
2264    let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
2265    std::assert_eq!(chain, back);
2266  }
2267
2268  #[test]
2269  fn envelope_with_absent_additive_fields_emits_none_of_their_keys() {
2270    // `attestationMode`, `id` and `previousProof` were added to structs
2271    // whose serialized bytes are what signatures cover. An envelope that
2272    // sets none of them MUST serialize byte-identically to one written
2273    // before the fields existed, or every already-issued signature breaks.
2274    let json = serde_json::to_string(&sample_envelope()).unwrap();
2275    std::assert!(
2276      !json.contains("attestationMode"),
2277      "absent attestationMode must not appear on the wire: {}",
2278      json
2279    );
2280    std::assert!(
2281      !json.contains("delegationMandate\""),
2282      "absent embedded delegationMandate must not appear on the wire: {}",
2283      json
2284    );
2285    std::assert!(
2286      !json.contains("previousProof"),
2287      "absent previousProof must not appear on the wire: {}",
2288      json
2289    );
2290    std::assert!(
2291      !json.contains("\"id\":null"),
2292      "absent proof id must be omitted, never emitted as null: {}",
2293      json
2294    );
2295  }
2296
2297  #[test]
2298  fn all_returns_every_proof_for_both_wire_forms() {
2299    // `all()` is what lets a caller iterate proofs without branching on the
2300    // variant. If the single form returned an empty slice, a verifier
2301    // looping over `all()` would verify NOTHING and report success.
2302    std::assert_eq!(
2303      super::EnvelopeProofs::Single(sample_proof()).all().len(),
2304      1
2305    );
2306    std::assert_eq!(
2307      super::EnvelopeProofs::Chain(std::vec![
2308        sample_principal_proof(),
2309        sample_notary_countersignature(),
2310      ])
2311      .all()
2312      .len(),
2313      2
2314    );
2315  }
2316
2317  #[test]
2318  fn principal_is_none_for_a_single_proof() {
2319    // A lone proof is the NOTARY's (§7.1.11). Reporting it as the principal
2320    // proof would let a caller present a notary attestation as the human's
2321    // own signature — the exact confusion `attestationMode` exists to stop.
2322    std::assert!(
2323      super::EnvelopeProofs::Single(sample_proof())
2324        .principal()
2325        .is_none()
2326    );
2327  }
2328
2329  #[test]
2330  fn notary_is_the_lone_proof_and_the_tail_of_a_chain() {
2331    // Both wire forms carry a notary proof, and it is the proof steps 2-9 of
2332    // §8.3 verify. A caller must be able to reach it identically either way.
2333    let single = super::EnvelopeProofs::Single(sample_proof());
2334    std::assert_eq!(
2335      single.notary().map(|p| p.proof_purpose.as_str()),
2336      std::option::Option::Some("assertionMethod")
2337    );
2338    let chain = super::EnvelopeProofs::Chain(std::vec![
2339      sample_principal_proof(),
2340      sample_notary_countersignature(),
2341    ]);
2342    std::assert_eq!(
2343      chain.notary().map(|p| p.proof_purpose.as_str()),
2344      std::option::Option::Some("authentication")
2345    );
2346  }
2347
2348  #[test]
2349  fn accessors_refuse_to_name_proofs_inside_a_malformed_chain() {
2350    // §7.1.11 fixes chain length at exactly two. A three-element array is
2351    // rejected there, but these accessors are called by code that has not
2352    // run that check yet, so they must not hand back a "principal proof" or
2353    // "notary proof" from a structure the spec refuses — a caller would
2354    // verify one signature out of three and believe the chain sound.
2355    let over_long = super::EnvelopeProofs::Chain(std::vec![
2356      sample_principal_proof(),
2357      sample_notary_countersignature(),
2358      sample_notary_countersignature(),
2359    ]);
2360    std::assert!(over_long.principal().is_none());
2361    std::assert!(over_long.notary().is_none());
2362  }
2363
2364  #[test]
2365  fn is_chain_reports_the_array_form_at_any_length() {
2366    // The distinction is the WIRE form, not validity: a one-element array is
2367    // still a chain (an invalid one). If `is_chain()` reported false for it,
2368    // §7.2.1's stripped-notary-proof attack would slip past — the whole
2369    // point is that a one-element array is recognised and then rejected.
2370    std::assert!(
2371      super::EnvelopeProofs::Chain(std::vec![sample_principal_proof()]).is_chain()
2372    );
2373    std::assert!(!super::EnvelopeProofs::Single(sample_proof()).is_chain());
2374  }
2375
2376  #[test]
2377  fn absent_attestation_mode_resolves_to_notary_attested() {
2378    // §7.1.7 fixes ABSENT as `NotaryAttested`, the WEAKER claim. All eight
2379    // published envelopes omit the field; defaulting the other way would
2380    // silently promote every one of them to asserting the human personally
2381    // signed, which no party ever claimed.
2382    std::assert_eq!(
2383      sample_policy().effective_attestation_mode(),
2384      super::AttestationMode::NotaryAttested
2385    );
2386  }
2387
2388  #[test]
2389  fn declared_attestation_mode_is_returned_verbatim() {
2390    // The default must apply only to absence. If it overrode a present
2391    // value, a `PrincipalSigned` envelope would be downgraded to the notary's
2392    // assertion and the human's signature would go unchecked.
2393    let mut policy = sample_policy();
2394    policy.attestation_mode = std::option::Option::Some(super::AttestationMode::PrincipalSigned);
2395    std::assert_eq!(
2396      policy.effective_attestation_mode(),
2397      super::AttestationMode::PrincipalSigned
2398    );
2399  }
2400
2401  #[test]
2402  fn attestation_mode_wire_spelling_matches_the_closed_enum() {
2403    // §7.1.7 fixes a CLOSED enum with these exact spellings, and other
2404    // implementations compare the string. A serde rename or a variant
2405    // rename would make our envelopes unreadable to them, and `label()` is
2406    // what error messages show an operator, so both must agree.
2407    std::assert_eq!(
2408      serde_json::to_string(&super::AttestationMode::PrincipalSigned).unwrap(),
2409      "\"PrincipalSigned\""
2410    );
2411    std::assert_eq!(
2412      serde_json::to_string(&super::AttestationMode::NotaryAttested).unwrap(),
2413      "\"NotaryAttested\""
2414    );
2415    std::assert_eq!(super::AttestationMode::PrincipalSigned.label(), "PrincipalSigned");
2416    std::assert_eq!(super::AttestationMode::NotaryAttested.label(), "NotaryAttested");
2417  }
2418
2419  #[test]
2420  fn envelope_parses_a_chain_and_the_principal_signed_label_from_the_wire() {
2421    // End-to-end wire acceptance of the §7.1.11 shape: a verifier written
2422    // against this crate must be able to receive a real `PrincipalSigned`
2423    // envelope. Both additive fields sit under `deny_unknown_fields`, so
2424    // without the declarations a conformant producer would be rejected.
2425    let mut envelope = sample_envelope();
2426    envelope.credential_subject.policy.attestation_mode =
2427      std::option::Option::Some(super::AttestationMode::PrincipalSigned);
2428    envelope.proof = super::EnvelopeProofs::Chain(std::vec![
2429      sample_principal_proof(),
2430      sample_notary_countersignature(),
2431    ]);
2432    let s = serde_json::to_string(&envelope).unwrap();
2433    let back: super::NotarizationEnvelope =
2434      serde_json::from_str(&s).expect("a PrincipalSigned chain envelope must parse");
2435    std::assert_eq!(envelope, back);
2436  }
2437}
2438
2439#[cfg(test)]
2440mod act_classification_tests {
2441  // WHY THIS MODULE EXISTS. `actClassification` is the first field added to
2442  // `credentialSubject` since every wire struct in this module acquired
2443  // `deny_unknown_fields`, which makes EMITTING it a version-gated event: a
2444  // verifier built before the field existed fails at strict parse rather than
2445  // ignoring it. Adding the field to the type is safe for everyone; putting
2446  // it on the wire is not. These tests pin the half that must stay safe.
2447
2448  /// A published golden, which predates this field and must stay untouched.
2449  fn golden() -> std::string::String {
2450    std::include_str!("../../../../examples/slack_reply_envelope.json").to_string()
2451  }
2452
2453  #[test]
2454  fn an_envelope_without_the_claim_round_trips_byte_identically() {
2455    // THE PIN THAT MATTERS, and it is verified by mutation rather than by
2456    // observing green: dropping `skip_serializing_if` from the field turns
2457    // this red. `skip_serializing_if` is a claim about OUTPUT, and a claim
2458    // about output is worth exactly as much as the byte comparison nobody
2459    // ran. Every published envelope predates this field — if it leaked into
2460    // serialization as `"actClassification": null`, every existing signature
2461    // over those bytes would break at once, and it would break SILENTLY,
2462    // because the envelope still parses.
2463    let raw = golden();
2464    let parsed: super::NotarizationEnvelope =
2465      serde_json::from_str(&raw).expect("a published golden must parse");
2466    std::assert!(
2467      parsed.credential_subject.act_classification.is_none(),
2468      "a published golden predates this field and must carry no claim"
2469    );
2470    let reserialized = serde_json::to_string(&parsed).expect("it serializes");
2471    std::assert!(
2472      !reserialized.contains("actClassification"),
2473      "an absent claim must not appear in output at all: {}",
2474      reserialized
2475    );
2476  }
2477
2478  #[test]
2479  fn a_classification_round_trips_through_serde() {
2480    // Pins that the serde delegation is wired for the nested types, including
2481    // `ActLabel`'s hand-written impls — a derive would have put a STRUCT on
2482    // the wire here instead of the `FAMILY/LABEL` string.
2483    let claim = super::ActClassification {
2484      vocabularies: std::vec![super::VocabularyRef {
2485        name: "aph_guardrails".to_string(),
2486        version: "0.1.0-alpha.1".to_string(),
2487        digest: "sha256-y6E/EGldCz2ogpVB7wlnS5orbnAjcCpoUBaDietJmXA=".to_string(),
2488      }],
2489      labels: std::vec![
2490        "APH_ACT_ACCESS/ACCESS_GRANT".parse().expect("a qualified label parses"),
2491      ],
2492    };
2493    let json = serde_json::to_string(&claim).expect("it serializes");
2494    std::assert!(
2495      json.contains("\"APH_ACT_ACCESS/ACCESS_GRANT\""),
2496      "a label rides the wire as its qualified STRING, not as a struct: {}",
2497      json
2498    );
2499    let back: super::ActClassification =
2500      serde_json::from_str(&json).expect("it round-trips");
2501    std::assert_eq!(claim, back);
2502  }
2503
2504  #[test]
2505  fn a_claim_citing_no_vocabulary_is_refused_at_parse() {
2506    // THE DIVERGENCE PIN, found by audit rather than by design: the
2507    // independent TypeScript implementation refused an empty `vocabularies`
2508    // from its first draft while this crate's derived Deserialize accepted
2509    // it — two conformant-claiming implementations reaching opposite
2510    // verdicts on the same bytes, the exact defect class QQ-era work closed
2511    // for `allowedChannels`, reintroduced in a field one day old. Per the
2512    // standing guardrail, the ACCEPTING surface was the wrong one.
2513    let refused = serde_json::from_str::<super::ActClassification>(
2514      r#"{"vocabularies":[],"labels":["A/B"]}"#,
2515    );
2516    std::assert!(refused.is_err(), "a claim citing no vocabulary must be refused");
2517    std::assert!(
2518      refused.unwrap_err().to_string().contains("claims nothing"),
2519      "the refusal must say why an empty citation is meaningless"
2520    );
2521  }
2522
2523  #[test]
2524  fn an_unqualified_label_is_refused_at_parse() {
2525    // WHY: a bare `ACCESS_GRANT` names nothing — the family is what scopes
2526    // it, and two vocabularies may both define the same bare word. Refusing
2527    // at parse is what stops the ambiguity being resolved later by whoever
2528    // guesses.
2529    let refused = "ACCESS_GRANT".parse::<super::ActLabel>();
2530    std::assert!(refused.is_err(), "an unqualified label must be refused");
2531    let message = refused.unwrap_err();
2532    std::assert!(
2533      message.contains("family-qualified"),
2534      "the refusal must say what shape was expected: {}",
2535      message
2536    );
2537  }
2538
2539  #[test]
2540  fn a_label_with_an_empty_side_or_two_separators_is_refused() {
2541    // The structural errors that would otherwise yield a label naming an
2542    // empty family, or one whose family is itself ambiguous.
2543    for raw in ["/ACCESS_GRANT", "APH_ACT_ACCESS/", "A/B/C"] {
2544      std::assert!(
2545        raw.parse::<super::ActLabel>().is_err(),
2546        "`{}` is structurally meaningless and must be refused",
2547        raw
2548      );
2549    }
2550  }
2551
2552  #[test]
2553  fn a_third_party_spelling_is_accepted_because_only_structure_is_ours() {
2554    // WHY: RFC 0006's extension model lets anyone publish a vocabulary. A
2555    // character-set rule imposed here would make that a formality — a
2556    // vocabulary naming its families in a convention this project did not
2557    // anticipate would be unusable. Refuse what is meaningless; do not
2558    // legislate taste.
2559    let parsed: super::ActLabel = "acme.finance/wire-transfer.initiate"
2560      .parse()
2561      .expect("an unfamiliar but well-formed spelling must be accepted");
2562    std::assert_eq!(parsed.family(), "acme.finance");
2563    std::assert_eq!(parsed.label(), "wire-transfer.initiate");
2564  }
2565}
2566
2567#[cfg(test)]
2568mod closed_vocabulary_tests {
2569  // These tests exist because the closed sets of §7.1.5 and §7.1.6 lived
2570  // only in prose while the wire fields were bare `String`s: this crate
2571  // accepted an out-of-set `kind` while the independent TypeScript
2572  // implementation refused it — two conformant-claiming verifiers reaching
2573  // opposite verdicts on the same bytes. Each test pins one half of the
2574  // repair.
2575
2576  #[test]
2577  fn every_channel_kind_wire_spelling_round_trips() {
2578    // label() and from_str() are the two halves of one mapping; if either
2579    // drifts (the google_chat snake_case erratum is the standing example of
2580    // how), envelopes minted by one implementation stop verifying in
2581    // another. ALL is the enumerable, so a variant added without updating
2582    // it is caught here rather than in a consumer.
2583    for kind in super::ChannelKind::ALL {
2584      let back: super::ChannelKind = kind
2585        .label()
2586        .parse()
2587        .expect("every published wire spelling must parse");
2588      std::assert_eq!(kind, back);
2589    }
2590  }
2591
2592  #[test]
2593  fn every_content_class_wire_spelling_round_trips() {
2594    // Same pin as the channel-kind twin. `DM` is the value most likely to
2595    // drift (a naive case transform yields `Dm`), so the round trip is what
2596    // stands between the enum and a silent casing fork.
2597    for class in super::ContentClass::ALL {
2598      let back: super::ContentClass = class
2599        .label()
2600        .parse()
2601        .expect("every published wire spelling must parse");
2602      std::assert_eq!(class, back);
2603    }
2604  }
2605
2606  #[test]
2607  fn every_policy_decision_wire_spelling_round_trips() {
2608    // The §7.1.7 set joins the closure late — an audit found this crate
2609    // validating it NOWHERE while the independent implementation refused at
2610    // parse — so it gets the same round-trip weld its two older siblings
2611    // have: label() and from_str() are two halves of one mapping, and this
2612    // is what keeps them from parting.
2613    for decision in super::PolicyDecision::ALL {
2614      let back: super::PolicyDecision = decision
2615        .label()
2616        .parse()
2617        .expect("every published wire spelling must parse");
2618      std::assert_eq!(decision, back);
2619    }
2620  }
2621
2622  #[test]
2623  fn an_unrecognized_policy_decision_is_refused_and_names_the_closed_set() {
2624    // `Sometimes` is a value no revision has ever defined and none will —
2625    // the durable form of a refusal pin, per the graduation lesson: pins
2626    // written against values someone is actively requesting have short
2627    // lives. A decision outside the set is how a producer would route past
2628    // policy keyed on one.
2629    let err = "Sometimes"
2630      .parse::<super::PolicyDecision>()
2631      .expect_err("a value outside the closed set must be refused");
2632    std::assert!(err.contains("closed set"), "must name the closed set: {err}");
2633    std::assert!(err.contains("AskEveryTime"), "must list the members: {err}");
2634  }
2635
2636  #[test]
2637  fn an_unrecognized_channel_kind_is_refused_and_names_the_closed_set() {
2638    // The divergence pin. This test was written against `service`, which
2639    // was then the exact value a draft wanted to emit and the reference
2640    // silently accepted. `service` has since been ADMITTED — the RFC's
2641    // stated prerequisite was this closure landing, and it did — so the pin
2642    // now uses a value that names no medium and never will. The property
2643    // under test never changed: an unrecognized value is refused, and the
2644    // message names the closed set so an operator reading a log learns what
2645    // WOULD have been accepted.
2646    let err = "carrier_pigeon"
2647      .parse::<super::ChannelKind>()
2648      .expect_err("a value outside the closed set must be refused");
2649    std::assert!(err.contains("closed set"), "error must name the closed set: {err}");
2650    std::assert!(err.contains("google_chat"), "error must list the members: {err}");
2651  }
2652
2653  #[test]
2654  fn an_unrecognized_content_class_is_refused() {
2655    // Twin of the channel-kind refusal, and it graduated the same way:
2656    // `Mutation` was this test's subject until the specification admitted
2657    // it. The replacement is a plausible-looking class that is not a member,
2658    // which is the case an operator actually hits.
2659    std::assert!("Digest".parse::<super::ContentClass>().is_err());
2660    std::assert!("reply".parse::<super::ContentClass>().is_err(), "case matters: wire is `Reply`");
2661  }
2662
2663  #[test]
2664  fn the_closed_sets_hold_their_declared_census() {
2665    // A deliberate census tripwire, not bookkeeping: adding a channel kind
2666    // or content class is a NORMATIVE event with a dozen documentation and
2667    // fixture surfaces attached. This test failing is the checklist firing.
2668    // When the service-act revision lands, update this count IN THE SAME
2669    // CHANGE as the spec tables, the examples inventory, and the bindings.
2670    std::assert_eq!(super::ChannelKind::ALL.len(), 9);
2671    std::assert_eq!(super::ContentClass::ALL.len(), 8);
2672    std::assert_eq!(super::PolicyDecision::ALL.len(), 3);
2673  }
2674}
2675
2676#[cfg(test)]
2677mod closed_vocabulary_deserialization_tests {
2678  // WHERE THE GUARANTEE MOVED. Before the field types closed, a separate
2679  // `require_closed_vocabulary` helper walked a parsed envelope and validated
2680  // two `String`s. That helper is gone — not because the check stopped
2681  // mattering, but because it is now unreachable-by-construction: a
2682  // `NotarizationEnvelope` cannot exist holding a value outside either closed
2683  // set, so the check happens at DESERIALIZATION and nothing downstream can
2684  // observe a violation. These tests are that helper's refusal cases, moved
2685  // to the boundary that now enforces them. Keeping them here is what stops
2686  // the swap from silently deleting the evidence a refusal was ever required.
2687
2688  fn golden() -> std::string::String {
2689    std::include_str!("../tests/golden/slack_reply_envelope.json").to_string()
2690  }
2691
2692  #[test]
2693  fn a_conformant_envelope_still_parses_after_the_types_closed() {
2694    // The swap must not narrow what the corpus admits. If this fails, the
2695    // wire spellings drifted from `label()` — the exact failure the serde
2696    // delegation exists to make impossible.
2697    let parsed: std::result::Result<super::NotarizationEnvelope, _> =
2698      serde_json::from_str(&golden());
2699    std::assert!(parsed.is_ok(), "the slack golden must still parse: {parsed:?}");
2700  }
2701
2702  #[test]
2703  fn an_envelope_naming_an_unrecognized_channel_is_refused_at_parse() {
2704    // Written when `service` was the live refusal case; it is now an
2705    // admitted kind, so the pin moved to a value outside the set. What it
2706    // proves is unchanged and is the whole point of closing the field: the
2707    // refusal happens AT PARSE, before any verification step runs, so a
2708    // verifier cannot silently verify an act it cannot describe.
2709    let doc = golden().replace("\"kind\": \"slack\"", "\"kind\": \"carrier_pigeon\"");
2710    let parsed: std::result::Result<super::NotarizationEnvelope, _> =
2711      serde_json::from_str(&doc);
2712    let err = parsed.expect_err("an unrecognized channel kind must be refused at parse");
2713    let msg = err.to_string();
2714    std::assert!(msg.contains("closed set"), "must name the closed set: {msg}");
2715  }
2716
2717  #[test]
2718  fn an_envelope_naming_an_unrecognized_content_class_is_refused_at_parse() {
2719    let doc = golden().replace("\"contentClass\": \"Reply\"", "\"contentClass\": \"Digest\"");
2720    let parsed: std::result::Result<super::NotarizationEnvelope, _> =
2721      serde_json::from_str(&doc);
2722    std::assert!(parsed.is_err(), "an unrecognized content class must be refused at parse");
2723  }
2724
2725  #[test]
2726  fn the_wire_spellings_survive_a_round_trip_through_serde() {
2727    // serde delegates to `label()`/`from_str`, so this pins that the
2728    // delegation is actually wired: a derive with a `rename_all` would pass
2729    // the tests above and still emit `Dm` for `DM`.
2730    for kind in super::ChannelKind::ALL {
2731      let json = serde_json::to_string(&kind).expect("a channel kind serializes");
2732      std::assert_eq!(json, std::format!("\"{}\"", kind.label()));
2733      let back: super::ChannelKind = serde_json::from_str(&json).expect("and parses back");
2734      std::assert_eq!(kind, back);
2735    }
2736    for class in super::ContentClass::ALL {
2737      let json = serde_json::to_string(&class).expect("a content class serializes");
2738      std::assert_eq!(json, std::format!("\"{}\"", class.label()));
2739      let back: super::ContentClass = serde_json::from_str(&json).expect("and parses back");
2740      std::assert_eq!(class, back);
2741    }
2742  }
2743}