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