1#[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 pub aph_version: String,
32 #[serde(rename = "@context")]
34 pub context: Vec<String>,
35 #[serde(rename = "type")]
38 pub r#type: Vec<String>,
39 pub id: String,
41 pub issuer: String,
43 pub valid_from: String,
45 pub valid_until: String,
47 pub credential_subject: CredentialSubject,
49 #[serde(default)]
51 pub linked_mandate: std::option::Option<LinkedMandate>,
52 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
68 pub credential_status:
69 std::option::Option<crate::credential_status::CredentialStatusEntry>,
70 pub proof: EnvelopeProofs,
73}
74
75#[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 Single(EnvelopeProof),
91 Chain(std::vec::Vec<EnvelopeProof>),
93}
94
95impl EnvelopeProofs {
96 pub fn all(&self) -> &[EnvelopeProof] {
98 match self {
99 Self::Single(proof) => std::slice::from_ref(proof),
102 Self::Chain(proofs) => proofs.as_slice(),
103 }
104 }
105
106 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 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 pub fn is_chain(&self) -> bool {
142 std::matches!(self, Self::Chain(_))
143 }
144}
145
146#[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 PrincipalSigned,
162 NotaryAttested,
164}
165
166impl AttestationMode {
167 pub fn label(&self) -> &'static str {
173 match self {
174 Self::PrincipalSigned => "PrincipalSigned",
175 Self::NotaryAttested => "NotaryAttested",
176 }
177 }
178}
179
180impl std::fmt::Display for AttestationMode {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 std::write!(f, "{}", self.label())
183 }
184}
185
186#[derive(
189 std::fmt::Debug,
190 std::clone::Clone,
191 std::cmp::PartialEq,
192 std::cmp::Eq,
193 serde::Serialize,
194 serde::Deserialize,
195)]
196#[serde(rename_all = "camelCase", deny_unknown_fields)]
197pub struct CredentialSubject {
198 pub human_principal: HumanPrincipalRef,
200 pub agent: AgentRef,
202 pub channel: ChannelDescriptor,
204 pub communication: CommunicationDescriptor,
206 pub policy: PolicyDescriptor,
208 pub notarization: NotarizationMetadata,
210 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
216 pub apple_aur_acceptance: std::option::Option<AppleAurAcceptanceClaim>,
217}
218
219#[derive(
221 std::fmt::Debug,
222 std::clone::Clone,
223 std::cmp::PartialEq,
224 std::cmp::Eq,
225 serde::Serialize,
226 serde::Deserialize,
227)]
228#[serde(rename_all = "camelCase", deny_unknown_fields)]
229pub struct HumanPrincipalRef {
230 pub id: String,
232 pub display_name: String,
234}
235
236#[derive(
238 std::fmt::Debug,
239 std::clone::Clone,
240 std::cmp::PartialEq,
241 std::cmp::Eq,
242 serde::Serialize,
243 serde::Deserialize,
244)]
245#[serde(rename_all = "camelCase", deny_unknown_fields)]
246pub struct AgentRef {
247 pub id: String,
249 #[serde(default)]
251 pub agent_card_uri: std::option::Option<String>,
252 pub display_name: String,
254 pub version: String,
256}
257
258#[derive(
260 std::fmt::Debug,
261 std::clone::Clone,
262 std::cmp::PartialEq,
263 std::cmp::Eq,
264 serde::Serialize,
265 serde::Deserialize,
266)]
267#[serde(rename_all = "camelCase", deny_unknown_fields)]
268pub struct ChannelDescriptor {
269 pub kind: String,
271 pub recipient_addressing: serde_json::Value,
273}
274
275#[derive(
278 std::fmt::Debug,
279 std::clone::Clone,
280 std::cmp::PartialEq,
281 std::cmp::Eq,
282 serde::Serialize,
283 serde::Deserialize,
284)]
285#[serde(rename_all = "camelCase", deny_unknown_fields)]
286pub struct CommunicationDescriptor {
287 pub content_class: String,
289 pub body_sha256: String,
291 pub body_size: u64,
293 pub preview_lines: u32,
295 pub preview: String,
297}
298
299#[derive(
301 std::fmt::Debug,
302 std::clone::Clone,
303 std::cmp::PartialEq,
304 std::cmp::Eq,
305 serde::Serialize,
306 serde::Deserialize,
307)]
308#[serde(rename_all = "camelCase", deny_unknown_fields)]
309pub struct PolicyDescriptor {
310 pub decision: String,
312 pub matched_scope: String,
314 #[serde(default)]
316 pub delegation_mandate_id: std::option::Option<String>,
317 #[serde(default)]
320 pub act_chain: Vec<String>,
321 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
323 pub attestation_mode: std::option::Option<AttestationMode>,
324 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
327 pub delegation_mandate: std::option::Option<crate::delegation_mandate::DelegationMandate>,
328}
329
330impl PolicyDescriptor {
331 pub fn effective_attestation_mode(&self) -> AttestationMode {
338 match self.attestation_mode {
339 std::option::Option::Some(mode) => mode,
340 std::option::Option::None => AttestationMode::NotaryAttested,
341 }
342 }
343}
344
345#[derive(
347 std::fmt::Debug,
348 std::clone::Clone,
349 std::cmp::PartialEq,
350 std::cmp::Eq,
351 serde::Serialize,
352 serde::Deserialize,
353)]
354#[serde(rename_all = "camelCase", deny_unknown_fields)]
355pub struct NotarizationMetadata {
356 pub notary_service: NotaryServiceRef,
358 pub decision_timestamp: String,
360 pub decision_latency_ms: u64,
363}
364
365#[derive(
367 std::fmt::Debug,
368 std::clone::Clone,
369 std::cmp::PartialEq,
370 std::cmp::Eq,
371 serde::Serialize,
372 serde::Deserialize,
373)]
374#[serde(rename_all = "camelCase", deny_unknown_fields)]
375pub struct NotaryServiceRef {
376 pub id: String,
378 pub name: String,
380 pub version: String,
382 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
388 pub attested_digest: std::option::Option<String>,
389 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
393 pub attestation_uri: std::option::Option<String>,
394}
395
396#[derive(
398 std::fmt::Debug,
399 std::clone::Clone,
400 std::cmp::PartialEq,
401 std::cmp::Eq,
402 serde::Serialize,
403 serde::Deserialize,
404)]
405#[serde(rename_all = "camelCase", deny_unknown_fields)]
406pub struct LinkedMandate {
407 #[serde(default)]
409 pub ap2_intent_mandate_uri: std::option::Option<String>,
410 #[serde(default)]
415 pub ap2_signed_payload_b64: std::option::Option<String>,
416 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
421 pub vault_mutation: std::option::Option<
422 crate::vault_mutation::VaultMutationMandate,
423 >,
424}
425
426#[derive(
427 std::fmt::Debug,
428 std::clone::Clone,
429 std::cmp::PartialEq,
430 std::cmp::Eq,
431 serde::Serialize,
432 serde::Deserialize,
433)]
434#[serde(rename_all = "camelCase", deny_unknown_fields)]
437pub struct EnvelopeProof {
438 #[serde(rename = "type")]
441 pub r#type: String,
442 #[serde(default)]
445 pub cryptosuite: std::option::Option<String>,
446 pub verification_method: String,
449 pub created: String,
451 pub proof_purpose: String,
454 pub proof_value: String,
456 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
459 pub id: std::option::Option<String>,
460 #[serde(default, skip_serializing_if = "std::option::Option::is_none")]
463 pub previous_proof: std::option::Option<String>,
464}
465
466#[derive(
473 std::fmt::Debug,
474 std::clone::Clone,
475 std::cmp::PartialEq,
476 std::cmp::Eq,
477 serde::Serialize,
478 serde::Deserialize,
479)]
480#[serde(rename_all = "camelCase", deny_unknown_fields)]
481pub struct AppleAurAcceptanceClaim {
482 pub user_id: String,
484 pub device_id: String,
487 pub aur_version_hash: String,
489 pub accepted_at: String,
491 pub document_kind: String,
493}
494
495#[cfg(test)]
496mod tests {
497 fn sample_human_principal() -> super::HumanPrincipalRef {
500 super::HumanPrincipalRef {
501 id: "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy".to_string(),
502 display_name: "Scott Wyatt".to_string(),
503 }
504 }
505
506 fn sample_agent() -> super::AgentRef {
507 super::AgentRef {
508 id: "did:web:agent.squillo.com".to_string(),
509 agent_card_uri: std::option::Option::Some(
510 "https://agent.squillo.com/.well-known/agent-card.json".to_string(),
511 ),
512 display_name: "Squillo Concierge".to_string(),
513 version: "1.0".to_string(),
514 }
515 }
516
517 fn sample_channel() -> super::ChannelDescriptor {
518 super::ChannelDescriptor {
519 kind: "slack".to_string(),
520 recipient_addressing: serde_json::json!({
521 "teamId": "T01234567",
522 "channelId": "C01234567",
523 "parentTs": "1716249600.000100"
524 }),
525 }
526 }
527
528 fn sample_communication() -> super::CommunicationDescriptor {
529 super::CommunicationDescriptor {
530 content_class: "Reply".to_string(),
531 body_sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(),
532 body_size: 1842,
533 preview_lines: 3,
534 preview: "hello world".to_string(),
535 }
536 }
537
538 fn sample_policy() -> super::PolicyDescriptor {
539 super::PolicyDescriptor {
540 decision: "AskEveryTime".to_string(),
541 matched_scope: "per-channel".to_string(),
542 delegation_mandate_id: std::option::Option::None,
543 act_chain: std::vec::Vec::new(),
544 attestation_mode: std::option::Option::None,
545 delegation_mandate: std::option::Option::None,
546 }
547 }
548
549 #[test]
550 fn attestation_fields_are_omitted_when_absent_and_accepted_when_present() {
551 let bare = sample_notary_service();
559 let json = serde_json::to_string(&bare).unwrap();
560 std::assert!(
561 !json.contains("attestedDigest") && !json.contains("attestationUri"),
562 "absent attestation fields must not appear on the wire: {}",
563 json
564 );
565
566 let with_claim: super::NotaryServiceRef = serde_json::from_str(
567 r#"{"id":"did:web:notary.squillo.com","name":"Squillo Notary Service","version":"0.1.0","attestedDigest":"sha256:abc","attestationUri":"https://notary.squillo.com/.well-known/aph-attestation.json"}"#,
568 )
569 .expect("a notary advertising an attestation must parse");
570 std::assert_eq!(
571 with_claim.attested_digest.as_deref(),
572 std::option::Option::Some("sha256:abc")
573 );
574 }
575
576 fn sample_notary_service() -> super::NotaryServiceRef {
577 super::NotaryServiceRef {
578 id: "did:web:notary.squillo.com".to_string(),
579 name: "Squillo Notary Service".to_string(),
580 version: "0.1.0".to_string(),
581 attested_digest: std::option::Option::None,
582 attestation_uri: std::option::Option::None,
583 }
584 }
585
586 fn sample_notarization_metadata() -> super::NotarizationMetadata {
587 super::NotarizationMetadata {
588 notary_service: sample_notary_service(),
589 decision_timestamp: "2026-05-21T00:00:01Z".to_string(),
590 decision_latency_ms: 1834,
591 }
592 }
593
594 fn sample_credential_subject() -> super::CredentialSubject {
595 super::CredentialSubject {
596 human_principal: sample_human_principal(),
597 agent: sample_agent(),
598 channel: sample_channel(),
599 communication: sample_communication(),
600 policy: sample_policy(),
601 notarization: sample_notarization_metadata(),
602 apple_aur_acceptance: std::option::Option::None,
603 }
604 }
605
606 fn sample_linked_mandate() -> super::LinkedMandate {
607 super::LinkedMandate {
608 ap2_intent_mandate_uri: std::option::Option::Some(
609 "urn:uuid:11111111-1111-4111-8111-111111111111".to_string(),
610 ),
611 ap2_signed_payload_b64: std::option::Option::None,
612 vault_mutation: std::option::Option::None,
613 }
614 }
615
616 fn sample_proof() -> super::EnvelopeProof {
617 super::EnvelopeProof {
618 r#type: "DataIntegrityProof".to_string(),
619 cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
620 verification_method:
621 "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
622 .to_string(),
623 created: "2026-05-21T00:00:01Z".to_string(),
624 proof_purpose: "assertionMethod".to_string(),
625 proof_value:
626 "z3WgvA9JHkbV3qLZHcM4FxBp4xHfQVnVnPKKDdyazQwQGdGzxsRdmZWBxXwQvN6P2sLZbLP4HnRy9LcZdpFLLM6h"
627 .to_string(),
628 id: std::option::Option::None,
629 previous_proof: std::option::Option::None,
630 }
631 }
632
633 fn sample_envelope() -> super::NotarizationEnvelope {
634 super::NotarizationEnvelope {
635 aph_version: "0.1".to_string(),
636 context: std::vec![
637 "https://www.w3.org/ns/credentials/v2".to_string(),
638 "https://w3id.org/aph/v1".to_string(),
639 ],
640 r#type: std::vec![
641 "VerifiableCredential".to_string(),
642 "AgentSendAuthorizationCredential".to_string(),
643 ],
644 id: "urn:uuid:00000000-0000-4000-8000-000000000001".to_string(),
645 issuer: "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV".to_string(),
646 valid_from: "2026-05-21T00:00:00Z".to_string(),
647 valid_until: "2026-05-22T00:00:00Z".to_string(),
648 credential_subject: sample_credential_subject(),
649 linked_mandate: std::option::Option::None,
650 credential_status: std::option::Option::None,
654 proof: super::EnvelopeProofs::Single(sample_proof()),
655 }
656 }
657
658 fn sample_principal_proof() -> super::EnvelopeProof {
659 super::EnvelopeProof {
660 r#type: "DataIntegrityProof".to_string(),
661 cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
662 verification_method:
665 "did:key:z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy#z6MkfAkfRZ3v9zJWh9LM2YQbWLh6hqGYDVxxC7ueoVcd5dGy"
666 .to_string(),
667 created: "2026-05-21T00:00:02Z".to_string(),
668 proof_purpose: "assertionMethod".to_string(),
669 proof_value: "z-illustrative-principal-proof-value".to_string(),
670 id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string()),
671 previous_proof: std::option::Option::None,
672 }
673 }
674
675 fn sample_notary_countersignature() -> super::EnvelopeProof {
676 super::EnvelopeProof {
677 r#type: "DataIntegrityProof".to_string(),
678 cryptosuite: std::option::Option::Some("eddsa-jcs-2022".to_string()),
679 verification_method:
680 "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV#z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV"
681 .to_string(),
682 created: "2026-05-21T00:00:03Z".to_string(),
683 proof_purpose: "authentication".to_string(),
684 proof_value: "z-illustrative-notary-countersignature".to_string(),
685 id: std::option::Option::Some("urn:uuid:00000000-0000-4000-8000-0000000000b1".to_string()),
686 previous_proof: std::option::Option::Some(
687 "urn:uuid:00000000-0000-4000-8000-0000000000a1".to_string(),
688 ),
689 }
690 }
691
692 #[test]
695 fn round_trip_human_principal_ref() {
696 let v = sample_human_principal();
701 let s = serde_json::to_string(&v).unwrap();
702 let v2: super::HumanPrincipalRef = serde_json::from_str(&s).unwrap();
703 std::assert_eq!(v, v2);
704 }
705
706 #[test]
707 fn round_trip_agent_ref() {
708 let v = sample_agent();
712 let s = serde_json::to_string(&v).unwrap();
713 let v2: super::AgentRef = serde_json::from_str(&s).unwrap();
714 std::assert_eq!(v, v2);
715 }
716
717 #[test]
718 fn round_trip_channel_descriptor() {
719 let v = sample_channel();
723 let s = serde_json::to_string(&v).unwrap();
724 let v2: super::ChannelDescriptor = serde_json::from_str(&s).unwrap();
725 std::assert_eq!(v, v2);
726 }
727
728 #[test]
729 fn round_trip_communication_descriptor() {
730 let v = sample_communication();
734 let s = serde_json::to_string(&v).unwrap();
735 let v2: super::CommunicationDescriptor = serde_json::from_str(&s).unwrap();
736 std::assert_eq!(v, v2);
737 }
738
739 #[test]
740 fn round_trip_policy_descriptor() {
741 let v = sample_policy();
745 let s = serde_json::to_string(&v).unwrap();
746 let v2: super::PolicyDescriptor = serde_json::from_str(&s).unwrap();
747 std::assert_eq!(v, v2);
748 }
749
750 #[test]
751 fn round_trip_notary_service_ref() {
752 let v = sample_notary_service();
755 let s = serde_json::to_string(&v).unwrap();
756 let v2: super::NotaryServiceRef = serde_json::from_str(&s).unwrap();
757 std::assert_eq!(v, v2);
758 }
759
760 #[test]
761 fn round_trip_notarization_metadata() {
762 let v = sample_notarization_metadata();
765 let s = serde_json::to_string(&v).unwrap();
766 let v2: super::NotarizationMetadata = serde_json::from_str(&s).unwrap();
767 std::assert_eq!(v, v2);
768 }
769
770 #[test]
771 fn round_trip_credential_subject() {
772 let v = sample_credential_subject();
776 let s = serde_json::to_string(&v).unwrap();
777 let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
778 std::assert_eq!(v, v2);
779 }
780
781 #[test]
782 fn round_trip_linked_mandate() {
783 let v = sample_linked_mandate();
787 let s = serde_json::to_string(&v).unwrap();
788 let v2: super::LinkedMandate = serde_json::from_str(&s).unwrap();
789 std::assert_eq!(v, v2);
790 }
791
792 #[test]
793 fn round_trip_envelope_proof() {
794 let v = sample_proof();
797 let s = serde_json::to_string(&v).unwrap();
798 let v2: super::EnvelopeProof = serde_json::from_str(&s).unwrap();
799 std::assert_eq!(v, v2);
800 }
801
802 #[test]
803 fn round_trip_notarization_envelope() {
804 let v = sample_envelope();
808 let s = serde_json::to_string(&v).unwrap();
809 let v2: super::NotarizationEnvelope = serde_json::from_str(&s).unwrap();
810 std::assert_eq!(v, v2);
811 }
812
813 #[test]
816 fn envelope_serializes_type_as_type_and_context_as_at_context() {
817 let v = sample_envelope();
822 let s = serde_json::to_string(&v).unwrap();
823 std::assert!(
824 s.contains("\"type\":"),
825 "envelope must serialize r#type as \"type\": {}",
826 s
827 );
828 std::assert!(
829 s.contains("\"@context\":"),
830 "envelope must serialize context as \"@context\": {}",
831 s
832 );
833 std::assert!(
834 !s.contains("\"rType\""),
835 "envelope must NOT serialize as \"rType\": {}",
836 s
837 );
838 std::assert!(
839 !s.contains("\"r#type\""),
840 "envelope must NOT serialize as \"r#type\": {}",
841 s
842 );
843 }
844
845 #[test]
846 fn proof_serializes_type_as_type() {
847 let v = sample_proof();
851 let s = serde_json::to_string(&v).unwrap();
852 std::assert!(
853 s.contains("\"type\":"),
854 "proof must serialize r#type as \"type\": {}",
855 s
856 );
857 std::assert!(
858 !s.contains("\"rType\""),
859 "proof must NOT serialize as \"rType\": {}",
860 s
861 );
862 }
863
864 #[test]
867 fn envelope_rejects_unknown_field() {
868 let s = serde_json::json!({
872 "aphVersion": "0.1",
873 "@context": [
874 "https://www.w3.org/ns/credentials/v2",
875 "https://w3id.org/aph/v1"
876 ],
877 "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
878 "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
879 "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
880 "validFrom": "2026-05-21T00:00:00Z",
881 "validUntil": "2026-05-22T00:00:00Z",
882 "credentialSubject": {
883 "humanPrincipal": {
884 "id": "did:key:abc",
885 "displayName": "X"
886 },
887 "agent": {
888 "id": "did:web:agent.squillo.com",
889 "displayName": "X",
890 "version": "1.0"
891 },
892 "channel": {
893 "kind": "slack",
894 "recipientAddressing": {}
895 },
896 "communication": {
897 "contentClass": "Reply",
898 "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
899 "bodySize": 0,
900 "previewLines": 0,
901 "preview": ""
902 },
903 "policy": {
904 "decision": "AskEveryTime",
905 "matchedScope": "per-channel"
906 },
907 "notarization": {
908 "notaryService": {
909 "id": "did:web:notary.squillo.com",
910 "name": "Squillo Notary Service",
911 "version": "0.1.0"
912 },
913 "decisionTimestamp": "2026-05-21T00:00:01Z",
914 "decisionLatencyMs": 0
915 }
916 },
917 "proof": {
918 "type": "DataIntegrityProof",
919 "verificationMethod": "did:key:abc#abc",
920 "created": "2026-05-21T00:00:01Z",
921 "proofPurpose": "assertionMethod",
922 "proofValue": "z..."
923 },
924 "extraKey": "x"
925 })
926 .to_string();
927 let r: std::result::Result<super::NotarizationEnvelope, _> = serde_json::from_str(&s);
928 std::assert!(
929 r.is_err(),
930 "deny_unknown_fields must reject extraKey: {:?}",
931 r
932 );
933 }
934
935 #[test]
936 fn human_principal_ref_rejects_unknown_field() {
937 let s = serde_json::json!({
941 "id": "did:key:abc",
942 "displayName": "X",
943 "rogue": true
944 })
945 .to_string();
946 let r: std::result::Result<super::HumanPrincipalRef, _> = serde_json::from_str(&s);
947 std::assert!(
948 r.is_err(),
949 "deny_unknown_fields must reject rogue field: {:?}",
950 r
951 );
952 }
953
954 #[test]
957 fn envelope_deserializes_without_linked_mandate() {
958 let s = serde_json::json!({
962 "aphVersion": "0.1",
963 "@context": [
964 "https://www.w3.org/ns/credentials/v2",
965 "https://w3id.org/aph/v1"
966 ],
967 "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
968 "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
969 "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
970 "validFrom": "2026-05-21T00:00:00Z",
971 "validUntil": "2026-05-22T00:00:00Z",
972 "credentialSubject": {
973 "humanPrincipal": {
974 "id": "did:key:abc",
975 "displayName": "X"
976 },
977 "agent": {
978 "id": "did:web:agent.squillo.com",
979 "displayName": "X",
980 "version": "1.0"
981 },
982 "channel": {
983 "kind": "slack",
984 "recipientAddressing": {}
985 },
986 "communication": {
987 "contentClass": "Reply",
988 "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
989 "bodySize": 0,
990 "previewLines": 0,
991 "preview": ""
992 },
993 "policy": {
994 "decision": "AskEveryTime",
995 "matchedScope": "per-channel"
996 },
997 "notarization": {
998 "notaryService": {
999 "id": "did:web:notary.squillo.com",
1000 "name": "Squillo Notary Service",
1001 "version": "0.1.0"
1002 },
1003 "decisionTimestamp": "2026-05-21T00:00:01Z",
1004 "decisionLatencyMs": 0
1005 }
1006 },
1007 "proof": {
1008 "type": "DataIntegrityProof",
1009 "verificationMethod": "did:key:abc#abc",
1010 "created": "2026-05-21T00:00:01Z",
1011 "proofPurpose": "assertionMethod",
1012 "proofValue": "z..."
1013 }
1014 })
1015 .to_string();
1016 let v: super::NotarizationEnvelope =
1017 serde_json::from_str(&s).expect("must deserialize with linkedMandate omitted");
1018 std::assert!(v.linked_mandate.is_none());
1019 }
1020
1021 #[test]
1022 fn envelope_round_trips_a_credential_status_under_the_camel_case_wire_key() {
1023 let s = serde_json::json!({
1038 "aphVersion": "0.1",
1039 "@context": [
1040 "https://www.w3.org/ns/credentials/v2",
1041 "https://w3id.org/aph/v1"
1042 ],
1043 "type": ["VerifiableCredential", "AgentSendAuthorizationCredential"],
1044 "id": "urn:uuid:00000000-0000-4000-8000-000000000001",
1045 "issuer": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSdoVTwBaPaeT1KhFmkV",
1046 "validFrom": "2026-05-21T00:00:00Z",
1047 "validUntil": "2026-05-22T00:00:00Z",
1048 "credentialSubject": {
1049 "humanPrincipal": {
1050 "id": "did:key:abc",
1051 "displayName": "X"
1052 },
1053 "agent": {
1054 "id": "did:web:agent.squillo.com",
1055 "displayName": "X",
1056 "version": "1.0"
1057 },
1058 "channel": {
1059 "kind": "slack",
1060 "recipientAddressing": {}
1061 },
1062 "communication": {
1063 "contentClass": "Reply",
1064 "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1065 "bodySize": 0,
1066 "previewLines": 0,
1067 "preview": ""
1068 },
1069 "policy": {
1070 "decision": "AskEveryTime",
1071 "matchedScope": "per-channel",
1072 "delegationMandateId": "urn:uuid:00000000-0000-4000-8000-0000000000d1"
1076 },
1077 "notarization": {
1078 "notaryService": {
1079 "id": "did:web:aph-notary.squillo.com",
1080 "name": "Squillo Notary Service",
1081 "version": "0.1.0"
1082 },
1083 "decisionTimestamp": "2026-05-21T00:00:01Z",
1084 "decisionLatencyMs": 0
1085 }
1086 },
1087 "credentialStatus": {
1088 "type": "BitstringStatusListEntry",
1089 "statusPurpose": "revocation",
1090 "statusListIndex": "94567",
1091 "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
1092 },
1093 "proof": {
1094 "type": "DataIntegrityProof",
1095 "verificationMethod": "did:key:abc#abc",
1096 "created": "2026-05-21T00:00:01Z",
1097 "proofPurpose": "assertionMethod",
1098 "proofValue": "z..."
1099 }
1100 })
1101 .to_string();
1102 let v: super::NotarizationEnvelope = serde_json::from_str(&s)
1103 .expect("deny_unknown_fields must ACCEPT the credentialStatus key");
1104 let entry = v
1105 .credential_status
1106 .as_ref()
1107 .expect("the wire key must land in the typed field");
1108 std::assert_eq!(
1109 entry.r#type,
1110 crate::credential_status::StatusEntryType::BitstringStatusListEntry
1111 );
1112 std::assert_eq!(
1113 entry.status_purpose,
1114 crate::credential_status::StatusPurpose::Revocation
1115 );
1116 std::assert_eq!(entry.index().expect("the index is readable"), 94567u64);
1117
1118 let back = serde_json::to_string(&v).expect("envelope serializes");
1122 let reparsed: serde_json::Value = serde_json::from_str(&back).expect("round-trip is JSON");
1123 let emitted = reparsed
1124 .get("credentialStatus")
1125 .expect("the member must be re-emitted as `credentialStatus`");
1126 std::assert_eq!(
1127 emitted,
1128 &serde_json::json!({
1129 "type": "BitstringStatusListEntry",
1130 "statusPurpose": "revocation",
1131 "statusListIndex": "94567",
1132 "statusListCredential": "https://aph-notary.squillo.com/.well-known/aph-status.json"
1133 }),
1134 "the absent optional `id` must not appear, as a null or otherwise"
1135 );
1136 let base = crate::crypto::proof_base::signing_base(
1143 &v,
1144 crate::crypto::proof_base::ProofRole::Notary,
1145 )
1146 .expect("a single notary proof builds a notary base");
1147 std::assert!(
1148 base.contains("\"credentialStatus\""),
1149 "the status reference must be covered by the notary signature"
1150 );
1151 }
1152
1153 #[test]
1154 fn agent_ref_deserializes_without_agent_card_uri() {
1155 let s = serde_json::json!({
1158 "id": "did:web:agent.squillo.com",
1159 "displayName": "X",
1160 "version": "1.0"
1161 })
1162 .to_string();
1163 let v: super::AgentRef =
1164 serde_json::from_str(&s).expect("must deserialize with agentCardUri omitted");
1165 std::assert!(v.agent_card_uri.is_none());
1166 }
1167
1168 #[test]
1169 fn policy_descriptor_deserializes_without_optionals() {
1170 let s = serde_json::json!({
1174 "decision": "AskEveryTime",
1175 "matchedScope": "per-channel"
1176 })
1177 .to_string();
1178 let v: super::PolicyDescriptor =
1179 serde_json::from_str(&s).expect("must deserialize with optionals omitted");
1180 std::assert!(v.delegation_mandate_id.is_none());
1181 std::assert!(v.act_chain.is_empty());
1182 std::assert!(v.attestation_mode.is_none());
1183 std::assert!(v.delegation_mandate.is_none());
1184 }
1185
1186 #[test]
1187 fn linked_mandate_deserializes_without_ap2_uri() {
1188 let s = serde_json::json!({}).to_string();
1191 let v: super::LinkedMandate =
1192 serde_json::from_str(&s).expect("must deserialize with ap2IntentMandateUri omitted");
1193 std::assert!(v.ap2_intent_mandate_uri.is_none());
1194 }
1195
1196 #[test]
1197 fn envelope_proof_deserializes_without_cryptosuite() {
1198 let s = serde_json::json!({
1202 "type": "JsonWebSignature2020",
1203 "verificationMethod": "did:key:abc#abc",
1204 "created": "2026-05-21T00:00:01Z",
1205 "proofPurpose": "assertionMethod",
1206 "proofValue": "z..."
1207 })
1208 .to_string();
1209 let v: super::EnvelopeProof =
1210 serde_json::from_str(&s).expect("must deserialize with cryptosuite omitted");
1211 std::assert!(v.cryptosuite.is_none());
1212 }
1213
1214 #[test]
1217 fn human_principal_ref_serializes_camel_case() {
1218 let v = super::HumanPrincipalRef {
1222 id: "did:key:abc".to_string(),
1223 display_name: "Scott".to_string(),
1224 };
1225 let s = serde_json::to_string(&v).unwrap();
1226 std::assert!(
1227 s.contains("\"displayName\""),
1228 "must serialize display_name as displayName: {}",
1229 s
1230 );
1231 std::assert!(
1232 !s.contains("\"display_name\""),
1233 "must NOT serialize as display_name: {}",
1234 s
1235 );
1236 }
1237
1238 #[test]
1241 fn apple_aur_acceptance_claim_round_trip() {
1242 let claim = super::AppleAurAcceptanceClaim {
1246 user_id: "did:key:z6MkUserAbc123".to_string(),
1247 device_id: "device-opaque-id-001".to_string(),
1248 aur_version_hash:
1249 "a3b4c5d6e7f8091011121314151617181920212223242526272829303132333435".to_string(),
1250 accepted_at: "2026-06-09T00:00:00Z".to_string(),
1251 document_kind: "foundation_models_framework_aur".to_string(),
1252 };
1253 let subject = super::CredentialSubject {
1254 human_principal: sample_human_principal(),
1255 agent: sample_agent(),
1256 channel: sample_channel(),
1257 communication: sample_communication(),
1258 policy: sample_policy(),
1259 notarization: sample_notarization_metadata(),
1260 apple_aur_acceptance: std::option::Option::Some(claim.clone()),
1261 };
1262 let s = serde_json::to_string(&subject).unwrap();
1263 std::assert!(
1265 s.contains("\"appleAurAcceptance\""),
1266 "must serialize apple_aur_acceptance as appleAurAcceptance: {}",
1267 s
1268 );
1269 let v2: super::CredentialSubject = serde_json::from_str(&s).unwrap();
1270 std::assert_eq!(subject, v2);
1271 let recovered = v2.apple_aur_acceptance.expect("must be Some after round-trip");
1272 std::assert_eq!(recovered.document_kind, "foundation_models_framework_aur");
1273 std::assert_eq!(recovered.user_id, claim.user_id);
1274 std::assert_eq!(recovered.device_id, claim.device_id);
1275 std::assert_eq!(recovered.aur_version_hash, claim.aur_version_hash);
1276 std::assert_eq!(recovered.accepted_at, claim.accepted_at);
1277 }
1278
1279 #[test]
1282 fn credential_subject_legacy_omit_apple_aur_acceptance() {
1283 let s = serde_json::json!({
1287 "humanPrincipal": {
1288 "id": "did:key:z6MkLegacyUser",
1289 "displayName": "Legacy User"
1290 },
1291 "agent": {
1292 "id": "did:web:agent.squillo.com",
1293 "displayName": "Squillo Concierge",
1294 "version": "1.0"
1295 },
1296 "channel": {
1297 "kind": "slack",
1298 "recipientAddressing": {}
1299 },
1300 "communication": {
1301 "contentClass": "Reply",
1302 "bodySha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1303 "bodySize": 0,
1304 "previewLines": 0,
1305 "preview": ""
1306 },
1307 "policy": {
1308 "decision": "AskEveryTime",
1309 "matchedScope": "per-channel"
1310 },
1311 "notarization": {
1312 "notaryService": {
1313 "id": "did:web:notary.squillo.com",
1314 "name": "Squillo Notary Service",
1315 "version": "0.1.0"
1316 },
1317 "decisionTimestamp": "2026-05-21T00:00:01Z",
1318 "decisionLatencyMs": 0
1319 }
1320 })
1321 .to_string();
1322 let v: super::CredentialSubject = serde_json::from_str(&s)
1323 .expect("legacy payload without appleAurAcceptance must deserialize cleanly");
1324 std::assert!(
1325 v.apple_aur_acceptance.is_none(),
1326 "apple_aur_acceptance must be None when absent from legacy wire payload"
1327 );
1328 }
1329
1330 #[test]
1333 fn envelope_proofs_round_trips_the_single_object_wire_form() {
1334 let single = super::EnvelopeProofs::Single(sample_proof());
1339 let s = serde_json::to_string(&single).unwrap();
1340 std::assert!(
1341 s.starts_with('{'),
1342 "the single form must serialize as a JSON OBJECT, not an array: {}",
1343 s
1344 );
1345 let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
1346 std::assert_eq!(single, back);
1347 }
1348
1349 #[test]
1350 fn envelope_proofs_round_trips_the_array_wire_form() {
1351 let chain = super::EnvelopeProofs::Chain(std::vec![
1357 sample_principal_proof(),
1358 sample_notary_countersignature(),
1359 ]);
1360 let s = serde_json::to_string(&chain).unwrap();
1361 std::assert!(
1362 s.starts_with('['),
1363 "the chain form must serialize as a JSON ARRAY: {}",
1364 s
1365 );
1366 let back: super::EnvelopeProofs = serde_json::from_str(&s).unwrap();
1367 std::assert_eq!(chain, back);
1368 }
1369
1370 #[test]
1371 fn envelope_with_absent_additive_fields_emits_none_of_their_keys() {
1372 let json = serde_json::to_string(&sample_envelope()).unwrap();
1377 std::assert!(
1378 !json.contains("attestationMode"),
1379 "absent attestationMode must not appear on the wire: {}",
1380 json
1381 );
1382 std::assert!(
1383 !json.contains("delegationMandate\""),
1384 "absent embedded delegationMandate must not appear on the wire: {}",
1385 json
1386 );
1387 std::assert!(
1388 !json.contains("previousProof"),
1389 "absent previousProof must not appear on the wire: {}",
1390 json
1391 );
1392 std::assert!(
1393 !json.contains("\"id\":null"),
1394 "absent proof id must be omitted, never emitted as null: {}",
1395 json
1396 );
1397 }
1398
1399 #[test]
1400 fn all_returns_every_proof_for_both_wire_forms() {
1401 std::assert_eq!(
1405 super::EnvelopeProofs::Single(sample_proof()).all().len(),
1406 1
1407 );
1408 std::assert_eq!(
1409 super::EnvelopeProofs::Chain(std::vec![
1410 sample_principal_proof(),
1411 sample_notary_countersignature(),
1412 ])
1413 .all()
1414 .len(),
1415 2
1416 );
1417 }
1418
1419 #[test]
1420 fn principal_is_none_for_a_single_proof() {
1421 std::assert!(
1425 super::EnvelopeProofs::Single(sample_proof())
1426 .principal()
1427 .is_none()
1428 );
1429 }
1430
1431 #[test]
1432 fn notary_is_the_lone_proof_and_the_tail_of_a_chain() {
1433 let single = super::EnvelopeProofs::Single(sample_proof());
1436 std::assert_eq!(
1437 single.notary().map(|p| p.proof_purpose.as_str()),
1438 std::option::Option::Some("assertionMethod")
1439 );
1440 let chain = super::EnvelopeProofs::Chain(std::vec![
1441 sample_principal_proof(),
1442 sample_notary_countersignature(),
1443 ]);
1444 std::assert_eq!(
1445 chain.notary().map(|p| p.proof_purpose.as_str()),
1446 std::option::Option::Some("authentication")
1447 );
1448 }
1449
1450 #[test]
1451 fn accessors_refuse_to_name_proofs_inside_a_malformed_chain() {
1452 let over_long = super::EnvelopeProofs::Chain(std::vec![
1458 sample_principal_proof(),
1459 sample_notary_countersignature(),
1460 sample_notary_countersignature(),
1461 ]);
1462 std::assert!(over_long.principal().is_none());
1463 std::assert!(over_long.notary().is_none());
1464 }
1465
1466 #[test]
1467 fn is_chain_reports_the_array_form_at_any_length() {
1468 std::assert!(
1473 super::EnvelopeProofs::Chain(std::vec![sample_principal_proof()]).is_chain()
1474 );
1475 std::assert!(!super::EnvelopeProofs::Single(sample_proof()).is_chain());
1476 }
1477
1478 #[test]
1479 fn absent_attestation_mode_resolves_to_notary_attested() {
1480 std::assert_eq!(
1485 sample_policy().effective_attestation_mode(),
1486 super::AttestationMode::NotaryAttested
1487 );
1488 }
1489
1490 #[test]
1491 fn declared_attestation_mode_is_returned_verbatim() {
1492 let mut policy = sample_policy();
1496 policy.attestation_mode = std::option::Option::Some(super::AttestationMode::PrincipalSigned);
1497 std::assert_eq!(
1498 policy.effective_attestation_mode(),
1499 super::AttestationMode::PrincipalSigned
1500 );
1501 }
1502
1503 #[test]
1504 fn attestation_mode_wire_spelling_matches_the_closed_enum() {
1505 std::assert_eq!(
1510 serde_json::to_string(&super::AttestationMode::PrincipalSigned).unwrap(),
1511 "\"PrincipalSigned\""
1512 );
1513 std::assert_eq!(
1514 serde_json::to_string(&super::AttestationMode::NotaryAttested).unwrap(),
1515 "\"NotaryAttested\""
1516 );
1517 std::assert_eq!(super::AttestationMode::PrincipalSigned.label(), "PrincipalSigned");
1518 std::assert_eq!(super::AttestationMode::NotaryAttested.label(), "NotaryAttested");
1519 }
1520
1521 #[test]
1522 fn envelope_parses_a_chain_and_the_principal_signed_label_from_the_wire() {
1523 let mut envelope = sample_envelope();
1528 envelope.credential_subject.policy.attestation_mode =
1529 std::option::Option::Some(super::AttestationMode::PrincipalSigned);
1530 envelope.proof = super::EnvelopeProofs::Chain(std::vec![
1531 sample_principal_proof(),
1532 sample_notary_countersignature(),
1533 ]);
1534 let s = serde_json::to_string(&envelope).unwrap();
1535 let back: super::NotarizationEnvelope =
1536 serde_json::from_str(&s).expect("a PrincipalSigned chain envelope must parse");
1537 std::assert_eq!(envelope, back);
1538 }
1539}