1use acdp_crypto::hash::derive_lineage_id;
8use acdp_primitives::error::AcdpError;
9use acdp_types::{
10 body::Body,
11 capabilities::CapabilitiesDocument,
12 primitives::{AgentDid, ContentHash, ContextType, CtxId, LineageId},
13 publish::PublishRequest,
14 revocation::KeyRevocation,
15};
16
17#[derive(Debug)]
20pub struct ValidatedPublish {
21 pub recomputed_hash: ContentHash,
23}
24
25pub struct PublishValidator<'a> {
32 caps: &'a CapabilitiesDocument,
33 own_authority: Option<&'a str>,
34}
35
36impl<'a> PublishValidator<'a> {
37 pub fn new(caps: &'a CapabilitiesDocument) -> Self {
39 Self {
40 caps,
41 own_authority: None,
42 }
43 }
44
45 pub fn for_authority(caps: &'a CapabilitiesDocument, own_authority: &'a str) -> Self {
53 Self {
54 caps,
55 own_authority: Some(own_authority),
56 }
57 }
58
59 pub fn validate_post_schema(
79 &self,
80 req: &PublishRequest,
81 raw_body_bytes: usize,
82 ) -> Result<ValidatedPublish, AcdpError> {
83 acdp_validation::validate_publish_request(req)?;
92 self.validate_registry_limits_and_crypto(req, None, raw_body_bytes)
93 }
94
95 pub fn validate_post_schema_raw(
114 &self,
115 req: &PublishRequest,
116 raw: &serde_json::Value,
117 raw_body_bytes: usize,
118 ) -> Result<ValidatedPublish, AcdpError> {
119 acdp_validation::validate_publish_request(req)?;
120 self.validate_registry_limits_and_crypto(req, Some(raw), raw_body_bytes)
121 }
122
123 #[deprecated(
132 since = "0.1.0",
133 note = "Use validate_post_schema; this alias no longer skips runtime validation"
134 )]
135 pub fn validate_structural(
136 &self,
137 req: &PublishRequest,
138 raw_body_bytes: usize,
139 ) -> Result<ValidatedPublish, AcdpError> {
140 self.validate_post_schema(req, raw_body_bytes)
141 }
142
143 fn validate_registry_limits_and_crypto(
147 &self,
148 req: &PublishRequest,
149 raw: Option<&serde_json::Value>,
150 raw_body_bytes: usize,
151 ) -> Result<ValidatedPublish, AcdpError> {
152 if raw_body_bytes as u64 > self.caps.limits.max_payload_bytes {
154 return Err(AcdpError::SchemaViolation(format!(
155 "payload {} bytes exceeds limit {}",
156 raw_body_bytes, self.caps.limits.max_payload_bytes
157 )));
158 }
159
160 for dr in &req.data_refs {
163 if let Some(emb) = &dr.embedded {
164 let decoded = acdp_validation::embedded_decoded_bytes(emb)?;
165 if decoded.len() as u64 > self.caps.limits.max_embedded_bytes {
166 return Err(AcdpError::EmbeddedTooLarge(format!(
167 "embedded data reference {} bytes exceeds {} limit",
168 decoded.len(),
169 self.caps.limits.max_embedded_bytes
170 )));
171 }
172 acdp_validation::verify_embedded_hash(dr)?;
175 }
176 }
177
178 let raw = raw.filter(|v| {
183 serde_json::from_value::<PublishRequest>((*v).clone())
184 .ok()
185 .and_then(|r| serde_json::to_value(r).ok())
186 == serde_json::to_value(req).ok()
187 });
188 match raw {
189 Some(v) => acdp_crypto::verify_content_hash(v, &req.content_hash)?,
190 None => acdp_verify::verify_typed_publish_request_hash(req)?,
191 }
192
193 if !self
195 .caps
196 .supported_signature_algorithms
197 .iter()
198 .any(|a| a == &req.signature.algorithm)
199 {
200 return Err(AcdpError::SchemaViolation(format!(
201 "unsupported algorithm '{}'; registry supports {:?}",
202 req.signature.algorithm, self.caps.supported_signature_algorithms,
203 )));
204 }
205
206 let agent_method = req
213 .agent_id
214 .as_str()
215 .splitn(3, ':')
216 .take(2)
217 .collect::<Vec<_>>()
218 .join(":");
219 if !self.caps.supports_did_method(&agent_method) {
220 return Err(AcdpError::KeyResolution(format!(
221 "agent_id method '{agent_method}' is not in this registry's \
222 supported_did_methods {:?}",
223 self.caps.supported_did_methods
224 )));
225 }
226
227 let key_id = &req.signature.key_id;
229 let did_part = key_id.split_once('#').map(|(d, _)| d).ok_or_else(|| {
230 AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'"))
231 })?;
232
233 if did_part != req.agent_id.as_str() {
234 return Err(AcdpError::KeyNotAuthorized(format!(
235 "key_id DID '{did_part}' ≠ agent_id '{}'",
236 req.agent_id
237 )));
238 }
239
240 if let (Some(own), Some(target)) = (self.own_authority, &req.supersedes) {
242 let target_authority = target.authority();
243 if target_authority != own {
244 return Err(AcdpError::SupersededTarget {
245 reason: acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported,
246 message: format!(
247 "supersedes target on '{target_authority}' rejected by '{own}'; \
248 v0.1.0 only allows same-registry supersession"
249 ),
250 });
251 }
252 }
253
254 if is_interim_key_revocation_form(&req.context_type)
264 && key_revocation_retirement_gate_applies(&self.caps.acdp_version)
265 {
266 return Err(AcdpError::SchemaViolation(format!(
267 "context_type '{}' (the interim key-revocation form) is retired for \
268 registries advertising acdp_version >= 0.5.0 (RFC-ACDP-0014 §10); \
269 publish using the standard 'key-revocation' context_type instead",
270 ContextType::KEY_REVOCATION_INTERIM
271 )));
272 }
273
274 if is_interim_key_revocation_form(&req.context_type)
290 && key_revocation_gate_applies(&self.caps.acdp_version)
291 {
292 KeyRevocation::check_not_self_signed_did_key_lenient(req)?;
293 self.check_revocation_controller_lenient(req)?;
294 }
295
296 if matches!(req.context_type, ContextType::KeyRevocation)
311 && key_revocation_gate_applies(&self.caps.acdp_version)
312 {
313 let revocation = KeyRevocation::from_publish_request(req)?;
314 self.check_revocation_controller(req, &revocation)?;
315 }
316
317 Ok(ValidatedPublish {
320 recomputed_hash: req.content_hash.clone(),
321 })
322 }
323
324 fn check_revocation_controller(
349 &self,
350 req: &PublishRequest,
351 revocation: &KeyRevocation,
352 ) -> Result<(), AcdpError> {
353 let controller_present = req
354 .metadata
355 .as_ref()
356 .and_then(|m| m.as_object())
357 .is_some_and(|m| m.contains_key("revoked_key_controller"));
358
359 let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
360 let controller_differs = revocation.revoked_key_controller != req.agent_id;
361
362 if controller_present && controller_differs && !agent_is_registry {
363 return Err(AcdpError::SchemaViolation(format!(
365 "metadata.revoked_key_controller '{}' differs from agent_id '{}', but \
366 agent_id is not this registry's own DID ('{}'); a controller different \
367 from agent_id is only valid on a §6 registry-attested revocation \
368 (RFC-ACDP-0014 §4, §6)",
369 revocation.revoked_key_controller, req.agent_id, self.caps.registry_did
370 )));
371 }
372
373 if !controller_present && agent_is_registry {
374 return Err(AcdpError::SchemaViolation(format!(
376 "key-revocation published under this registry's own DID ('{}') has no \
377 metadata.revoked_key_controller; a registry-attested revocation MUST \
378 name the affected producer's DID as the controller (RFC-ACDP-0014 §4, §6)",
379 self.caps.registry_did
380 )));
381 }
382
383 Ok(())
384 }
385
386 fn check_revocation_controller_lenient(&self, req: &PublishRequest) -> Result<(), AcdpError> {
401 let controller_raw = req
402 .metadata
403 .as_ref()
404 .and_then(|m| m.as_object())
405 .and_then(|m| m.get("revoked_key_controller"))
406 .and_then(|v| v.as_str());
407
408 let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
409
410 match controller_raw {
411 Some(raw) => {
412 let Ok(controller) = AgentDid::parse(raw) else {
413 return Ok(());
414 };
415 if controller != req.agent_id && !agent_is_registry {
416 return Err(AcdpError::SchemaViolation(format!(
418 "metadata.revoked_key_controller '{raw}' differs from agent_id '{}', \
419 but agent_id is not this registry's own DID ('{}'); a controller \
420 different from agent_id is only valid on a §6 registry-attested \
421 revocation (RFC-ACDP-0014 §5, §6)",
422 req.agent_id, self.caps.registry_did
423 )));
424 }
425 Ok(())
426 }
427 None => {
428 if agent_is_registry {
429 return Err(AcdpError::SchemaViolation(format!(
431 "key-revocation published under this registry's own DID ('{}') has \
432 no metadata.revoked_key_controller; a registry-attested revocation \
433 MUST name the affected producer's DID as the controller \
434 (RFC-ACDP-0014 §6)",
435 self.caps.registry_did
436 )));
437 }
438 Ok(())
439 }
440 }
441 }
442}
443
444fn is_well_formed_version(v: &str) -> bool {
451 let parts: Vec<&str> = v.split('.').collect();
452 parts.len() == 3
453 && parts
454 .iter()
455 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
456}
457
458fn parse_major_minor_saturating(v: &str) -> (u64, u64) {
474 let mut parts = v.split('.');
475 let major = parts.next().unwrap_or("0").parse().unwrap_or(u64::MAX);
476 let minor = parts.next().unwrap_or("0").parse().unwrap_or(u64::MAX);
477 (major, minor)
478}
479
480pub fn key_revocation_gate_applies(acdp_version: &str) -> bool {
503 if !is_well_formed_version(acdp_version) {
504 return true;
505 }
506 let (major, minor) = parse_major_minor_saturating(acdp_version);
507 major > 0 || minor >= 3
508}
509
510fn is_interim_key_revocation_form(ct: &ContextType) -> bool {
515 matches!(ct, ContextType::Custom(s) if s == ContextType::KEY_REVOCATION_INTERIM)
516}
517
518fn key_revocation_retirement_gate_applies(acdp_version: &str) -> bool {
527 if !is_well_formed_version(acdp_version) {
528 return true;
529 }
530 let (major, minor) = parse_major_minor_saturating(acdp_version);
531 major > 0 || minor >= 5
532}
533
534fn advertises_0_5_0_or_higher(acdp_version: &str) -> bool {
551 if !is_well_formed_version(acdp_version) {
552 return false;
553 }
554 let (major, minor) = parse_major_minor_saturating(acdp_version);
555 major > 0 || minor >= 5
556}
557
558pub fn check_revocation_supersession(
649 prev: &Body,
650 req: &PublishRequest,
651 acdp_version: &str,
652) -> Result<(), AcdpError> {
653 if !prev.context_type.is_key_revocation() {
654 return Ok(());
657 }
658
659 if !req.context_type.is_key_revocation() {
660 if advertises_0_5_0_or_higher(acdp_version) {
675 return Err(AcdpError::SupersededTarget {
676 reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
677 message: format!(
678 "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
679 another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
680 from agent_id '{}' has type '{}'",
681 prev.ctx_id,
682 req.agent_id,
683 context_type_label(&req.context_type),
684 ),
685 });
686 }
687 return Err(AcdpError::SchemaViolation(format!(
688 "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
689 another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
690 from agent_id '{}' has type '{}'",
691 prev.ctx_id,
692 req.agent_id,
693 context_type_label(&req.context_type),
694 )));
695 }
696
697 let prev_revocation = match KeyRevocation::from_body(prev) {
700 Ok(r) => r,
701 Err(_) => {
702 return Ok(());
720 }
721 };
722
723 let incoming_revocation = KeyRevocation::from_publish_request(req)?;
724
725 if prev_revocation.trust_class == incoming_revocation.trust_class {
726 Ok(())
729 } else {
730 Err(AcdpError::SchemaViolation(format!(
732 "ctx_id '{}' is a key-revocation with signer class {:?}; the incoming \
733 supersession from agent_id '{}' is a key-revocation with signer class {:?} \
734 — a revocation MAY only be superseded by another key-revocation from the \
735 same signer class (RFC-ACDP-0014 §4)",
736 prev.ctx_id, prev_revocation.trust_class, req.agent_id, incoming_revocation.trust_class,
737 )))
738 }
739}
740
741fn context_type_label(context_type: &acdp_types::primitives::ContextType) -> String {
745 serde_json::to_value(context_type)
746 .ok()
747 .and_then(|v| v.as_str().map(str::to_owned))
748 .unwrap_or_else(|| "<unrepresentable>".into())
749}
750
751pub fn assign_identifiers(
764 authority: &str,
765 supersedes: &Option<CtxId>,
766 first_version_ctx_id: Option<&CtxId>,
767 _validated: &ValidatedPublish,
768) -> Result<(CtxId, LineageId), AcdpError> {
769 let uuid = uuid::Uuid::new_v4();
770 let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
771 let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
772 (None, _) => &ctx_id,
773 (Some(_), Some(v1)) => v1,
774 (Some(_), None) => {
775 return Err(AcdpError::SchemaViolation(
776 "supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
777 ));
778 }
779 };
780 let lineage_id = derive_lineage_id(lineage_source);
781 Ok((ctx_id, lineage_id))
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787 use acdp_crypto::SigningKey;
788 use acdp_producer::Producer;
789 use acdp_types::{
790 capabilities::Limits, primitives::Visibility, revocation::RevocationTrustClass,
791 };
792
793 fn test_caps() -> CapabilitiesDocument {
794 CapabilitiesDocument {
795 acdp_version: "0.1.0".into(),
796 registry_did: "did:web:registry.example.com".into(),
797 supported_signature_algorithms: vec!["ed25519".into()],
798 supported_did_methods: vec!["did:web".into()],
799 profiles: vec!["acdp-registry-core".into()],
800 limits: Limits {
801 max_payload_bytes: 1_048_576,
802 max_embedded_bytes: 65_536,
803 idempotency_key_ttl_seconds: None,
804 max_publish_per_minute: None,
805 change_retention_seconds: None,
806 max_change_filters: None,
807 max_changes_page: None,
808 max_subscriptions_per_requester: None,
809 },
810 read_authentication_methods: vec![],
811 anonymous_public_reads: true,
812 supports_idempotency_key: false,
813 extensions: Default::default(),
814 }
815 }
816
817 fn test_request() -> PublishRequest {
818 let key = SigningKey::from_bytes(&[0u8; 32]);
819 let p = Producer::new(
820 key,
821 AgentDid::new("did:web:agents.example.com:test-producer"),
822 "did:web:agents.example.com:test-producer#key-1",
823 );
824 p.publish_request()
825 .title("Golden test vector — minimal first version")
826 .context_type(ContextType::DataSnapshot)
827 .visibility(Visibility::Public)
828 .build()
829 .unwrap()
830 }
831
832 fn respelled_request(spelling: &str) -> (serde_json::Value, PublishRequest) {
836 let key = SigningKey::from_bytes(&[0u8; 32]);
837 let req = Producer::new(
838 key,
839 AgentDid::new("did:web:agents.example.com:test-producer"),
840 "did:web:agents.example.com:test-producer#key-1",
841 )
842 .publish_request()
843 .title("Golden test vector — minimal first version")
844 .context_type(ContextType::DataSnapshot)
845 .visibility(Visibility::Public)
846 .expires_at(
847 chrono::DateTime::parse_from_rfc3339("2026-12-31T23:59:59Z")
848 .unwrap()
849 .with_timezone(&chrono::Utc),
850 )
851 .build()
852 .unwrap();
853 let mut raw = serde_json::to_value(&req).unwrap();
854 raw["expires_at"] = serde_json::json!(spelling);
855 let hash = acdp_crypto::compute_content_hash(&raw).unwrap();
856 raw["content_hash"] = serde_json::json!(hash.as_str());
857 let parsed = serde_json::from_value(raw.clone()).unwrap();
858 (raw, parsed)
859 }
860
861 #[test]
864 fn raw_path_accepts_non_canonical_spelling_typed_path_does_not() {
865 let caps = test_caps();
866 let v = PublishValidator::new(&caps);
867 let (raw, req) = respelled_request("2026-12-31T23:59:59+00:00");
868 let len = serde_json::to_vec(&raw).unwrap().len();
869 v.validate_post_schema_raw(&req, &raw, len).unwrap();
870 assert!(matches!(
871 v.validate_post_schema(&req, len),
872 Err(AcdpError::HashMismatch { .. })
873 ));
874 }
875
876 #[test]
879 fn typed_path_accepts_canonical_and_legacy_whole_seconds() {
880 let caps = test_caps();
881 let v = PublishValidator::new(&caps);
882 for spelling in ["2026-12-31T23:59:59.000Z", "2026-12-31T23:59:59Z"] {
883 let (raw, req) = respelled_request(spelling);
884 let len = serde_json::to_vec(&raw).unwrap().len();
885 v.validate_post_schema(&req, len)
886 .unwrap_or_else(|e| panic!("{spelling}: {e:?}"));
887 v.validate_post_schema_raw(&req, &raw, len).unwrap();
888 }
889 }
890
891 #[test]
894 fn raw_path_ignores_raw_that_does_not_describe_request() {
895 let caps = test_caps();
896 let v = PublishValidator::new(&caps);
897 let (raw, mut req) = respelled_request("2026-12-31T23:59:59+00:00");
898 req.title = "Tampered after parse".into();
899 let len = serde_json::to_vec(&raw).unwrap().len();
900 assert!(matches!(
901 v.validate_post_schema_raw(&req, &raw, len),
902 Err(AcdpError::HashMismatch { .. })
903 ));
904 }
905
906 #[test]
907 fn happy_path_validates() {
908 let caps = test_caps();
909 let v = PublishValidator::new(&caps);
910 let req = test_request();
911 let raw_len = serde_json::to_vec(&req).unwrap().len();
912 v.validate_post_schema(&req, raw_len).unwrap();
913 }
914
915 #[test]
916 fn payload_too_large_rejected() {
917 let mut caps = test_caps();
918 caps.limits.max_payload_bytes = 10;
919 let v = PublishValidator::new(&caps);
920 let req = test_request();
921 let err = v.validate_post_schema(&req, 1024).unwrap_err();
922 assert!(matches!(err, AcdpError::SchemaViolation(_)));
923 }
924
925 #[test]
926 fn unsupported_algorithm_rejected() {
927 let mut caps = test_caps();
928 caps.supported_signature_algorithms = vec!["secp256k1".into()];
929 let v = PublishValidator::new(&caps);
930 let req = test_request();
931 let err = v.validate_post_schema(&req, 1024).unwrap_err();
932 assert!(matches!(err, AcdpError::SchemaViolation(_)));
933 }
934
935 #[test]
936 fn key_id_without_fragment_rejected() {
937 let caps = test_caps();
938 let v = PublishValidator::new(&caps);
939 let mut req = test_request();
940 req.signature.key_id = "did:web:agents.example.com:test-producer".into();
941 let err = v.validate_post_schema(&req, 1024).unwrap_err();
942 assert!(matches!(err, AcdpError::KeyResolution(_)));
943 }
944
945 #[test]
946 fn key_id_did_must_match_agent_id() {
947 let caps = test_caps();
948 let v = PublishValidator::new(&caps);
949 let mut req = test_request();
950 req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
951 let err = v.validate_post_schema(&req, 1024).unwrap_err();
952 assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
953 }
954
955 #[test]
956 fn tampered_hash_detected() {
957 let caps = test_caps();
958 let v = PublishValidator::new(&caps);
959 let mut req = test_request();
960 req.title = "tampered title".into();
961 let err = v.validate_post_schema(&req, 1024).unwrap_err();
962 assert!(matches!(err, AcdpError::HashMismatch { .. }));
963 }
964
965 #[test]
966 fn assign_identifiers_first_version_derives_lineage_from_new_id() {
967 let v = ValidatedPublish {
968 recomputed_hash: ContentHash("sha256:abcd".into()),
969 };
970 let (ctx_id, lineage_id) =
971 assign_identifiers("registry.example.com", &None, None, &v).unwrap();
972 let expected = derive_lineage_id(&ctx_id);
973 assert_eq!(lineage_id, expected);
974 }
975
976 #[test]
977 fn assign_identifiers_supersession_uses_v1_ctx_id() {
978 let v = ValidatedPublish {
979 recomputed_hash: ContentHash("sha256:abcd".into()),
980 };
981 let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
982 let supersedes = Some(CtxId(
983 "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
984 ));
985 let (_new_id, lineage_id) =
986 assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
987 assert_eq!(lineage_id, derive_lineage_id(&v1));
988 }
989
990 #[test]
991 fn cross_registry_supersession_rejected() {
992 let caps = test_caps();
993 let v = PublishValidator::for_authority(&caps, "registry.example.com");
994 let key = SigningKey::from_bytes(&[0u8; 32]);
996 let p = Producer::new(
997 key,
998 AgentDid::new("did:web:agents.example.com:test-producer"),
999 "did:web:agents.example.com:test-producer#key-1",
1000 );
1001 let other_reg =
1002 CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
1003 let req = p
1004 .supersede(other_reg)
1005 .version(2)
1006 .title("v2")
1007 .context_type(ContextType::DataSnapshot)
1008 .build()
1009 .unwrap();
1010 let raw_len = serde_json::to_vec(&req).unwrap().len();
1011 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1012 match err {
1013 AcdpError::SupersededTarget { reason, .. } => {
1014 assert_eq!(
1015 reason,
1016 acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
1017 );
1018 }
1019 other => panic!("expected SupersededTarget, got {other:?}"),
1020 }
1021 }
1022
1023 #[test]
1024 fn same_registry_supersession_passes_authority_check() {
1025 let caps = test_caps();
1026 let v = PublishValidator::for_authority(&caps, "registry.example.com");
1027 let key = SigningKey::from_bytes(&[0u8; 32]);
1028 let p = Producer::new(
1029 key,
1030 AgentDid::new("did:web:agents.example.com:test-producer"),
1031 "did:web:agents.example.com:test-producer#key-1",
1032 );
1033 let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
1034 let req = p
1035 .supersede(same)
1036 .version(2)
1037 .title("v2")
1038 .context_type(ContextType::DataSnapshot)
1039 .build()
1040 .unwrap();
1041 let raw_len = serde_json::to_vec(&req).unwrap().len();
1042 v.validate_post_schema(&req, raw_len).unwrap();
1043 }
1044
1045 #[test]
1046 fn assign_identifiers_supersession_without_v1_id_rejected() {
1047 let v = ValidatedPublish {
1048 recomputed_hash: ContentHash("sha256:abcd".into()),
1049 };
1050 let supersedes = Some(CtxId("acdp://x/y".into()));
1051 let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
1052 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1053 }
1054
1055 fn test_caps_v030() -> CapabilitiesDocument {
1058 CapabilitiesDocument {
1059 acdp_version: "0.3.0".into(),
1060 ..test_caps()
1061 }
1062 }
1063
1064 fn test_caps_v020() -> CapabilitiesDocument {
1065 CapabilitiesDocument {
1066 acdp_version: "0.2.0".into(),
1067 ..test_caps()
1068 }
1069 }
1070
1071 fn test_caps_v050() -> CapabilitiesDocument {
1072 CapabilitiesDocument {
1073 acdp_version: "0.5.0".into(),
1074 ..test_caps()
1075 }
1076 }
1077
1078 const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
1079 const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
1080 const REVOCATION_FP: &str =
1081 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1082 const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
1083
1084 fn valid_revocation_metadata() -> serde_json::Value {
1085 serde_json::json!({
1086 "revoked_key_fingerprint": REVOCATION_FP,
1087 "compromised_since": REVOCATION_SINCE,
1088 })
1089 }
1090
1091 fn build_revocation_request(
1096 agent_did: &str,
1097 metadata: serde_json::Value,
1098 acdp_version: &str,
1099 ) -> PublishRequest {
1100 let key = SigningKey::from_bytes(&[0u8; 32]);
1101 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1102 p.publish_request()
1103 .title("Key revocation test")
1104 .context_type(ContextType::KeyRevocation)
1105 .visibility(Visibility::Public)
1106 .acdp_version(acdp_version)
1107 .metadata(metadata)
1108 .build()
1109 .unwrap()
1110 }
1111
1112 #[test]
1115 fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
1116 let caps = test_caps_v030();
1117 let v = PublishValidator::new(&caps);
1118 let req = build_revocation_request(
1119 REVOCATION_PRODUCER_DID,
1120 valid_revocation_metadata(),
1121 "0.3.0",
1122 );
1123 let raw_len = serde_json::to_vec(&req).unwrap().len();
1124 v.validate_post_schema(&req, raw_len).unwrap();
1125 }
1126
1127 #[test]
1130 fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
1131 let caps = test_caps_v030();
1132 let v = PublishValidator::new(&caps);
1133 let mut meta = valid_revocation_metadata();
1134 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1135 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1136 let raw_len = serde_json::to_vec(&req).unwrap().len();
1137 v.validate_post_schema(&req, raw_len).unwrap();
1138 }
1139
1140 #[test]
1143 fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
1144 let caps = test_caps_v030();
1145 let registry_did = caps.registry_did.clone();
1146 let v = PublishValidator::new(&caps);
1147 let mut meta = valid_revocation_metadata();
1148 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1149 let req = build_revocation_request(®istry_did, meta, "0.3.0");
1150 let raw_len = serde_json::to_vec(&req).unwrap().len();
1151 v.validate_post_schema(&req, raw_len).unwrap();
1152 }
1153
1154 #[test]
1157 fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
1158 let caps = test_caps_v030();
1159 let v = PublishValidator::new(&caps);
1160 let mut meta = valid_revocation_metadata();
1161 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1162 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1163 let raw_len = serde_json::to_vec(&req).unwrap().len();
1164 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1165 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1166 }
1167
1168 #[test]
1169 fn revocation_arm4_accepted_at_0_2_0_positive_control() {
1170 let caps = test_caps_v020();
1171 let v = PublishValidator::new(&caps);
1172 let mut meta = valid_revocation_metadata();
1173 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1174 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1175 let raw_len = serde_json::to_vec(&req).unwrap().len();
1176 v.validate_post_schema(&req, raw_len).unwrap();
1177 }
1178
1179 #[test]
1186 fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
1187 let caps = test_caps_v030();
1188 let registry_did = caps.registry_did.clone();
1189 let v = PublishValidator::new(&caps);
1190 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
1191 let raw_len = serde_json::to_vec(&req).unwrap().len();
1192 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1193 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1194 }
1195
1196 #[test]
1197 fn revocation_arm5_accepted_at_0_2_0_positive_control() {
1198 let caps = test_caps_v020();
1199 let registry_did = caps.registry_did.clone();
1200 let v = PublishValidator::new(&caps);
1201 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
1202 let raw_len = serde_json::to_vec(&req).unwrap().len();
1203 v.validate_post_schema(&req, raw_len).unwrap();
1204 }
1205
1206 #[test]
1207 fn revocation_non_public_visibility_rejected_at_0_3_0() {
1208 let caps = test_caps_v030();
1209 let v = PublishValidator::new(&caps);
1210 let key = SigningKey::from_bytes(&[0u8; 32]);
1211 let p = Producer::new(
1212 key,
1213 AgentDid::new(REVOCATION_PRODUCER_DID),
1214 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1215 );
1216 let req = p
1217 .publish_request()
1218 .title("Key revocation test")
1219 .context_type(ContextType::KeyRevocation)
1220 .visibility(Visibility::Restricted)
1221 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1222 .acdp_version("0.3.0")
1223 .metadata(valid_revocation_metadata())
1224 .build()
1225 .unwrap();
1226 let raw_len = serde_json::to_vec(&req).unwrap().len();
1227 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1228 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1229 }
1230
1231 #[test]
1232 fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
1233 let caps = test_caps_v020();
1234 let v = PublishValidator::new(&caps);
1235 let key = SigningKey::from_bytes(&[0u8; 32]);
1236 let p = Producer::new(
1237 key,
1238 AgentDid::new(REVOCATION_PRODUCER_DID),
1239 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1240 );
1241 let req = p
1242 .publish_request()
1243 .title("Key revocation test")
1244 .context_type(ContextType::KeyRevocation)
1245 .visibility(Visibility::Restricted)
1246 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1247 .acdp_version("0.3.0")
1248 .metadata(valid_revocation_metadata())
1249 .build()
1250 .unwrap();
1251 let raw_len = serde_json::to_vec(&req).unwrap().len();
1252 v.validate_post_schema(&req, raw_len).unwrap();
1253 }
1254
1255 #[test]
1256 fn revocation_missing_fingerprint_rejected_at_0_3_0() {
1257 let caps = test_caps_v030();
1258 let v = PublishValidator::new(&caps);
1259 let mut meta = valid_revocation_metadata();
1260 meta.as_object_mut()
1261 .unwrap()
1262 .remove("revoked_key_fingerprint");
1263 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1264 let raw_len = serde_json::to_vec(&req).unwrap().len();
1265 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1266 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1267 }
1268
1269 #[test]
1270 fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
1271 let caps = test_caps_v020();
1272 let v = PublishValidator::new(&caps);
1273 let mut meta = valid_revocation_metadata();
1274 meta.as_object_mut()
1275 .unwrap()
1276 .remove("revoked_key_fingerprint");
1277 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1278 let raw_len = serde_json::to_vec(&req).unwrap().len();
1279 v.validate_post_schema(&req, raw_len).unwrap();
1280 }
1281
1282 #[test]
1283 fn revocation_missing_compromised_since_rejected_at_0_3_0() {
1284 let caps = test_caps_v030();
1285 let v = PublishValidator::new(&caps);
1286 let mut meta = valid_revocation_metadata();
1287 meta.as_object_mut().unwrap().remove("compromised_since");
1288 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1289 let raw_len = serde_json::to_vec(&req).unwrap().len();
1290 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1291 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1292 }
1293
1294 #[test]
1295 fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
1296 let caps = test_caps_v020();
1297 let v = PublishValidator::new(&caps);
1298 let mut meta = valid_revocation_metadata();
1299 meta.as_object_mut().unwrap().remove("compromised_since");
1300 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1301 let raw_len = serde_json::to_vec(&req).unwrap().len();
1302 v.validate_post_schema(&req, raw_len).unwrap();
1303 }
1304
1305 #[test]
1306 fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
1307 let caps = test_caps_v030();
1308 let v = PublishValidator::new(&caps);
1309 let mut meta = valid_revocation_metadata();
1310 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1311 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1312 let raw_len = serde_json::to_vec(&req).unwrap().len();
1313 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1314 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1315 }
1316
1317 #[test]
1318 fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
1319 let caps = test_caps_v020();
1320 let v = PublishValidator::new(&caps);
1321 let mut meta = valid_revocation_metadata();
1322 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1323 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1324 let raw_len = serde_json::to_vec(&req).unwrap().len();
1325 v.validate_post_schema(&req, raw_len).unwrap();
1326 }
1327
1328 #[test]
1329 fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
1330 let caps = test_caps_v030();
1331 let v = PublishValidator::new(&caps);
1332 let mut meta = valid_revocation_metadata();
1333 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1334 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1335 let raw_len = serde_json::to_vec(&req).unwrap().len();
1336 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1337 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1338 }
1339
1340 #[test]
1341 fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
1342 let caps = test_caps_v020();
1343 let v = PublishValidator::new(&caps);
1344 let mut meta = valid_revocation_metadata();
1345 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1346 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1347 let raw_len = serde_json::to_vec(&req).unwrap().len();
1348 v.validate_post_schema(&req, raw_len).unwrap();
1349 }
1350
1351 #[test]
1352 fn revocation_reason_over_limit_rejected_at_0_3_0() {
1353 let caps = test_caps_v030();
1354 let v = PublishValidator::new(&caps);
1355 let mut meta = valid_revocation_metadata();
1356 meta["reason"] =
1357 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1358 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1359 let raw_len = serde_json::to_vec(&req).unwrap().len();
1360 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1361 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1362 }
1363
1364 #[test]
1365 fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
1366 let caps = test_caps_v020();
1367 let v = PublishValidator::new(&caps);
1368 let mut meta = valid_revocation_metadata();
1369 meta["reason"] =
1370 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1371 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1372 let raw_len = serde_json::to_vec(&req).unwrap().len();
1373 v.validate_post_schema(&req, raw_len).unwrap();
1374 }
1375
1376 #[test]
1384 fn revocation_reason_at_exactly_the_limit_accepted_at_0_3_0() {
1385 let caps = test_caps_v030();
1386 let v = PublishValidator::new(&caps);
1387 let mut meta = valid_revocation_metadata();
1388 meta["reason"] = serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS));
1389 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1390 let raw_len = serde_json::to_vec(&req).unwrap().len();
1391 v.validate_post_schema(&req, raw_len)
1392 .expect("a reason of exactly MAX_REASON_CHARS must be accepted, not rejected");
1393 }
1394
1395 #[test]
1400 fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
1401 let mut caps = test_caps_v030();
1402 caps.acdp_version = "not-a-version".into();
1403 let v = PublishValidator::new(&caps);
1404 let mut meta = valid_revocation_metadata();
1405 meta.as_object_mut()
1406 .unwrap()
1407 .remove("revoked_key_fingerprint");
1408 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1409 let raw_len = serde_json::to_vec(&req).unwrap().len();
1410 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1411 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1412 }
1413
1414 #[test]
1415 fn revocation_gate_fails_closed_on_empty_acdp_version() {
1416 let mut caps = test_caps_v030();
1417 caps.acdp_version = "".into();
1418 let v = PublishValidator::new(&caps);
1419 let mut meta = valid_revocation_metadata();
1420 meta.as_object_mut()
1421 .unwrap()
1422 .remove("revoked_key_fingerprint");
1423 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1424 let raw_len = serde_json::to_vec(&req).unwrap().len();
1425 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1426 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1427 }
1428
1429 #[test]
1432 fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
1433 let caps = test_caps_v030();
1434 let v = PublishValidator::new(&caps);
1435 let req = test_request();
1436 let raw_len = serde_json::to_vec(&req).unwrap().len();
1437 v.validate_post_schema(&req, raw_len).unwrap();
1438 }
1439
1440 #[test]
1449 fn key_revocation_gate_truth_table() {
1450 let cases: &[(&str, bool)] = &[
1451 ("0.3x.0", true),
1454 ("0. 3.0", true),
1457 ("0.2.0 ", true),
1461 ("0.2.0;", true),
1462 ("0.x.1", true),
1464 ("0.\u{0663}.0", true),
1468 ("not-a-version", true),
1471 ("", true),
1472 ("0.3.0", true),
1474 ("0.4.0", true),
1475 ("1.0.0", true),
1476 ("0.2.9", false),
1478 ("0.2.0", false),
1479 ];
1480 for (input, expected) in cases {
1481 assert_eq!(
1482 key_revocation_gate_applies(input),
1483 *expected,
1484 "input {input:?} should gate {}",
1485 if *expected { "ON" } else { "OFF" }
1486 );
1487 }
1488 }
1489
1490 #[test]
1503 fn zero_five_zero_threshold_gates_truth_table() {
1504 let cases: &[(&str, bool, bool)] = &[
1505 ("0.5.0", true, true),
1507 ("0.4.9", false, false),
1508 ("1.0.0", true, true),
1509 ("0.5.1", true, true),
1510 ("0.05.0", true, true),
1515 ("000.005.000", true, true),
1516 ("0.5.0-alpha", true, false),
1520 ("0.5.0.1", true, false),
1523 ("99999999999999999999.0.0", true, true),
1531 ];
1532 for (input, retirement_expected, advertises_expected) in cases {
1533 assert_eq!(
1534 key_revocation_retirement_gate_applies(input),
1535 *retirement_expected,
1536 "§10 retirement gate: input {input:?} should gate {}",
1537 if *retirement_expected { "ON" } else { "OFF" }
1538 );
1539 assert_eq!(
1540 advertises_0_5_0_or_higher(input),
1541 *advertises_expected,
1542 "advertises_0_5_0_or_higher: input {input:?} should be {advertises_expected}"
1543 );
1544 }
1545 }
1546
1547 fn build_revocation_request_with_type(
1553 agent_did: &str,
1554 metadata: serde_json::Value,
1555 acdp_version: &str,
1556 context_type: ContextType,
1557 ) -> PublishRequest {
1558 let key = SigningKey::from_bytes(&[0u8; 32]);
1559 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1560 p.publish_request()
1561 .title("Key revocation test (interim §10 type)")
1562 .context_type(context_type)
1563 .visibility(Visibility::Public)
1564 .acdp_version(acdp_version)
1565 .metadata(metadata)
1566 .build()
1567 .unwrap()
1568 }
1569
1570 #[test]
1581 fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
1582 let caps = test_caps_v030();
1583 let v = PublishValidator::new(&caps);
1584 let req = build_revocation_request_with_type(
1585 REVOCATION_PRODUCER_DID,
1586 valid_revocation_metadata(),
1587 "0.3.0",
1588 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1589 );
1590 let raw_len = serde_json::to_vec(&req).unwrap().len();
1591 v.validate_post_schema(&req, raw_len).unwrap();
1592 }
1593
1594 #[test]
1600 fn revocation_interim_custom_type_malformed_body_accepted_at_0_3_0() {
1601 let caps = test_caps_v030();
1602 let v = PublishValidator::new(&caps);
1603 let mut meta = valid_revocation_metadata();
1604 meta.as_object_mut()
1605 .unwrap()
1606 .remove("revoked_key_fingerprint");
1607 let req = build_revocation_request_with_type(
1608 REVOCATION_PRODUCER_DID,
1609 meta,
1610 "0.3.0",
1611 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1612 );
1613 let raw_len = serde_json::to_vec(&req).unwrap().len();
1614 v.validate_post_schema(&req, raw_len).unwrap();
1615 }
1616
1617 fn did_key_producer_fixture(seed: [u8; 32]) -> (SigningKey, String, String, String) {
1618 let key = SigningKey::from_bytes(&seed);
1619 let public_key = key.verifying_key_bytes();
1620 let did = acdp_did::key::did_key_from_ed25519(&public_key);
1621 let key_id = acdp_did::key::did_key_url(&did).unwrap();
1622 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
1623 (key, did, key_id, fingerprint)
1624 }
1625
1626 fn caps_v030_with_did_key() -> CapabilitiesDocument {
1627 CapabilitiesDocument {
1628 acdp_version: "0.3.0".into(),
1629 supported_did_methods: vec!["did:web".into(), "did:key".into()],
1630 ..test_caps()
1631 }
1632 }
1633
1634 #[test]
1644 fn revocation_interim_custom_type_did_key_self_signed_rejected_at_0_3_0() {
1645 let (key, did, key_id, fingerprint) = did_key_producer_fixture([9u8; 32]);
1646 let mut meta = valid_revocation_metadata();
1647 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
1648
1649 let req = Producer::new(key, AgentDid::new(&did), key_id)
1650 .publish_request()
1651 .title("self-signed interim revocation")
1652 .context_type(ContextType::Custom(
1653 ContextType::KEY_REVOCATION_INTERIM.into(),
1654 ))
1655 .visibility(Visibility::Public)
1656 .acdp_version("0.3.0")
1657 .metadata(meta)
1658 .build()
1659 .unwrap();
1660
1661 let caps = caps_v030_with_did_key();
1662 let v = PublishValidator::new(&caps);
1663 let raw_len = serde_json::to_vec(&req).unwrap().len();
1664 assert!(matches!(
1665 v.validate_post_schema(&req, raw_len),
1666 Err(AcdpError::KeyNotAuthorized(_))
1667 ));
1668 }
1669
1670 #[test]
1676 fn revocation_interim_custom_type_did_key_different_key_accepted_at_0_3_0() {
1677 let (key, did, key_id, _fingerprint) = did_key_producer_fixture([10u8; 32]);
1678 let meta = valid_revocation_metadata();
1681
1682 let req = Producer::new(key, AgentDid::new(&did), key_id)
1683 .publish_request()
1684 .title("non-self-signed interim revocation")
1685 .context_type(ContextType::Custom(
1686 ContextType::KEY_REVOCATION_INTERIM.into(),
1687 ))
1688 .visibility(Visibility::Public)
1689 .acdp_version("0.3.0")
1690 .metadata(meta)
1691 .build()
1692 .unwrap();
1693
1694 let caps = caps_v030_with_did_key();
1695 let v = PublishValidator::new(&caps);
1696 let raw_len = serde_json::to_vec(&req).unwrap().len();
1697 v.validate_post_schema(&req, raw_len)
1698 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
1699 }
1700
1701 #[test]
1708 fn revocation_interim_custom_type_mismatched_controller_rejected_at_0_3_0() {
1709 let mut meta = valid_revocation_metadata();
1710 meta["revoked_key_controller"] = serde_json::json!("did:web:someone-else.example.com");
1711 let req = build_revocation_request_with_type(
1712 REVOCATION_PRODUCER_DID,
1713 meta,
1714 "0.3.0",
1715 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1716 );
1717 let caps = test_caps_v030();
1718 let v = PublishValidator::new(&caps);
1719 let raw_len = serde_json::to_vec(&req).unwrap().len();
1720 assert!(matches!(
1721 v.validate_post_schema(&req, raw_len),
1722 Err(AcdpError::SchemaViolation(_))
1723 ));
1724 }
1725
1726 #[test]
1730 fn revocation_interim_custom_type_registry_attested_controller_accepted_at_0_3_0() {
1731 let caps = test_caps_v030();
1732 let mut meta = valid_revocation_metadata();
1733 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1734 let req = build_revocation_request_with_type(
1735 &caps.registry_did,
1736 meta,
1737 "0.3.0",
1738 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1739 );
1740 let v = PublishValidator::new(&caps);
1741 let raw_len = serde_json::to_vec(&req).unwrap().len();
1742 v.validate_post_schema(&req, raw_len)
1743 .expect("registry-attested interim revocation with a named controller must pass");
1744 }
1745
1746 #[test]
1751 fn revocation_interim_custom_type_registry_attested_missing_controller_rejected_at_0_3_0() {
1752 let caps = test_caps_v030();
1753 let meta = valid_revocation_metadata();
1754 let req = build_revocation_request_with_type(
1755 &caps.registry_did,
1756 meta,
1757 "0.3.0",
1758 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1759 );
1760 let v = PublishValidator::new(&caps);
1761 let raw_len = serde_json::to_vec(&req).unwrap().len();
1762 assert!(matches!(
1763 v.validate_post_schema(&req, raw_len),
1764 Err(AcdpError::SchemaViolation(_))
1765 ));
1766 }
1767
1768 #[test]
1776 fn revocation_interim_custom_type_rejected_unconditionally_at_0_5_0() {
1777 let caps = test_caps_v050();
1778 let v = PublishValidator::new(&caps);
1779 let req = build_revocation_request_with_type(
1780 REVOCATION_PRODUCER_DID,
1781 valid_revocation_metadata(),
1782 "0.5.0",
1783 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1784 );
1785 let raw_len = serde_json::to_vec(&req).unwrap().len();
1786 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1787 assert!(
1788 matches!(err, AcdpError::SchemaViolation(_)),
1789 "the interim form must be rejected unconditionally at >= 0.5.0, got {err:?}"
1790 );
1791 }
1792
1793 #[test]
1799 fn interim_form_retirement_gate_fails_closed_on_malformed_acdp_version() {
1800 let mut caps = test_caps_v050();
1801 caps.acdp_version = "not-a-version".into();
1802 let v = PublishValidator::new(&caps);
1803 let req = build_revocation_request_with_type(
1804 REVOCATION_PRODUCER_DID,
1805 valid_revocation_metadata(),
1806 "0.5.0",
1807 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1808 );
1809 let raw_len = serde_json::to_vec(&req).unwrap().len();
1810 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1811 assert!(
1812 matches!(err, AcdpError::SchemaViolation(_)),
1813 "a malformed acdp_version must fail closed toward retiring the interim form, got {err:?}"
1814 );
1815 }
1816
1817 #[test]
1823 fn revocation_interim_custom_type_rejected_with_supersedes_at_0_5_0() {
1824 let caps = test_caps_v050();
1825 let v = PublishValidator::new(&caps);
1826 let key = SigningKey::from_bytes(&[0u8; 32]);
1827 let p = Producer::new(
1828 key,
1829 AgentDid::new(REVOCATION_PRODUCER_DID),
1830 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1831 );
1832 let target =
1833 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000002".into());
1834 let req = p
1835 .supersede(target)
1836 .version(2)
1837 .title("Key revocation test (interim §10 type, v2 supersedes)")
1838 .context_type(ContextType::Custom(
1839 ContextType::KEY_REVOCATION_INTERIM.into(),
1840 ))
1841 .visibility(Visibility::Public)
1842 .acdp_version("0.5.0")
1843 .metadata(valid_revocation_metadata())
1844 .build()
1845 .unwrap();
1846 let raw_len = serde_json::to_vec(&req).unwrap().len();
1847 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1848 assert!(
1849 matches!(err, AcdpError::SchemaViolation(_)),
1850 "the interim form must be rejected even when it carries a supersedes target, got {err:?}"
1851 );
1852 }
1853
1854 #[test]
1859 fn revocation_standard_type_still_accepted_at_0_5_0() {
1860 let caps = test_caps_v050();
1861 let v = PublishValidator::new(&caps);
1862 let req = build_revocation_request(
1863 REVOCATION_PRODUCER_DID,
1864 valid_revocation_metadata(),
1865 "0.5.0",
1866 );
1867 let raw_len = serde_json::to_vec(&req).unwrap().len();
1868 v.validate_post_schema(&req, raw_len).expect(
1869 "the standard key-revocation type is not retired by §10, only the interim spelling is",
1870 );
1871 }
1872
1873 fn body_from_request(req: &PublishRequest) -> Body {
1881 Body::from_publish_request(
1882 req,
1883 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
1884 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
1885 "registry.example.com",
1886 chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1887 .unwrap()
1888 .with_timezone(&chrono::Utc),
1889 )
1890 }
1891
1892 #[test]
1896 fn revocation_supersession_same_class_allowed_t_earlier() {
1897 let prev_req = build_revocation_request(
1898 REVOCATION_PRODUCER_DID,
1899 valid_revocation_metadata(), "0.3.0",
1901 );
1902 let prev = body_from_request(&prev_req);
1903
1904 let mut meta = valid_revocation_metadata();
1905 meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1907
1908 check_revocation_supersession(&prev, &req, "0.3.0")
1909 .expect("same signer class supersession must be allowed regardless of T direction");
1910 }
1911
1912 #[test]
1916 fn revocation_supersession_different_class_rejected() {
1917 let prev_req = build_revocation_request(
1918 REVOCATION_PRODUCER_DID,
1919 valid_revocation_metadata(), "0.3.0",
1921 );
1922 let prev = body_from_request(&prev_req);
1923
1924 let registry_did = test_caps().registry_did;
1925 let mut meta = valid_revocation_metadata();
1926 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1927 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1930 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1931 }
1932
1933 #[test]
1938 fn revocation_superseded_by_non_revocation_rejected() {
1939 let prev_req = build_revocation_request(
1940 REVOCATION_PRODUCER_DID,
1941 valid_revocation_metadata(),
1942 "0.3.0",
1943 );
1944 let prev = body_from_request(&prev_req);
1945 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1948 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1949 }
1950
1951 #[test]
1956 fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_at_0_5_0() {
1957 let prev_req = build_revocation_request(
1958 REVOCATION_PRODUCER_DID,
1959 valid_revocation_metadata(),
1960 "0.3.0",
1961 );
1962 let prev = body_from_request(&prev_req);
1963 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
1966 assert!(
1967 matches!(
1968 err,
1969 AcdpError::SupersededTarget {
1970 reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
1971 ..
1972 }
1973 ),
1974 "expected SupersededTarget/RevocationTypeMismatch at acdp_version >= 0.5.0, got {err:?}"
1975 );
1976 }
1977
1978 #[test]
1982 fn revocation_superseded_by_non_revocation_still_schema_violation_below_0_5_0() {
1983 let prev_req = build_revocation_request(
1984 REVOCATION_PRODUCER_DID,
1985 valid_revocation_metadata(),
1986 "0.3.0",
1987 );
1988 let prev = body_from_request(&prev_req);
1989 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.4.9").unwrap_err();
1992 assert!(
1993 matches!(err, AcdpError::SchemaViolation(_)),
1994 "0.4.9 is below the 0.5.0 boundary; expected the unchanged SchemaViolation, got {err:?}"
1995 );
1996 }
1997
1998 #[test]
2006 fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_interim_predecessor_at_0_5_0(
2007 ) {
2008 let prev_req = build_revocation_request_with_type(
2009 REVOCATION_PRODUCER_DID,
2010 valid_revocation_metadata(),
2011 "0.3.0",
2012 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
2013 );
2014 let prev = body_from_request(&prev_req);
2015 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
2018 assert!(
2019 matches!(
2020 err,
2021 AcdpError::SupersededTarget {
2022 reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
2023 ..
2024 }
2025 ),
2026 "an interim-typed predecessor must trigger the same rejection as a \
2027 standard-typed one, got {err:?}"
2028 );
2029 }
2030
2031 #[test]
2042 fn revocation_supersession_same_class_allowed_at_0_5_0() {
2043 let prev_req = build_revocation_request(
2044 REVOCATION_PRODUCER_DID,
2045 valid_revocation_metadata(), "0.5.0",
2047 );
2048 let prev = body_from_request(&prev_req);
2049
2050 let mut meta = valid_revocation_metadata();
2051 meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.5.0");
2053
2054 check_revocation_supersession(&prev, &req, "0.5.0").expect(
2055 "a same-class key-revocation supersession must still be allowed at 0.5.0 — \
2056 the (0.5.0) rule targets non-revocation successors only",
2057 );
2058 }
2059
2060 #[test]
2065 fn non_revocation_predecessor_superseded_by_revocation_allowed() {
2066 let prev_req = test_request();
2067 let prev = body_from_request(&prev_req);
2068 let req = build_revocation_request(
2069 REVOCATION_PRODUCER_DID,
2070 valid_revocation_metadata(),
2071 "0.3.0",
2072 );
2073
2074 check_revocation_supersession(&prev, &req, "0.3.0")
2075 .expect("a non-revocation predecessor is out of scope for this §4 row");
2076 }
2077
2078 #[test]
2094 fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
2095 let mut prev_meta = valid_revocation_metadata();
2096 prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
2097 let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
2098 let prev = body_from_request(&prev_req);
2099
2100 let mut meta = valid_revocation_metadata();
2101 meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
2103
2104 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2105 "narrowing the compromise window (T moved later) is allowed at publish time; \
2106 this function enforces only type + signer class, not compromised_since \
2107 direction (RFC-ACDP-0014 §4:58)",
2108 );
2109 }
2110
2111 #[test]
2117 fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
2118 let key = SigningKey::from_bytes(&[0u8; 32]);
2119 let p = Producer::new(
2120 key,
2121 AgentDid::new(REVOCATION_PRODUCER_DID),
2122 format!("{REVOCATION_PRODUCER_DID}#key-1"),
2123 );
2124 let prev_req = p
2125 .publish_request()
2126 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2127 .context_type(ContextType::KeyRevocation)
2128 .visibility(Visibility::Public)
2129 .acdp_version("0.2.0")
2130 .build()
2131 .unwrap();
2132 let prev = body_from_request(&prev_req);
2133 assert!(
2134 KeyRevocation::from_body(&prev).is_err(),
2135 "fixture must actually fail from_body, or this test proves nothing"
2136 );
2137
2138 let req = build_revocation_request(
2139 REVOCATION_OTHER_PRODUCER_DID,
2140 valid_revocation_metadata(),
2141 "0.3.0",
2142 );
2143
2144 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2145 "arm 6b: a malformed predecessor skips the class comparison but a \
2146 well-formed key-revocation successor is still allowed",
2147 );
2148 }
2149
2150 #[test]
2157 fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
2158 let key = SigningKey::from_bytes(&[0u8; 32]);
2159 let p = Producer::new(
2160 key,
2161 AgentDid::new(REVOCATION_PRODUCER_DID),
2162 format!("{REVOCATION_PRODUCER_DID}#key-1"),
2163 );
2164 let prev_req = p
2165 .publish_request()
2166 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2167 .context_type(ContextType::KeyRevocation)
2168 .visibility(Visibility::Public)
2169 .acdp_version("0.2.0")
2170 .build()
2171 .unwrap();
2172 let prev = body_from_request(&prev_req);
2173 assert!(
2174 KeyRevocation::from_body(&prev).is_err(),
2175 "fixture must actually fail from_body, or this test proves nothing"
2176 );
2177
2178 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2181 assert!(
2182 matches!(err, AcdpError::SchemaViolation(_)),
2183 "arm 3's type rule must still reject a non-revocation successor even when the \
2184 predecessor is malformed and the class comparison is skipped"
2185 );
2186 }
2187
2188 #[test]
2199 fn revocation_supersession_same_did_class_flip_rejected() {
2200 let caps = test_caps_v030();
2201 let v = PublishValidator::new(&caps);
2202 let registry_did = caps.registry_did.clone();
2203
2204 let mut prev_meta = valid_revocation_metadata();
2205 prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
2206 let prev_req = build_revocation_request(®istry_did, prev_meta, "0.3.0"); let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
2208 assert_eq!(
2209 prev_revocation.trust_class,
2210 RevocationTrustClass::ProducerSigned
2211 );
2212 v.check_revocation_controller(&prev_req, &prev_revocation)
2213 .expect("PREV fixture must be a legitimately publishable revocation");
2214 let prev = body_from_request(&prev_req);
2215
2216 let mut meta = valid_revocation_metadata();
2217 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
2218 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
2220 assert_eq!(
2221 in_revocation.trust_class,
2222 RevocationTrustClass::RegistryAttested
2223 );
2224 v.check_revocation_controller(&req, &in_revocation)
2225 .expect("IN fixture must be a legitimately publishable revocation");
2226
2227 let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2228 assert!(
2229 matches!(err, AcdpError::SchemaViolation(_)),
2230 "same agent_id on both sides must NOT be enough to allow this supersession — \
2231 the criterion is signer class, not DID"
2232 );
2233 }
2234
2235 #[test]
2243 fn revocation_supersession_cross_did_same_class_allowed() {
2244 let prev_req = build_revocation_request(
2245 REVOCATION_PRODUCER_DID,
2246 valid_revocation_metadata(), "0.3.0",
2248 );
2249 let prev = body_from_request(&prev_req);
2250
2251 let req = build_revocation_request(
2252 REVOCATION_OTHER_PRODUCER_DID, valid_revocation_metadata(), "0.3.0",
2255 );
2256
2257 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2258 "cross-DID, same signer class (ProducerSigned) must be allowed — \
2259 RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
2260 class, not DID",
2261 );
2262 }
2263}