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 },
806 read_authentication_methods: vec![],
807 anonymous_public_reads: true,
808 supports_idempotency_key: false,
809 extensions: Default::default(),
810 }
811 }
812
813 fn test_request() -> PublishRequest {
814 let key = SigningKey::from_bytes(&[0u8; 32]);
815 let p = Producer::new(
816 key,
817 AgentDid::new("did:web:agents.example.com:test-producer"),
818 "did:web:agents.example.com:test-producer#key-1",
819 );
820 p.publish_request()
821 .title("Golden test vector — minimal first version")
822 .context_type(ContextType::DataSnapshot)
823 .visibility(Visibility::Public)
824 .build()
825 .unwrap()
826 }
827
828 fn respelled_request(spelling: &str) -> (serde_json::Value, PublishRequest) {
832 let key = SigningKey::from_bytes(&[0u8; 32]);
833 let req = Producer::new(
834 key,
835 AgentDid::new("did:web:agents.example.com:test-producer"),
836 "did:web:agents.example.com:test-producer#key-1",
837 )
838 .publish_request()
839 .title("Golden test vector — minimal first version")
840 .context_type(ContextType::DataSnapshot)
841 .visibility(Visibility::Public)
842 .expires_at(
843 chrono::DateTime::parse_from_rfc3339("2026-12-31T23:59:59Z")
844 .unwrap()
845 .with_timezone(&chrono::Utc),
846 )
847 .build()
848 .unwrap();
849 let mut raw = serde_json::to_value(&req).unwrap();
850 raw["expires_at"] = serde_json::json!(spelling);
851 let hash = acdp_crypto::compute_content_hash(&raw).unwrap();
852 raw["content_hash"] = serde_json::json!(hash.as_str());
853 let parsed = serde_json::from_value(raw.clone()).unwrap();
854 (raw, parsed)
855 }
856
857 #[test]
860 fn raw_path_accepts_non_canonical_spelling_typed_path_does_not() {
861 let caps = test_caps();
862 let v = PublishValidator::new(&caps);
863 let (raw, req) = respelled_request("2026-12-31T23:59:59+00:00");
864 let len = serde_json::to_vec(&raw).unwrap().len();
865 v.validate_post_schema_raw(&req, &raw, len).unwrap();
866 assert!(matches!(
867 v.validate_post_schema(&req, len),
868 Err(AcdpError::HashMismatch { .. })
869 ));
870 }
871
872 #[test]
875 fn typed_path_accepts_canonical_and_legacy_whole_seconds() {
876 let caps = test_caps();
877 let v = PublishValidator::new(&caps);
878 for spelling in ["2026-12-31T23:59:59.000Z", "2026-12-31T23:59:59Z"] {
879 let (raw, req) = respelled_request(spelling);
880 let len = serde_json::to_vec(&raw).unwrap().len();
881 v.validate_post_schema(&req, len)
882 .unwrap_or_else(|e| panic!("{spelling}: {e:?}"));
883 v.validate_post_schema_raw(&req, &raw, len).unwrap();
884 }
885 }
886
887 #[test]
890 fn raw_path_ignores_raw_that_does_not_describe_request() {
891 let caps = test_caps();
892 let v = PublishValidator::new(&caps);
893 let (raw, mut req) = respelled_request("2026-12-31T23:59:59+00:00");
894 req.title = "Tampered after parse".into();
895 let len = serde_json::to_vec(&raw).unwrap().len();
896 assert!(matches!(
897 v.validate_post_schema_raw(&req, &raw, len),
898 Err(AcdpError::HashMismatch { .. })
899 ));
900 }
901
902 #[test]
903 fn happy_path_validates() {
904 let caps = test_caps();
905 let v = PublishValidator::new(&caps);
906 let req = test_request();
907 let raw_len = serde_json::to_vec(&req).unwrap().len();
908 v.validate_post_schema(&req, raw_len).unwrap();
909 }
910
911 #[test]
912 fn payload_too_large_rejected() {
913 let mut caps = test_caps();
914 caps.limits.max_payload_bytes = 10;
915 let v = PublishValidator::new(&caps);
916 let req = test_request();
917 let err = v.validate_post_schema(&req, 1024).unwrap_err();
918 assert!(matches!(err, AcdpError::SchemaViolation(_)));
919 }
920
921 #[test]
922 fn unsupported_algorithm_rejected() {
923 let mut caps = test_caps();
924 caps.supported_signature_algorithms = vec!["secp256k1".into()];
925 let v = PublishValidator::new(&caps);
926 let req = test_request();
927 let err = v.validate_post_schema(&req, 1024).unwrap_err();
928 assert!(matches!(err, AcdpError::SchemaViolation(_)));
929 }
930
931 #[test]
932 fn key_id_without_fragment_rejected() {
933 let caps = test_caps();
934 let v = PublishValidator::new(&caps);
935 let mut req = test_request();
936 req.signature.key_id = "did:web:agents.example.com:test-producer".into();
937 let err = v.validate_post_schema(&req, 1024).unwrap_err();
938 assert!(matches!(err, AcdpError::KeyResolution(_)));
939 }
940
941 #[test]
942 fn key_id_did_must_match_agent_id() {
943 let caps = test_caps();
944 let v = PublishValidator::new(&caps);
945 let mut req = test_request();
946 req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
947 let err = v.validate_post_schema(&req, 1024).unwrap_err();
948 assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
949 }
950
951 #[test]
952 fn tampered_hash_detected() {
953 let caps = test_caps();
954 let v = PublishValidator::new(&caps);
955 let mut req = test_request();
956 req.title = "tampered title".into();
957 let err = v.validate_post_schema(&req, 1024).unwrap_err();
958 assert!(matches!(err, AcdpError::HashMismatch { .. }));
959 }
960
961 #[test]
962 fn assign_identifiers_first_version_derives_lineage_from_new_id() {
963 let v = ValidatedPublish {
964 recomputed_hash: ContentHash("sha256:abcd".into()),
965 };
966 let (ctx_id, lineage_id) =
967 assign_identifiers("registry.example.com", &None, None, &v).unwrap();
968 let expected = derive_lineage_id(&ctx_id);
969 assert_eq!(lineage_id, expected);
970 }
971
972 #[test]
973 fn assign_identifiers_supersession_uses_v1_ctx_id() {
974 let v = ValidatedPublish {
975 recomputed_hash: ContentHash("sha256:abcd".into()),
976 };
977 let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
978 let supersedes = Some(CtxId(
979 "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
980 ));
981 let (_new_id, lineage_id) =
982 assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
983 assert_eq!(lineage_id, derive_lineage_id(&v1));
984 }
985
986 #[test]
987 fn cross_registry_supersession_rejected() {
988 let caps = test_caps();
989 let v = PublishValidator::for_authority(&caps, "registry.example.com");
990 let key = SigningKey::from_bytes(&[0u8; 32]);
992 let p = Producer::new(
993 key,
994 AgentDid::new("did:web:agents.example.com:test-producer"),
995 "did:web:agents.example.com:test-producer#key-1",
996 );
997 let other_reg =
998 CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
999 let req = p
1000 .supersede(other_reg)
1001 .version(2)
1002 .title("v2")
1003 .context_type(ContextType::DataSnapshot)
1004 .build()
1005 .unwrap();
1006 let raw_len = serde_json::to_vec(&req).unwrap().len();
1007 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1008 match err {
1009 AcdpError::SupersededTarget { reason, .. } => {
1010 assert_eq!(
1011 reason,
1012 acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
1013 );
1014 }
1015 other => panic!("expected SupersededTarget, got {other:?}"),
1016 }
1017 }
1018
1019 #[test]
1020 fn same_registry_supersession_passes_authority_check() {
1021 let caps = test_caps();
1022 let v = PublishValidator::for_authority(&caps, "registry.example.com");
1023 let key = SigningKey::from_bytes(&[0u8; 32]);
1024 let p = Producer::new(
1025 key,
1026 AgentDid::new("did:web:agents.example.com:test-producer"),
1027 "did:web:agents.example.com:test-producer#key-1",
1028 );
1029 let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
1030 let req = p
1031 .supersede(same)
1032 .version(2)
1033 .title("v2")
1034 .context_type(ContextType::DataSnapshot)
1035 .build()
1036 .unwrap();
1037 let raw_len = serde_json::to_vec(&req).unwrap().len();
1038 v.validate_post_schema(&req, raw_len).unwrap();
1039 }
1040
1041 #[test]
1042 fn assign_identifiers_supersession_without_v1_id_rejected() {
1043 let v = ValidatedPublish {
1044 recomputed_hash: ContentHash("sha256:abcd".into()),
1045 };
1046 let supersedes = Some(CtxId("acdp://x/y".into()));
1047 let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
1048 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1049 }
1050
1051 fn test_caps_v030() -> CapabilitiesDocument {
1054 CapabilitiesDocument {
1055 acdp_version: "0.3.0".into(),
1056 ..test_caps()
1057 }
1058 }
1059
1060 fn test_caps_v020() -> CapabilitiesDocument {
1061 CapabilitiesDocument {
1062 acdp_version: "0.2.0".into(),
1063 ..test_caps()
1064 }
1065 }
1066
1067 fn test_caps_v050() -> CapabilitiesDocument {
1068 CapabilitiesDocument {
1069 acdp_version: "0.5.0".into(),
1070 ..test_caps()
1071 }
1072 }
1073
1074 const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
1075 const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
1076 const REVOCATION_FP: &str =
1077 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1078 const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
1079
1080 fn valid_revocation_metadata() -> serde_json::Value {
1081 serde_json::json!({
1082 "revoked_key_fingerprint": REVOCATION_FP,
1083 "compromised_since": REVOCATION_SINCE,
1084 })
1085 }
1086
1087 fn build_revocation_request(
1092 agent_did: &str,
1093 metadata: serde_json::Value,
1094 acdp_version: &str,
1095 ) -> PublishRequest {
1096 let key = SigningKey::from_bytes(&[0u8; 32]);
1097 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1098 p.publish_request()
1099 .title("Key revocation test")
1100 .context_type(ContextType::KeyRevocation)
1101 .visibility(Visibility::Public)
1102 .acdp_version(acdp_version)
1103 .metadata(metadata)
1104 .build()
1105 .unwrap()
1106 }
1107
1108 #[test]
1111 fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
1112 let caps = test_caps_v030();
1113 let v = PublishValidator::new(&caps);
1114 let req = build_revocation_request(
1115 REVOCATION_PRODUCER_DID,
1116 valid_revocation_metadata(),
1117 "0.3.0",
1118 );
1119 let raw_len = serde_json::to_vec(&req).unwrap().len();
1120 v.validate_post_schema(&req, raw_len).unwrap();
1121 }
1122
1123 #[test]
1126 fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
1127 let caps = test_caps_v030();
1128 let v = PublishValidator::new(&caps);
1129 let mut meta = valid_revocation_metadata();
1130 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1131 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1132 let raw_len = serde_json::to_vec(&req).unwrap().len();
1133 v.validate_post_schema(&req, raw_len).unwrap();
1134 }
1135
1136 #[test]
1139 fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
1140 let caps = test_caps_v030();
1141 let registry_did = caps.registry_did.clone();
1142 let v = PublishValidator::new(&caps);
1143 let mut meta = valid_revocation_metadata();
1144 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1145 let req = build_revocation_request(®istry_did, meta, "0.3.0");
1146 let raw_len = serde_json::to_vec(&req).unwrap().len();
1147 v.validate_post_schema(&req, raw_len).unwrap();
1148 }
1149
1150 #[test]
1153 fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
1154 let caps = test_caps_v030();
1155 let v = PublishValidator::new(&caps);
1156 let mut meta = valid_revocation_metadata();
1157 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1158 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1159 let raw_len = serde_json::to_vec(&req).unwrap().len();
1160 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1161 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1162 }
1163
1164 #[test]
1165 fn revocation_arm4_accepted_at_0_2_0_positive_control() {
1166 let caps = test_caps_v020();
1167 let v = PublishValidator::new(&caps);
1168 let mut meta = valid_revocation_metadata();
1169 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1170 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1171 let raw_len = serde_json::to_vec(&req).unwrap().len();
1172 v.validate_post_schema(&req, raw_len).unwrap();
1173 }
1174
1175 #[test]
1182 fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
1183 let caps = test_caps_v030();
1184 let registry_did = caps.registry_did.clone();
1185 let v = PublishValidator::new(&caps);
1186 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
1187 let raw_len = serde_json::to_vec(&req).unwrap().len();
1188 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1189 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1190 }
1191
1192 #[test]
1193 fn revocation_arm5_accepted_at_0_2_0_positive_control() {
1194 let caps = test_caps_v020();
1195 let registry_did = caps.registry_did.clone();
1196 let v = PublishValidator::new(&caps);
1197 let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
1198 let raw_len = serde_json::to_vec(&req).unwrap().len();
1199 v.validate_post_schema(&req, raw_len).unwrap();
1200 }
1201
1202 #[test]
1203 fn revocation_non_public_visibility_rejected_at_0_3_0() {
1204 let caps = test_caps_v030();
1205 let v = PublishValidator::new(&caps);
1206 let key = SigningKey::from_bytes(&[0u8; 32]);
1207 let p = Producer::new(
1208 key,
1209 AgentDid::new(REVOCATION_PRODUCER_DID),
1210 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1211 );
1212 let req = p
1213 .publish_request()
1214 .title("Key revocation test")
1215 .context_type(ContextType::KeyRevocation)
1216 .visibility(Visibility::Restricted)
1217 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1218 .acdp_version("0.3.0")
1219 .metadata(valid_revocation_metadata())
1220 .build()
1221 .unwrap();
1222 let raw_len = serde_json::to_vec(&req).unwrap().len();
1223 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1224 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1225 }
1226
1227 #[test]
1228 fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
1229 let caps = test_caps_v020();
1230 let v = PublishValidator::new(&caps);
1231 let key = SigningKey::from_bytes(&[0u8; 32]);
1232 let p = Producer::new(
1233 key,
1234 AgentDid::new(REVOCATION_PRODUCER_DID),
1235 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1236 );
1237 let req = p
1238 .publish_request()
1239 .title("Key revocation test")
1240 .context_type(ContextType::KeyRevocation)
1241 .visibility(Visibility::Restricted)
1242 .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1243 .acdp_version("0.3.0")
1244 .metadata(valid_revocation_metadata())
1245 .build()
1246 .unwrap();
1247 let raw_len = serde_json::to_vec(&req).unwrap().len();
1248 v.validate_post_schema(&req, raw_len).unwrap();
1249 }
1250
1251 #[test]
1252 fn revocation_missing_fingerprint_rejected_at_0_3_0() {
1253 let caps = test_caps_v030();
1254 let v = PublishValidator::new(&caps);
1255 let mut meta = valid_revocation_metadata();
1256 meta.as_object_mut()
1257 .unwrap()
1258 .remove("revoked_key_fingerprint");
1259 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1260 let raw_len = serde_json::to_vec(&req).unwrap().len();
1261 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1262 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1263 }
1264
1265 #[test]
1266 fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
1267 let caps = test_caps_v020();
1268 let v = PublishValidator::new(&caps);
1269 let mut meta = valid_revocation_metadata();
1270 meta.as_object_mut()
1271 .unwrap()
1272 .remove("revoked_key_fingerprint");
1273 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1274 let raw_len = serde_json::to_vec(&req).unwrap().len();
1275 v.validate_post_schema(&req, raw_len).unwrap();
1276 }
1277
1278 #[test]
1279 fn revocation_missing_compromised_since_rejected_at_0_3_0() {
1280 let caps = test_caps_v030();
1281 let v = PublishValidator::new(&caps);
1282 let mut meta = valid_revocation_metadata();
1283 meta.as_object_mut().unwrap().remove("compromised_since");
1284 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1285 let raw_len = serde_json::to_vec(&req).unwrap().len();
1286 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1287 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1288 }
1289
1290 #[test]
1291 fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
1292 let caps = test_caps_v020();
1293 let v = PublishValidator::new(&caps);
1294 let mut meta = valid_revocation_metadata();
1295 meta.as_object_mut().unwrap().remove("compromised_since");
1296 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1297 let raw_len = serde_json::to_vec(&req).unwrap().len();
1298 v.validate_post_schema(&req, raw_len).unwrap();
1299 }
1300
1301 #[test]
1302 fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
1303 let caps = test_caps_v030();
1304 let v = PublishValidator::new(&caps);
1305 let mut meta = valid_revocation_metadata();
1306 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1307 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1308 let raw_len = serde_json::to_vec(&req).unwrap().len();
1309 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1310 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1311 }
1312
1313 #[test]
1314 fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
1315 let caps = test_caps_v020();
1316 let v = PublishValidator::new(&caps);
1317 let mut meta = valid_revocation_metadata();
1318 meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1319 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1320 let raw_len = serde_json::to_vec(&req).unwrap().len();
1321 v.validate_post_schema(&req, raw_len).unwrap();
1322 }
1323
1324 #[test]
1325 fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
1326 let caps = test_caps_v030();
1327 let v = PublishValidator::new(&caps);
1328 let mut meta = valid_revocation_metadata();
1329 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1330 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1331 let raw_len = serde_json::to_vec(&req).unwrap().len();
1332 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1333 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1334 }
1335
1336 #[test]
1337 fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
1338 let caps = test_caps_v020();
1339 let v = PublishValidator::new(&caps);
1340 let mut meta = valid_revocation_metadata();
1341 meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1342 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1343 let raw_len = serde_json::to_vec(&req).unwrap().len();
1344 v.validate_post_schema(&req, raw_len).unwrap();
1345 }
1346
1347 #[test]
1348 fn revocation_reason_over_limit_rejected_at_0_3_0() {
1349 let caps = test_caps_v030();
1350 let v = PublishValidator::new(&caps);
1351 let mut meta = valid_revocation_metadata();
1352 meta["reason"] =
1353 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1354 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1355 let raw_len = serde_json::to_vec(&req).unwrap().len();
1356 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1357 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1358 }
1359
1360 #[test]
1361 fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
1362 let caps = test_caps_v020();
1363 let v = PublishValidator::new(&caps);
1364 let mut meta = valid_revocation_metadata();
1365 meta["reason"] =
1366 serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1367 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1368 let raw_len = serde_json::to_vec(&req).unwrap().len();
1369 v.validate_post_schema(&req, raw_len).unwrap();
1370 }
1371
1372 #[test]
1380 fn revocation_reason_at_exactly_the_limit_accepted_at_0_3_0() {
1381 let caps = test_caps_v030();
1382 let v = PublishValidator::new(&caps);
1383 let mut meta = valid_revocation_metadata();
1384 meta["reason"] = serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS));
1385 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1386 let raw_len = serde_json::to_vec(&req).unwrap().len();
1387 v.validate_post_schema(&req, raw_len)
1388 .expect("a reason of exactly MAX_REASON_CHARS must be accepted, not rejected");
1389 }
1390
1391 #[test]
1396 fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
1397 let mut caps = test_caps_v030();
1398 caps.acdp_version = "not-a-version".into();
1399 let v = PublishValidator::new(&caps);
1400 let mut meta = valid_revocation_metadata();
1401 meta.as_object_mut()
1402 .unwrap()
1403 .remove("revoked_key_fingerprint");
1404 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1405 let raw_len = serde_json::to_vec(&req).unwrap().len();
1406 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1407 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1408 }
1409
1410 #[test]
1411 fn revocation_gate_fails_closed_on_empty_acdp_version() {
1412 let mut caps = test_caps_v030();
1413 caps.acdp_version = "".into();
1414 let v = PublishValidator::new(&caps);
1415 let mut meta = valid_revocation_metadata();
1416 meta.as_object_mut()
1417 .unwrap()
1418 .remove("revoked_key_fingerprint");
1419 let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1420 let raw_len = serde_json::to_vec(&req).unwrap().len();
1421 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1422 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1423 }
1424
1425 #[test]
1428 fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
1429 let caps = test_caps_v030();
1430 let v = PublishValidator::new(&caps);
1431 let req = test_request();
1432 let raw_len = serde_json::to_vec(&req).unwrap().len();
1433 v.validate_post_schema(&req, raw_len).unwrap();
1434 }
1435
1436 #[test]
1445 fn key_revocation_gate_truth_table() {
1446 let cases: &[(&str, bool)] = &[
1447 ("0.3x.0", true),
1450 ("0. 3.0", true),
1453 ("0.2.0 ", true),
1457 ("0.2.0;", true),
1458 ("0.x.1", true),
1460 ("0.\u{0663}.0", true),
1464 ("not-a-version", true),
1467 ("", true),
1468 ("0.3.0", true),
1470 ("0.4.0", true),
1471 ("1.0.0", true),
1472 ("0.2.9", false),
1474 ("0.2.0", false),
1475 ];
1476 for (input, expected) in cases {
1477 assert_eq!(
1478 key_revocation_gate_applies(input),
1479 *expected,
1480 "input {input:?} should gate {}",
1481 if *expected { "ON" } else { "OFF" }
1482 );
1483 }
1484 }
1485
1486 #[test]
1499 fn zero_five_zero_threshold_gates_truth_table() {
1500 let cases: &[(&str, bool, bool)] = &[
1501 ("0.5.0", true, true),
1503 ("0.4.9", false, false),
1504 ("1.0.0", true, true),
1505 ("0.5.1", true, true),
1506 ("0.05.0", true, true),
1511 ("000.005.000", true, true),
1512 ("0.5.0-alpha", true, false),
1516 ("0.5.0.1", true, false),
1519 ("99999999999999999999.0.0", true, true),
1527 ];
1528 for (input, retirement_expected, advertises_expected) in cases {
1529 assert_eq!(
1530 key_revocation_retirement_gate_applies(input),
1531 *retirement_expected,
1532 "§10 retirement gate: input {input:?} should gate {}",
1533 if *retirement_expected { "ON" } else { "OFF" }
1534 );
1535 assert_eq!(
1536 advertises_0_5_0_or_higher(input),
1537 *advertises_expected,
1538 "advertises_0_5_0_or_higher: input {input:?} should be {advertises_expected}"
1539 );
1540 }
1541 }
1542
1543 fn build_revocation_request_with_type(
1549 agent_did: &str,
1550 metadata: serde_json::Value,
1551 acdp_version: &str,
1552 context_type: ContextType,
1553 ) -> PublishRequest {
1554 let key = SigningKey::from_bytes(&[0u8; 32]);
1555 let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1556 p.publish_request()
1557 .title("Key revocation test (interim §10 type)")
1558 .context_type(context_type)
1559 .visibility(Visibility::Public)
1560 .acdp_version(acdp_version)
1561 .metadata(metadata)
1562 .build()
1563 .unwrap()
1564 }
1565
1566 #[test]
1577 fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
1578 let caps = test_caps_v030();
1579 let v = PublishValidator::new(&caps);
1580 let req = build_revocation_request_with_type(
1581 REVOCATION_PRODUCER_DID,
1582 valid_revocation_metadata(),
1583 "0.3.0",
1584 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1585 );
1586 let raw_len = serde_json::to_vec(&req).unwrap().len();
1587 v.validate_post_schema(&req, raw_len).unwrap();
1588 }
1589
1590 #[test]
1596 fn revocation_interim_custom_type_malformed_body_accepted_at_0_3_0() {
1597 let caps = test_caps_v030();
1598 let v = PublishValidator::new(&caps);
1599 let mut meta = valid_revocation_metadata();
1600 meta.as_object_mut()
1601 .unwrap()
1602 .remove("revoked_key_fingerprint");
1603 let req = build_revocation_request_with_type(
1604 REVOCATION_PRODUCER_DID,
1605 meta,
1606 "0.3.0",
1607 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1608 );
1609 let raw_len = serde_json::to_vec(&req).unwrap().len();
1610 v.validate_post_schema(&req, raw_len).unwrap();
1611 }
1612
1613 fn did_key_producer_fixture(seed: [u8; 32]) -> (SigningKey, String, String, String) {
1614 let key = SigningKey::from_bytes(&seed);
1615 let public_key = key.verifying_key_bytes();
1616 let did = acdp_did::key::did_key_from_ed25519(&public_key);
1617 let key_id = acdp_did::key::did_key_url(&did).unwrap();
1618 let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
1619 (key, did, key_id, fingerprint)
1620 }
1621
1622 fn caps_v030_with_did_key() -> CapabilitiesDocument {
1623 CapabilitiesDocument {
1624 acdp_version: "0.3.0".into(),
1625 supported_did_methods: vec!["did:web".into(), "did:key".into()],
1626 ..test_caps()
1627 }
1628 }
1629
1630 #[test]
1640 fn revocation_interim_custom_type_did_key_self_signed_rejected_at_0_3_0() {
1641 let (key, did, key_id, fingerprint) = did_key_producer_fixture([9u8; 32]);
1642 let mut meta = valid_revocation_metadata();
1643 meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
1644
1645 let req = Producer::new(key, AgentDid::new(&did), key_id)
1646 .publish_request()
1647 .title("self-signed interim revocation")
1648 .context_type(ContextType::Custom(
1649 ContextType::KEY_REVOCATION_INTERIM.into(),
1650 ))
1651 .visibility(Visibility::Public)
1652 .acdp_version("0.3.0")
1653 .metadata(meta)
1654 .build()
1655 .unwrap();
1656
1657 let caps = caps_v030_with_did_key();
1658 let v = PublishValidator::new(&caps);
1659 let raw_len = serde_json::to_vec(&req).unwrap().len();
1660 assert!(matches!(
1661 v.validate_post_schema(&req, raw_len),
1662 Err(AcdpError::KeyNotAuthorized(_))
1663 ));
1664 }
1665
1666 #[test]
1672 fn revocation_interim_custom_type_did_key_different_key_accepted_at_0_3_0() {
1673 let (key, did, key_id, _fingerprint) = did_key_producer_fixture([10u8; 32]);
1674 let meta = valid_revocation_metadata();
1677
1678 let req = Producer::new(key, AgentDid::new(&did), key_id)
1679 .publish_request()
1680 .title("non-self-signed interim revocation")
1681 .context_type(ContextType::Custom(
1682 ContextType::KEY_REVOCATION_INTERIM.into(),
1683 ))
1684 .visibility(Visibility::Public)
1685 .acdp_version("0.3.0")
1686 .metadata(meta)
1687 .build()
1688 .unwrap();
1689
1690 let caps = caps_v030_with_did_key();
1691 let v = PublishValidator::new(&caps);
1692 let raw_len = serde_json::to_vec(&req).unwrap().len();
1693 v.validate_post_schema(&req, raw_len)
1694 .expect("did:key signer whose fingerprint differs from the revoked key must pass");
1695 }
1696
1697 #[test]
1704 fn revocation_interim_custom_type_mismatched_controller_rejected_at_0_3_0() {
1705 let mut meta = valid_revocation_metadata();
1706 meta["revoked_key_controller"] = serde_json::json!("did:web:someone-else.example.com");
1707 let req = build_revocation_request_with_type(
1708 REVOCATION_PRODUCER_DID,
1709 meta,
1710 "0.3.0",
1711 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1712 );
1713 let caps = test_caps_v030();
1714 let v = PublishValidator::new(&caps);
1715 let raw_len = serde_json::to_vec(&req).unwrap().len();
1716 assert!(matches!(
1717 v.validate_post_schema(&req, raw_len),
1718 Err(AcdpError::SchemaViolation(_))
1719 ));
1720 }
1721
1722 #[test]
1726 fn revocation_interim_custom_type_registry_attested_controller_accepted_at_0_3_0() {
1727 let caps = test_caps_v030();
1728 let mut meta = valid_revocation_metadata();
1729 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1730 let req = build_revocation_request_with_type(
1731 &caps.registry_did,
1732 meta,
1733 "0.3.0",
1734 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1735 );
1736 let v = PublishValidator::new(&caps);
1737 let raw_len = serde_json::to_vec(&req).unwrap().len();
1738 v.validate_post_schema(&req, raw_len)
1739 .expect("registry-attested interim revocation with a named controller must pass");
1740 }
1741
1742 #[test]
1747 fn revocation_interim_custom_type_registry_attested_missing_controller_rejected_at_0_3_0() {
1748 let caps = test_caps_v030();
1749 let meta = valid_revocation_metadata();
1750 let req = build_revocation_request_with_type(
1751 &caps.registry_did,
1752 meta,
1753 "0.3.0",
1754 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1755 );
1756 let v = PublishValidator::new(&caps);
1757 let raw_len = serde_json::to_vec(&req).unwrap().len();
1758 assert!(matches!(
1759 v.validate_post_schema(&req, raw_len),
1760 Err(AcdpError::SchemaViolation(_))
1761 ));
1762 }
1763
1764 #[test]
1772 fn revocation_interim_custom_type_rejected_unconditionally_at_0_5_0() {
1773 let caps = test_caps_v050();
1774 let v = PublishValidator::new(&caps);
1775 let req = build_revocation_request_with_type(
1776 REVOCATION_PRODUCER_DID,
1777 valid_revocation_metadata(),
1778 "0.5.0",
1779 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1780 );
1781 let raw_len = serde_json::to_vec(&req).unwrap().len();
1782 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1783 assert!(
1784 matches!(err, AcdpError::SchemaViolation(_)),
1785 "the interim form must be rejected unconditionally at >= 0.5.0, got {err:?}"
1786 );
1787 }
1788
1789 #[test]
1795 fn interim_form_retirement_gate_fails_closed_on_malformed_acdp_version() {
1796 let mut caps = test_caps_v050();
1797 caps.acdp_version = "not-a-version".into();
1798 let v = PublishValidator::new(&caps);
1799 let req = build_revocation_request_with_type(
1800 REVOCATION_PRODUCER_DID,
1801 valid_revocation_metadata(),
1802 "0.5.0",
1803 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1804 );
1805 let raw_len = serde_json::to_vec(&req).unwrap().len();
1806 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1807 assert!(
1808 matches!(err, AcdpError::SchemaViolation(_)),
1809 "a malformed acdp_version must fail closed toward retiring the interim form, got {err:?}"
1810 );
1811 }
1812
1813 #[test]
1819 fn revocation_interim_custom_type_rejected_with_supersedes_at_0_5_0() {
1820 let caps = test_caps_v050();
1821 let v = PublishValidator::new(&caps);
1822 let key = SigningKey::from_bytes(&[0u8; 32]);
1823 let p = Producer::new(
1824 key,
1825 AgentDid::new(REVOCATION_PRODUCER_DID),
1826 format!("{REVOCATION_PRODUCER_DID}#key-1"),
1827 );
1828 let target =
1829 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000002".into());
1830 let req = p
1831 .supersede(target)
1832 .version(2)
1833 .title("Key revocation test (interim §10 type, v2 supersedes)")
1834 .context_type(ContextType::Custom(
1835 ContextType::KEY_REVOCATION_INTERIM.into(),
1836 ))
1837 .visibility(Visibility::Public)
1838 .acdp_version("0.5.0")
1839 .metadata(valid_revocation_metadata())
1840 .build()
1841 .unwrap();
1842 let raw_len = serde_json::to_vec(&req).unwrap().len();
1843 let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1844 assert!(
1845 matches!(err, AcdpError::SchemaViolation(_)),
1846 "the interim form must be rejected even when it carries a supersedes target, got {err:?}"
1847 );
1848 }
1849
1850 #[test]
1855 fn revocation_standard_type_still_accepted_at_0_5_0() {
1856 let caps = test_caps_v050();
1857 let v = PublishValidator::new(&caps);
1858 let req = build_revocation_request(
1859 REVOCATION_PRODUCER_DID,
1860 valid_revocation_metadata(),
1861 "0.5.0",
1862 );
1863 let raw_len = serde_json::to_vec(&req).unwrap().len();
1864 v.validate_post_schema(&req, raw_len).expect(
1865 "the standard key-revocation type is not retired by §10, only the interim spelling is",
1866 );
1867 }
1868
1869 fn body_from_request(req: &PublishRequest) -> Body {
1877 Body::from_publish_request(
1878 req,
1879 CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
1880 LineageId(format!("lin:sha256:{}", "0".repeat(64))),
1881 "registry.example.com",
1882 chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1883 .unwrap()
1884 .with_timezone(&chrono::Utc),
1885 )
1886 }
1887
1888 #[test]
1892 fn revocation_supersession_same_class_allowed_t_earlier() {
1893 let prev_req = build_revocation_request(
1894 REVOCATION_PRODUCER_DID,
1895 valid_revocation_metadata(), "0.3.0",
1897 );
1898 let prev = body_from_request(&prev_req);
1899
1900 let mut meta = valid_revocation_metadata();
1901 meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1903
1904 check_revocation_supersession(&prev, &req, "0.3.0")
1905 .expect("same signer class supersession must be allowed regardless of T direction");
1906 }
1907
1908 #[test]
1912 fn revocation_supersession_different_class_rejected() {
1913 let prev_req = build_revocation_request(
1914 REVOCATION_PRODUCER_DID,
1915 valid_revocation_metadata(), "0.3.0",
1917 );
1918 let prev = body_from_request(&prev_req);
1919
1920 let registry_did = test_caps().registry_did;
1921 let mut meta = valid_revocation_metadata();
1922 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1923 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1926 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1927 }
1928
1929 #[test]
1934 fn revocation_superseded_by_non_revocation_rejected() {
1935 let prev_req = build_revocation_request(
1936 REVOCATION_PRODUCER_DID,
1937 valid_revocation_metadata(),
1938 "0.3.0",
1939 );
1940 let prev = body_from_request(&prev_req);
1941 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1944 assert!(matches!(err, AcdpError::SchemaViolation(_)));
1945 }
1946
1947 #[test]
1952 fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_at_0_5_0() {
1953 let prev_req = build_revocation_request(
1954 REVOCATION_PRODUCER_DID,
1955 valid_revocation_metadata(),
1956 "0.3.0",
1957 );
1958 let prev = body_from_request(&prev_req);
1959 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
1962 assert!(
1963 matches!(
1964 err,
1965 AcdpError::SupersededTarget {
1966 reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
1967 ..
1968 }
1969 ),
1970 "expected SupersededTarget/RevocationTypeMismatch at acdp_version >= 0.5.0, got {err:?}"
1971 );
1972 }
1973
1974 #[test]
1978 fn revocation_superseded_by_non_revocation_still_schema_violation_below_0_5_0() {
1979 let prev_req = build_revocation_request(
1980 REVOCATION_PRODUCER_DID,
1981 valid_revocation_metadata(),
1982 "0.3.0",
1983 );
1984 let prev = body_from_request(&prev_req);
1985 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.4.9").unwrap_err();
1988 assert!(
1989 matches!(err, AcdpError::SchemaViolation(_)),
1990 "0.4.9 is below the 0.5.0 boundary; expected the unchanged SchemaViolation, got {err:?}"
1991 );
1992 }
1993
1994 #[test]
2002 fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_interim_predecessor_at_0_5_0(
2003 ) {
2004 let prev_req = build_revocation_request_with_type(
2005 REVOCATION_PRODUCER_DID,
2006 valid_revocation_metadata(),
2007 "0.3.0",
2008 ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
2009 );
2010 let prev = body_from_request(&prev_req);
2011 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
2014 assert!(
2015 matches!(
2016 err,
2017 AcdpError::SupersededTarget {
2018 reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
2019 ..
2020 }
2021 ),
2022 "an interim-typed predecessor must trigger the same rejection as a \
2023 standard-typed one, got {err:?}"
2024 );
2025 }
2026
2027 #[test]
2038 fn revocation_supersession_same_class_allowed_at_0_5_0() {
2039 let prev_req = build_revocation_request(
2040 REVOCATION_PRODUCER_DID,
2041 valid_revocation_metadata(), "0.5.0",
2043 );
2044 let prev = body_from_request(&prev_req);
2045
2046 let mut meta = valid_revocation_metadata();
2047 meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.5.0");
2049
2050 check_revocation_supersession(&prev, &req, "0.5.0").expect(
2051 "a same-class key-revocation supersession must still be allowed at 0.5.0 — \
2052 the (0.5.0) rule targets non-revocation successors only",
2053 );
2054 }
2055
2056 #[test]
2061 fn non_revocation_predecessor_superseded_by_revocation_allowed() {
2062 let prev_req = test_request();
2063 let prev = body_from_request(&prev_req);
2064 let req = build_revocation_request(
2065 REVOCATION_PRODUCER_DID,
2066 valid_revocation_metadata(),
2067 "0.3.0",
2068 );
2069
2070 check_revocation_supersession(&prev, &req, "0.3.0")
2071 .expect("a non-revocation predecessor is out of scope for this §4 row");
2072 }
2073
2074 #[test]
2090 fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
2091 let mut prev_meta = valid_revocation_metadata();
2092 prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
2093 let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
2094 let prev = body_from_request(&prev_req);
2095
2096 let mut meta = valid_revocation_metadata();
2097 meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
2099
2100 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2101 "narrowing the compromise window (T moved later) is allowed at publish time; \
2102 this function enforces only type + signer class, not compromised_since \
2103 direction (RFC-ACDP-0014 §4:58)",
2104 );
2105 }
2106
2107 #[test]
2113 fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
2114 let key = SigningKey::from_bytes(&[0u8; 32]);
2115 let p = Producer::new(
2116 key,
2117 AgentDid::new(REVOCATION_PRODUCER_DID),
2118 format!("{REVOCATION_PRODUCER_DID}#key-1"),
2119 );
2120 let prev_req = p
2121 .publish_request()
2122 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2123 .context_type(ContextType::KeyRevocation)
2124 .visibility(Visibility::Public)
2125 .acdp_version("0.2.0")
2126 .build()
2127 .unwrap();
2128 let prev = body_from_request(&prev_req);
2129 assert!(
2130 KeyRevocation::from_body(&prev).is_err(),
2131 "fixture must actually fail from_body, or this test proves nothing"
2132 );
2133
2134 let req = build_revocation_request(
2135 REVOCATION_OTHER_PRODUCER_DID,
2136 valid_revocation_metadata(),
2137 "0.3.0",
2138 );
2139
2140 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2141 "arm 6b: a malformed predecessor skips the class comparison but a \
2142 well-formed key-revocation successor is still allowed",
2143 );
2144 }
2145
2146 #[test]
2153 fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
2154 let key = SigningKey::from_bytes(&[0u8; 32]);
2155 let p = Producer::new(
2156 key,
2157 AgentDid::new(REVOCATION_PRODUCER_DID),
2158 format!("{REVOCATION_PRODUCER_DID}#key-1"),
2159 );
2160 let prev_req = p
2161 .publish_request()
2162 .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2163 .context_type(ContextType::KeyRevocation)
2164 .visibility(Visibility::Public)
2165 .acdp_version("0.2.0")
2166 .build()
2167 .unwrap();
2168 let prev = body_from_request(&prev_req);
2169 assert!(
2170 KeyRevocation::from_body(&prev).is_err(),
2171 "fixture must actually fail from_body, or this test proves nothing"
2172 );
2173
2174 let req = test_request(); let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2177 assert!(
2178 matches!(err, AcdpError::SchemaViolation(_)),
2179 "arm 3's type rule must still reject a non-revocation successor even when the \
2180 predecessor is malformed and the class comparison is skipped"
2181 );
2182 }
2183
2184 #[test]
2195 fn revocation_supersession_same_did_class_flip_rejected() {
2196 let caps = test_caps_v030();
2197 let v = PublishValidator::new(&caps);
2198 let registry_did = caps.registry_did.clone();
2199
2200 let mut prev_meta = valid_revocation_metadata();
2201 prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
2202 let prev_req = build_revocation_request(®istry_did, prev_meta, "0.3.0"); let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
2204 assert_eq!(
2205 prev_revocation.trust_class,
2206 RevocationTrustClass::ProducerSigned
2207 );
2208 v.check_revocation_controller(&prev_req, &prev_revocation)
2209 .expect("PREV fixture must be a legitimately publishable revocation");
2210 let prev = body_from_request(&prev_req);
2211
2212 let mut meta = valid_revocation_metadata();
2213 meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
2214 let req = build_revocation_request(®istry_did, meta, "0.3.0"); let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
2216 assert_eq!(
2217 in_revocation.trust_class,
2218 RevocationTrustClass::RegistryAttested
2219 );
2220 v.check_revocation_controller(&req, &in_revocation)
2221 .expect("IN fixture must be a legitimately publishable revocation");
2222
2223 let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2224 assert!(
2225 matches!(err, AcdpError::SchemaViolation(_)),
2226 "same agent_id on both sides must NOT be enough to allow this supersession — \
2227 the criterion is signer class, not DID"
2228 );
2229 }
2230
2231 #[test]
2239 fn revocation_supersession_cross_did_same_class_allowed() {
2240 let prev_req = build_revocation_request(
2241 REVOCATION_PRODUCER_DID,
2242 valid_revocation_metadata(), "0.3.0",
2244 );
2245 let prev = body_from_request(&prev_req);
2246
2247 let req = build_revocation_request(
2248 REVOCATION_OTHER_PRODUCER_DID, valid_revocation_metadata(), "0.3.0",
2251 );
2252
2253 check_revocation_supersession(&prev, &req, "0.3.0").expect(
2254 "cross-DID, same signer class (ProducerSigned) must be allowed — \
2255 RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
2256 class, not DID",
2257 );
2258 }
2259}