use acdp_crypto::hash::{compute_content_hash, derive_lineage_id};
use acdp_primitives::error::AcdpError;
use acdp_types::{
body::Body,
capabilities::CapabilitiesDocument,
primitives::{ContentHash, CtxId, LineageId},
publish::PublishRequest,
revocation::KeyRevocation,
};
#[derive(Debug)]
pub struct ValidatedPublish {
pub recomputed_hash: ContentHash,
}
pub struct PublishValidator<'a> {
caps: &'a CapabilitiesDocument,
own_authority: Option<&'a str>,
}
impl<'a> PublishValidator<'a> {
pub fn new(caps: &'a CapabilitiesDocument) -> Self {
Self {
caps,
own_authority: None,
}
}
pub fn for_authority(caps: &'a CapabilitiesDocument, own_authority: &'a str) -> Self {
Self {
caps,
own_authority: Some(own_authority),
}
}
pub fn validate_post_schema(
&self,
req: &PublishRequest,
raw_body_bytes: usize,
) -> Result<ValidatedPublish, AcdpError> {
acdp_validation::validate_publish_request(req)?;
self.validate_registry_limits_and_crypto(req, raw_body_bytes)
}
#[deprecated(
since = "0.1.0",
note = "Use validate_post_schema; this alias no longer skips runtime validation"
)]
pub fn validate_structural(
&self,
req: &PublishRequest,
raw_body_bytes: usize,
) -> Result<ValidatedPublish, AcdpError> {
self.validate_post_schema(req, raw_body_bytes)
}
fn validate_registry_limits_and_crypto(
&self,
req: &PublishRequest,
raw_body_bytes: usize,
) -> Result<ValidatedPublish, AcdpError> {
if raw_body_bytes as u64 > self.caps.limits.max_payload_bytes {
return Err(AcdpError::SchemaViolation(format!(
"payload {} bytes exceeds limit {}",
raw_body_bytes, self.caps.limits.max_payload_bytes
)));
}
for dr in &req.data_refs {
if let Some(emb) = &dr.embedded {
let decoded = acdp_validation::embedded_decoded_bytes(emb)?;
if decoded.len() as u64 > self.caps.limits.max_embedded_bytes {
return Err(AcdpError::EmbeddedTooLarge(format!(
"embedded data reference {} bytes exceeds {} limit",
decoded.len(),
self.caps.limits.max_embedded_bytes
)));
}
acdp_validation::verify_embedded_hash(dr)?;
}
}
let body_val = serde_json::to_value(req)?;
let recomputed = compute_content_hash(&body_val)?;
if recomputed != req.content_hash {
return Err(AcdpError::HashMismatch {
stored: req.content_hash.clone(),
recomputed: recomputed.clone(),
});
}
if !self
.caps
.supported_signature_algorithms
.iter()
.any(|a| a == &req.signature.algorithm)
{
return Err(AcdpError::SchemaViolation(format!(
"unsupported algorithm '{}'; registry supports {:?}",
req.signature.algorithm, self.caps.supported_signature_algorithms,
)));
}
let agent_method = req
.agent_id
.as_str()
.splitn(3, ':')
.take(2)
.collect::<Vec<_>>()
.join(":");
if !self.caps.supports_did_method(&agent_method) {
return Err(AcdpError::KeyResolution(format!(
"agent_id method '{agent_method}' is not in this registry's \
supported_did_methods {:?}",
self.caps.supported_did_methods
)));
}
let key_id = &req.signature.key_id;
let did_part = key_id.split_once('#').map(|(d, _)| d).ok_or_else(|| {
AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'"))
})?;
if did_part != req.agent_id.as_str() {
return Err(AcdpError::KeyNotAuthorized(format!(
"key_id DID '{did_part}' ≠ agent_id '{}'",
req.agent_id
)));
}
if let (Some(own), Some(target)) = (self.own_authority, &req.supersedes) {
let target_authority = target.authority();
if target_authority != own {
return Err(AcdpError::SupersededTarget {
reason: acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported,
message: format!(
"supersedes target on '{target_authority}' rejected by '{own}'; \
v0.1.0 only allows same-registry supersession"
),
});
}
}
if req.context_type.is_key_revocation()
&& key_revocation_gate_applies(&self.caps.acdp_version)
{
let revocation = KeyRevocation::from_publish_request(req)?;
self.check_revocation_controller(req, &revocation)?;
}
Ok(ValidatedPublish {
recomputed_hash: recomputed,
})
}
fn check_revocation_controller(
&self,
req: &PublishRequest,
revocation: &KeyRevocation,
) -> Result<(), AcdpError> {
let controller_present = req
.metadata
.as_ref()
.and_then(|m| m.as_object())
.is_some_and(|m| m.contains_key("revoked_key_controller"));
let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
let controller_differs = revocation.revoked_key_controller != req.agent_id;
if controller_present && controller_differs && !agent_is_registry {
return Err(AcdpError::SchemaViolation(format!(
"metadata.revoked_key_controller '{}' differs from agent_id '{}', but \
agent_id is not this registry's own DID ('{}'); a controller different \
from agent_id is only valid on a §6 registry-attested revocation \
(RFC-ACDP-0014 §4, §6)",
revocation.revoked_key_controller, req.agent_id, self.caps.registry_did
)));
}
if !controller_present && agent_is_registry {
return Err(AcdpError::SchemaViolation(format!(
"key-revocation published under this registry's own DID ('{}') has no \
metadata.revoked_key_controller; a registry-attested revocation MUST \
name the affected producer's DID as the controller (RFC-ACDP-0014 §4, §6)",
self.caps.registry_did
)));
}
Ok(())
}
}
fn is_well_formed_version(v: &str) -> bool {
let parts: Vec<&str> = v.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}
pub fn key_revocation_gate_applies(acdp_version: &str) -> bool {
if !is_well_formed_version(acdp_version) {
return true;
}
let mut parts = acdp_version.split('.');
let major: u64 = match parts.next().and_then(|p| p.parse().ok()) {
Some(m) => m,
None => return true,
};
let minor: u64 = match parts.next().and_then(|p| p.parse().ok()) {
Some(m) => m,
None => return true,
};
major > 0 || minor >= 3
}
pub fn check_revocation_supersession(prev: &Body, req: &PublishRequest) -> Result<(), AcdpError> {
if !prev.context_type.is_key_revocation() {
return Ok(());
}
if !req.context_type.is_key_revocation() {
return Err(AcdpError::SchemaViolation(format!(
"ctx_id '{}' is a key-revocation context and MAY only be superseded by \
another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
from agent_id '{}' has type '{}'",
prev.ctx_id,
req.agent_id,
context_type_label(&req.context_type),
)));
}
let prev_revocation = match KeyRevocation::from_body(prev) {
Ok(r) => r,
Err(_) => {
return Ok(());
}
};
let incoming_revocation = KeyRevocation::from_publish_request(req)?;
if prev_revocation.trust_class == incoming_revocation.trust_class {
Ok(())
} else {
Err(AcdpError::SchemaViolation(format!(
"ctx_id '{}' is a key-revocation with signer class {:?}; the incoming \
supersession from agent_id '{}' is a key-revocation with signer class {:?} \
— a revocation MAY only be superseded by another key-revocation from the \
same signer class (RFC-ACDP-0014 §4)",
prev.ctx_id, prev_revocation.trust_class, req.agent_id, incoming_revocation.trust_class,
)))
}
}
fn context_type_label(context_type: &acdp_types::primitives::ContextType) -> String {
serde_json::to_value(context_type)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_else(|| "<unrepresentable>".into())
}
pub fn assign_identifiers(
authority: &str,
supersedes: &Option<CtxId>,
first_version_ctx_id: Option<&CtxId>,
_validated: &ValidatedPublish,
) -> Result<(CtxId, LineageId), AcdpError> {
let uuid = uuid::Uuid::new_v4();
let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
(None, _) => &ctx_id,
(Some(_), Some(v1)) => v1,
(Some(_), None) => {
return Err(AcdpError::SchemaViolation(
"supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
));
}
};
let lineage_id = derive_lineage_id(lineage_source);
Ok((ctx_id, lineage_id))
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_crypto::SigningKey;
use acdp_producer::Producer;
use acdp_types::{
capabilities::Limits,
primitives::{AgentDid, ContextType, Visibility},
revocation::RevocationTrustClass,
};
fn test_caps() -> CapabilitiesDocument {
CapabilitiesDocument {
acdp_version: "0.1.0".into(),
registry_did: "did:web:registry.example.com".into(),
supported_signature_algorithms: vec!["ed25519".into()],
supported_did_methods: vec!["did:web".into()],
profiles: vec!["acdp-registry-core".into()],
limits: Limits {
max_payload_bytes: 1_048_576,
max_embedded_bytes: 65_536,
idempotency_key_ttl_seconds: None,
max_publish_per_minute: None,
},
read_authentication_methods: vec![],
anonymous_public_reads: true,
supports_idempotency_key: false,
extensions: Default::default(),
}
}
fn test_request() -> PublishRequest {
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new("did:web:agents.example.com:test-producer"),
"did:web:agents.example.com:test-producer#key-1",
);
p.publish_request()
.title("Golden test vector — minimal first version")
.context_type(ContextType::DataSnapshot)
.visibility(Visibility::Public)
.build()
.unwrap()
}
#[test]
fn happy_path_validates() {
let caps = test_caps();
let v = PublishValidator::new(&caps);
let req = test_request();
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn payload_too_large_rejected() {
let mut caps = test_caps();
caps.limits.max_payload_bytes = 10;
let v = PublishValidator::new(&caps);
let req = test_request();
let err = v.validate_post_schema(&req, 1024).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn unsupported_algorithm_rejected() {
let mut caps = test_caps();
caps.supported_signature_algorithms = vec!["secp256k1".into()];
let v = PublishValidator::new(&caps);
let req = test_request();
let err = v.validate_post_schema(&req, 1024).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn key_id_without_fragment_rejected() {
let caps = test_caps();
let v = PublishValidator::new(&caps);
let mut req = test_request();
req.signature.key_id = "did:web:agents.example.com:test-producer".into();
let err = v.validate_post_schema(&req, 1024).unwrap_err();
assert!(matches!(err, AcdpError::KeyResolution(_)));
}
#[test]
fn key_id_did_must_match_agent_id() {
let caps = test_caps();
let v = PublishValidator::new(&caps);
let mut req = test_request();
req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
let err = v.validate_post_schema(&req, 1024).unwrap_err();
assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
}
#[test]
fn tampered_hash_detected() {
let caps = test_caps();
let v = PublishValidator::new(&caps);
let mut req = test_request();
req.title = "tampered title".into();
let err = v.validate_post_schema(&req, 1024).unwrap_err();
assert!(matches!(err, AcdpError::HashMismatch { .. }));
}
#[test]
fn assign_identifiers_first_version_derives_lineage_from_new_id() {
let v = ValidatedPublish {
recomputed_hash: ContentHash("sha256:abcd".into()),
};
let (ctx_id, lineage_id) =
assign_identifiers("registry.example.com", &None, None, &v).unwrap();
let expected = derive_lineage_id(&ctx_id);
assert_eq!(lineage_id, expected);
}
#[test]
fn assign_identifiers_supersession_uses_v1_ctx_id() {
let v = ValidatedPublish {
recomputed_hash: ContentHash("sha256:abcd".into()),
};
let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
let supersedes = Some(CtxId(
"acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
));
let (_new_id, lineage_id) =
assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
assert_eq!(lineage_id, derive_lineage_id(&v1));
}
#[test]
fn cross_registry_supersession_rejected() {
let caps = test_caps();
let v = PublishValidator::for_authority(&caps, "registry.example.com");
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new("did:web:agents.example.com:test-producer"),
"did:web:agents.example.com:test-producer#key-1",
);
let other_reg =
CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
let req = p
.supersede(other_reg)
.version(2)
.title("v2")
.context_type(ContextType::DataSnapshot)
.build()
.unwrap();
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
match err {
AcdpError::SupersededTarget { reason, .. } => {
assert_eq!(
reason,
acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
);
}
other => panic!("expected SupersededTarget, got {other:?}"),
}
}
#[test]
fn same_registry_supersession_passes_authority_check() {
let caps = test_caps();
let v = PublishValidator::for_authority(&caps, "registry.example.com");
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new("did:web:agents.example.com:test-producer"),
"did:web:agents.example.com:test-producer#key-1",
);
let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
let req = p
.supersede(same)
.version(2)
.title("v2")
.context_type(ContextType::DataSnapshot)
.build()
.unwrap();
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn assign_identifiers_supersession_without_v1_id_rejected() {
let v = ValidatedPublish {
recomputed_hash: ContentHash("sha256:abcd".into()),
};
let supersedes = Some(CtxId("acdp://x/y".into()));
let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
fn test_caps_v030() -> CapabilitiesDocument {
CapabilitiesDocument {
acdp_version: "0.3.0".into(),
..test_caps()
}
}
fn test_caps_v020() -> CapabilitiesDocument {
CapabilitiesDocument {
acdp_version: "0.2.0".into(),
..test_caps()
}
}
const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
const REVOCATION_FP: &str =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
fn valid_revocation_metadata() -> serde_json::Value {
serde_json::json!({
"revoked_key_fingerprint": REVOCATION_FP,
"compromised_since": REVOCATION_SINCE,
})
}
fn build_revocation_request(
agent_did: &str,
metadata: serde_json::Value,
acdp_version: &str,
) -> PublishRequest {
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
p.publish_request()
.title("Key revocation test")
.context_type(ContextType::KeyRevocation)
.visibility(Visibility::Public)
.acdp_version(acdp_version)
.metadata(metadata)
.build()
.unwrap()
}
#[test]
fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(),
"0.3.0",
);
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
let caps = test_caps_v030();
let registry_did = caps.registry_did.clone();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
let req = build_revocation_request(®istry_did, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_arm4_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
let caps = test_caps_v030();
let registry_did = caps.registry_did.clone();
let v = PublishValidator::new(&caps);
let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_arm5_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let registry_did = caps.registry_did.clone();
let v = PublishValidator::new(&caps);
let req = build_revocation_request(®istry_did, valid_revocation_metadata(), "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_non_public_visibility_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new(REVOCATION_PRODUCER_DID),
format!("{REVOCATION_PRODUCER_DID}#key-1"),
);
let req = p
.publish_request()
.title("Key revocation test")
.context_type(ContextType::KeyRevocation)
.visibility(Visibility::Restricted)
.audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
.acdp_version("0.3.0")
.metadata(valid_revocation_metadata())
.build()
.unwrap();
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new(REVOCATION_PRODUCER_DID),
format!("{REVOCATION_PRODUCER_DID}#key-1"),
);
let req = p
.publish_request()
.title("Key revocation test")
.context_type(ContextType::KeyRevocation)
.visibility(Visibility::Restricted)
.audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
.acdp_version("0.3.0")
.metadata(valid_revocation_metadata())
.build()
.unwrap();
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_missing_fingerprint_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_missing_compromised_since_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut().unwrap().remove("compromised_since");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut().unwrap().remove("compromised_since");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_reason_over_limit_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["reason"] =
serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
let caps = test_caps_v020();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta["reason"] =
serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
let mut caps = test_caps_v030();
caps.acdp_version = "not-a-version".into();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_gate_fails_closed_on_empty_acdp_version() {
let mut caps = test_caps_v030();
caps.acdp_version = "".into();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let req = test_request();
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn key_revocation_gate_truth_table() {
let cases: &[(&str, bool)] = &[
("0.3x.0", true),
("0. 3.0", true),
("0.2.0 ", true),
("0.2.0;", true),
("0.x.1", true),
("0.\u{0663}.0", true),
("not-a-version", true),
("", true),
("0.3.0", true),
("0.4.0", true),
("1.0.0", true),
("0.2.9", false),
("0.2.0", false),
];
for (input, expected) in cases {
assert_eq!(
key_revocation_gate_applies(input),
*expected,
"input {input:?} should gate {}",
if *expected { "ON" } else { "OFF" }
);
}
}
fn build_revocation_request_with_type(
agent_did: &str,
metadata: serde_json::Value,
acdp_version: &str,
context_type: ContextType,
) -> PublishRequest {
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
p.publish_request()
.title("Key revocation test (interim §10 type)")
.context_type(context_type)
.visibility(Visibility::Public)
.acdp_version(acdp_version)
.metadata(metadata)
.build()
.unwrap()
}
#[test]
fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let req = build_revocation_request_with_type(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(),
"0.3.0",
ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
);
let raw_len = serde_json::to_vec(&req).unwrap().len();
v.validate_post_schema(&req, raw_len).unwrap();
}
#[test]
fn revocation_interim_custom_type_violation_rejected_at_0_3_0() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let mut meta = valid_revocation_metadata();
meta.as_object_mut()
.unwrap()
.remove("revoked_key_fingerprint");
let req = build_revocation_request_with_type(
REVOCATION_PRODUCER_DID,
meta,
"0.3.0",
ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
);
let raw_len = serde_json::to_vec(&req).unwrap().len();
let err = v.validate_post_schema(&req, raw_len).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
fn body_from_request(req: &PublishRequest) -> Body {
Body::from_publish_request(
req,
CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
LineageId(format!("lin:sha256:{}", "0".repeat(64))),
"registry.example.com",
chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
.unwrap()
.with_timezone(&chrono::Utc),
)
}
#[test]
fn revocation_supersession_same_class_allowed_t_earlier() {
let prev_req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(), "0.3.0",
);
let prev = body_from_request(&prev_req);
let mut meta = valid_revocation_metadata();
meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
check_revocation_supersession(&prev, &req)
.expect("same signer class supersession must be allowed regardless of T direction");
}
#[test]
fn revocation_supersession_different_class_rejected() {
let prev_req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(), "0.3.0",
);
let prev = body_from_request(&prev_req);
let registry_did = test_caps().registry_did;
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
let req = build_revocation_request(®istry_did, meta, "0.3.0");
let err = check_revocation_supersession(&prev, &req).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn revocation_superseded_by_non_revocation_rejected() {
let prev_req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(),
"0.3.0",
);
let prev = body_from_request(&prev_req);
let req = test_request();
let err = check_revocation_supersession(&prev, &req).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)));
}
#[test]
fn non_revocation_predecessor_superseded_by_revocation_allowed() {
let prev_req = test_request();
let prev = body_from_request(&prev_req);
let req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(),
"0.3.0",
);
check_revocation_supersession(&prev, &req)
.expect("a non-revocation predecessor is out of scope for this §4 row");
}
#[test]
fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
let mut prev_meta = valid_revocation_metadata();
prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
let prev = body_from_request(&prev_req);
let mut meta = valid_revocation_metadata();
meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
check_revocation_supersession(&prev, &req).expect(
"narrowing the compromise window (T moved later) is allowed at publish time; \
this function enforces only type + signer class, not compromised_since \
direction (RFC-ACDP-0014 §4:58)",
);
}
#[test]
fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new(REVOCATION_PRODUCER_DID),
format!("{REVOCATION_PRODUCER_DID}#key-1"),
);
let prev_req = p
.publish_request()
.title("Malformed pre-0.3.0 key-revocation (no metadata)")
.context_type(ContextType::KeyRevocation)
.visibility(Visibility::Public)
.acdp_version("0.2.0")
.build()
.unwrap();
let prev = body_from_request(&prev_req);
assert!(
KeyRevocation::from_body(&prev).is_err(),
"fixture must actually fail from_body, or this test proves nothing"
);
let req = build_revocation_request(
REVOCATION_OTHER_PRODUCER_DID,
valid_revocation_metadata(),
"0.3.0",
);
check_revocation_supersession(&prev, &req).expect(
"arm 6b: a malformed predecessor skips the class comparison but a \
well-formed key-revocation successor is still allowed",
);
}
#[test]
fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
let key = SigningKey::from_bytes(&[0u8; 32]);
let p = Producer::new(
key,
AgentDid::new(REVOCATION_PRODUCER_DID),
format!("{REVOCATION_PRODUCER_DID}#key-1"),
);
let prev_req = p
.publish_request()
.title("Malformed pre-0.3.0 key-revocation (no metadata)")
.context_type(ContextType::KeyRevocation)
.visibility(Visibility::Public)
.acdp_version("0.2.0")
.build()
.unwrap();
let prev = body_from_request(&prev_req);
assert!(
KeyRevocation::from_body(&prev).is_err(),
"fixture must actually fail from_body, or this test proves nothing"
);
let req = test_request();
let err = check_revocation_supersession(&prev, &req).unwrap_err();
assert!(
matches!(err, AcdpError::SchemaViolation(_)),
"arm 3's type rule must still reject a non-revocation successor even when the \
predecessor is malformed and the class comparison is skipped"
);
}
#[test]
fn revocation_supersession_same_did_class_flip_rejected() {
let caps = test_caps_v030();
let v = PublishValidator::new(&caps);
let registry_did = caps.registry_did.clone();
let mut prev_meta = valid_revocation_metadata();
prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
let prev_req = build_revocation_request(®istry_did, prev_meta, "0.3.0"); let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
assert_eq!(
prev_revocation.trust_class,
RevocationTrustClass::ProducerSigned
);
v.check_revocation_controller(&prev_req, &prev_revocation)
.expect("PREV fixture must be a legitimately publishable revocation");
let prev = body_from_request(&prev_req);
let mut meta = valid_revocation_metadata();
meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
let req = build_revocation_request(®istry_did, meta, "0.3.0"); let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
assert_eq!(
in_revocation.trust_class,
RevocationTrustClass::RegistryAttested
);
v.check_revocation_controller(&req, &in_revocation)
.expect("IN fixture must be a legitimately publishable revocation");
let err = check_revocation_supersession(&prev, &req).unwrap_err();
assert!(
matches!(err, AcdpError::SchemaViolation(_)),
"same agent_id on both sides must NOT be enough to allow this supersession — \
the criterion is signer class, not DID"
);
}
#[test]
fn revocation_supersession_cross_did_same_class_allowed() {
let prev_req = build_revocation_request(
REVOCATION_PRODUCER_DID,
valid_revocation_metadata(), "0.3.0",
);
let prev = body_from_request(&prev_req);
let req = build_revocation_request(
REVOCATION_OTHER_PRODUCER_DID, valid_revocation_metadata(), "0.3.0",
);
check_revocation_supersession(&prev, &req).expect(
"cross-DID, same signer class (ProducerSigned) must be allowed — \
RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
class, not DID",
);
}
}