Skip to main content

acdp_server/registry/
validator.rs

1//! Server-side publish validation pipeline — RFC-ACDP-0003 §2.1 (feature = "server").
2//!
3//! Runs steps 1–8 (validation) before any persistence occurs.
4
5use acdp_crypto::hash::{compute_content_hash, derive_lineage_id};
6use acdp_primitives::error::AcdpError;
7use acdp_types::{
8    body::Body,
9    capabilities::CapabilitiesDocument,
10    primitives::{ContentHash, CtxId, LineageId},
11    publish::PublishRequest,
12    revocation::KeyRevocation,
13};
14
15/// Outcome of a successful validation — the registry can now assign
16/// identifiers and persist.
17#[derive(Debug)]
18pub struct ValidatedPublish {
19    /// The hash recomputed by the validator over ProducerContent.
20    pub recomputed_hash: ContentHash,
21}
22
23/// Stateless publish request validator.
24///
25/// Runs §2.1 steps 1–8 (structural and cryptographic checks).
26/// Steps 9+ (identifier assignment, lineage, supersession, persistence)
27/// are registry-implementation concerns.
28pub struct PublishValidator<'a> {
29    caps: &'a CapabilitiesDocument,
30    own_authority: Option<&'a str>,
31}
32
33impl<'a> PublishValidator<'a> {
34    /// Create a validator without same-registry supersession enforcement.
35    pub fn new(caps: &'a CapabilitiesDocument) -> Self {
36        Self {
37            caps,
38            own_authority: None,
39        }
40    }
41
42    /// Create a validator that rejects cross-registry supersession.
43    ///
44    /// `own_authority` is the registry's DNS authority (e.g.
45    /// `registry.example.com`). When set, a publish request whose
46    /// `supersedes` ctx_id has a different authority will be rejected with
47    /// [`AcdpError::SupersededTarget`] / `CrossRegistrySupersessionUnsupported`
48    /// (RFC-ACDP-0006 — v0.1.0 only allows same-registry supersession).
49    pub fn for_authority(caps: &'a CapabilitiesDocument, own_authority: &'a str) -> Self {
50        Self {
51            caps,
52            own_authority: Some(own_authority),
53        }
54    }
55
56    /// Validate a publish request through the structural / cryptographic
57    /// steps of RFC-ACDP-0003 §2.1, plus the cross-registry-supersession
58    /// guard if the validator was built with [`Self::for_authority`].
59    ///
60    /// Mapped steps from RFC-ACDP-0003 §2.1:
61    /// - **Step 1** (schema validation) — assumed performed upstream
62    ///   (e.g. by `validate_publish_request`).
63    /// - **Step 2** (payload size vs `limits.max_payload_bytes`).
64    /// - **Step 3** (embedded size vs `limits.max_embedded_bytes`).
65    /// - **Step 4** (hash recomputation over ProducerContent).
66    /// - **Step 5** (signature algorithm vs
67    ///   `supported_signature_algorithms`).
68    /// - **Step 6** (key_id DID portion equals `agent_id`).
69    /// - **Step 7–8** (DID resolution + signature verification) — async,
70    ///   handled separately by `acdp_verify::Verifier::verify_body`.
71    /// - Cross-registry supersession check (RFC-ACDP-0006): when an
72    ///   own-authority is configured, rejects supersedes targets on a
73    ///   different authority.
74    pub fn validate_post_schema(
75        &self,
76        req: &PublishRequest,
77        raw_body_bytes: usize,
78    ) -> Result<ValidatedPublish, AcdpError> {
79        // Run the full schema-aligned validation (string lengths, array
80        // uniqueness, DataRef oneOf + URI rules, metadata depth/size,
81        // visibility/audience invariants, did:web check, signature length,
82        // identifier patterns, version coherence) on top of the raw
83        // structural / cryptographic steps below. This makes
84        // `validate_post_schema` a complete RFC-ACDP-0003 §2.1
85        // implementation regardless of whether the producer side ran
86        // [`acdp_validation::validate_publish_request`] first.
87        acdp_validation::validate_publish_request(req)?;
88        self.validate_registry_limits_and_crypto(req, raw_body_bytes)
89    }
90
91    /// Deprecated alias — now routes through [`Self::validate_post_schema`].
92    ///
93    /// The previous implementation skipped the schema-level validation
94    /// (title length, metadata depth, DataRef integrity, did:web check,
95    /// version coherence, …). Callers using `validate_structural`
96    /// directly were silently bypassing those checks. The deprecated
97    /// alias now runs the full pipeline so existing call sites remain
98    /// safe; new code should call `validate_post_schema` explicitly.
99    #[deprecated(
100        since = "0.1.0",
101        note = "Use validate_post_schema; this alias no longer skips runtime validation"
102    )]
103    pub fn validate_structural(
104        &self,
105        req: &PublishRequest,
106        raw_body_bytes: usize,
107    ) -> Result<ValidatedPublish, AcdpError> {
108        self.validate_post_schema(req, raw_body_bytes)
109    }
110
111    /// Internal: registry-limit + cryptographic step list (no schema
112    /// validation). Keep private — bypassing the schema validation is
113    /// not a publishable surface.
114    fn validate_registry_limits_and_crypto(
115        &self,
116        req: &PublishRequest,
117        raw_body_bytes: usize,
118    ) -> Result<ValidatedPublish, AcdpError> {
119        // Step 2: payload size
120        if raw_body_bytes as u64 > self.caps.limits.max_payload_bytes {
121            return Err(AcdpError::SchemaViolation(format!(
122                "payload {} bytes exceeds limit {}",
123                raw_body_bytes, self.caps.limits.max_payload_bytes
124            )));
125        }
126
127        // Step 3: embedded size + optional embedded content_hash check
128        // (RFC-ACDP-0003 §2.1 step 3 last sentence; RFC-ACDP-0002 §6.6 #8).
129        for dr in &req.data_refs {
130            if let Some(emb) = &dr.embedded {
131                let decoded = acdp_validation::embedded_decoded_bytes(emb)?;
132                if decoded.len() as u64 > self.caps.limits.max_embedded_bytes {
133                    return Err(AcdpError::EmbeddedTooLarge(format!(
134                        "embedded data reference {} bytes exceeds {} limit",
135                        decoded.len(),
136                        self.caps.limits.max_embedded_bytes
137                    )));
138                }
139                // If the producer declared an embedded content_hash, recompute
140                // and verify per §2.1 step 3.
141                acdp_validation::verify_embedded_hash(dr)?;
142            }
143        }
144
145        // Step 4: hash recomputation over ProducerContent
146        let body_val = serde_json::to_value(req)?;
147        let recomputed = compute_content_hash(&body_val)?;
148        if recomputed != req.content_hash {
149            return Err(AcdpError::HashMismatch {
150                stored: req.content_hash.clone(),
151                recomputed: recomputed.clone(),
152            });
153        }
154
155        // Step 5: algorithm check
156        if !self
157            .caps
158            .supported_signature_algorithms
159            .iter()
160            .any(|a| a == &req.signature.algorithm)
161        {
162            return Err(AcdpError::SchemaViolation(format!(
163                "unsupported algorithm '{}'; registry supports {:?}",
164                req.signature.algorithm, self.caps.supported_signature_algorithms,
165            )));
166        }
167
168        // Step 5.5: DID-method gate — the producer's method must be one
169        // this registry advertises in `supported_did_methods` (ACDP 0.2:
170        // did:key acceptance is a per-registry capabilities decision;
171        // did:web is mandatory for every registry). Maps to
172        // `key_resolution_failed` (permanent): the registry has no
173        // resolver for the method, and no retry will grow one.
174        let agent_method = req
175            .agent_id
176            .as_str()
177            .splitn(3, ':')
178            .take(2)
179            .collect::<Vec<_>>()
180            .join(":");
181        if !self.caps.supports_did_method(&agent_method) {
182            return Err(AcdpError::KeyResolution(format!(
183                "agent_id method '{agent_method}' is not in this registry's \
184                 supported_did_methods {:?}",
185                self.caps.supported_did_methods
186            )));
187        }
188
189        // Step 6: key-id binding — DID portion must equal agent_id
190        let key_id = &req.signature.key_id;
191        let did_part = key_id.split_once('#').map(|(d, _)| d).ok_or_else(|| {
192            AcdpError::KeyResolution(format!("key_id '{key_id}' has no '#fragment'"))
193        })?;
194
195        if did_part != req.agent_id.as_str() {
196            return Err(AcdpError::KeyNotAuthorized(format!(
197                "key_id DID '{did_part}' ≠ agent_id '{}'",
198                req.agent_id
199            )));
200        }
201
202        // Cross-registry supersession check — v0.1.0 only allows same-registry.
203        if let (Some(own), Some(target)) = (self.own_authority, &req.supersedes) {
204            let target_authority = target.authority();
205            if target_authority != own {
206                return Err(AcdpError::SupersededTarget {
207                    reason: acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported,
208                    message: format!(
209                        "supersedes target on '{target_authority}' rejected by '{own}'; \
210                         v0.1.0 only allows same-registry supersession"
211                    ),
212                });
213            }
214        }
215
216        // RFC-ACDP-0014 §4 publish-time gate: registries advertising
217        // acdp_version >= 0.3.0 MUST reject malformed key-revocation
218        // bodies with schema_violation. See `key_revocation_gate_applies`
219        // for the fail-closed polarity on a malformed acdp_version.
220        if req.context_type.is_key_revocation()
221            && key_revocation_gate_applies(&self.caps.acdp_version)
222        {
223            let revocation = KeyRevocation::from_publish_request(req)?;
224            self.check_revocation_controller(req, &revocation)?;
225        }
226
227        // Steps 7–8 (key resolution + signature verification) require async
228        // DID resolution; the caller should invoke Verifier::verify_body for those.
229        Ok(ValidatedPublish {
230            recomputed_hash: recomputed,
231        })
232    }
233
234    /// RFC-ACDP-0014 §4/§6 controller-class rule — the one clause
235    /// `KeyRevocation::from_publish_request` cannot enforce on its own
236    /// because it needs the registry's own identity
237    /// (`caps.registry_did`), which lives only here.
238    ///
239    /// Five arms (§4 makes the controller OPTIONAL — defaulting to
240    /// `agent_id` — on producer-signed revocations; §6 makes it REQUIRED
241    /// and different on registry-attested ones):
242    ///
243    /// 1. absent, `agent_id != registry_did` ⇒ OK (producer-signed, defaulted).
244    /// 2. present, `== agent_id` ⇒ OK (producer-signed, explicit).
245    /// 3. present, `!= agent_id`, `agent_id == registry_did` ⇒ OK (§6 registry-attested).
246    /// 4. present, `!= agent_id`, `agent_id != registry_did` ⇒ `SchemaViolation`.
247    /// 5. absent, `agent_id == registry_did` ⇒ `SchemaViolation` — §4 and §6 step 2
248    ///    both make the controller REQUIRED on registry-attested revocations; without
249    ///    this arm a registry publishing under its own DID with no controller would be
250    ///    silently classified `ProducerSigned` by `from_parts`, i.e. treated as revoking
251    ///    its own key.
252    ///
253    /// Arm 5 is indistinguishable from arm 2 by inspecting the returned
254    /// `KeyRevocation` alone — `from_parts` collapses an absent controller
255    /// to `(agent_id.clone(), ProducerSigned)`, exactly what arm 2
256    /// produces. So presence is read directly off `req.metadata` here,
257    /// not inferred from the parsed struct.
258    fn check_revocation_controller(
259        &self,
260        req: &PublishRequest,
261        revocation: &KeyRevocation,
262    ) -> Result<(), AcdpError> {
263        let controller_present = req
264            .metadata
265            .as_ref()
266            .and_then(|m| m.as_object())
267            .is_some_and(|m| m.contains_key("revoked_key_controller"));
268
269        let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
270        let controller_differs = revocation.revoked_key_controller != req.agent_id;
271
272        if controller_present && controller_differs && !agent_is_registry {
273            // Arm 4.
274            return Err(AcdpError::SchemaViolation(format!(
275                "metadata.revoked_key_controller '{}' differs from agent_id '{}', but \
276                 agent_id is not this registry's own DID ('{}'); a controller different \
277                 from agent_id is only valid on a §6 registry-attested revocation \
278                 (RFC-ACDP-0014 §4, §6)",
279                revocation.revoked_key_controller, req.agent_id, self.caps.registry_did
280            )));
281        }
282
283        if !controller_present && agent_is_registry {
284            // Arm 5.
285            return Err(AcdpError::SchemaViolation(format!(
286                "key-revocation published under this registry's own DID ('{}') has no \
287                 metadata.revoked_key_controller; a registry-attested revocation MUST \
288                 name the affected producer's DID as the controller (RFC-ACDP-0014 §4, §6)",
289                self.caps.registry_did
290            )));
291        }
292
293        Ok(())
294    }
295}
296
297/// True when `v` is a well-formed `major.minor.patch` version string:
298/// exactly three non-empty, all-ASCII-digit, dot-separated parts. Mirrors
299/// `acdp_validation::validate_semver_pattern`'s notion of well-formedness
300/// (kept as a private, local copy here rather than a shared export, since
301/// this gate's fail-closed polarity on malformed input is specific to an
302/// admission check and should not be exposed as a general-purpose helper).
303fn is_well_formed_version(v: &str) -> bool {
304    let parts: Vec<&str> = v.split('.').collect();
305    parts.len() == 3
306        && parts
307            .iter()
308            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
309}
310
311/// RFC-ACDP-0014 §4 version gate, fail-closed.
312///
313/// A malformed `acdp_version` must turn the gate ON, never OFF. This
314/// checks well-formedness first (exactly three non-empty, all-digit,
315/// dot-separated parts — same criteria as
316/// `acdp_validation::validate_semver_pattern`) before doing any numeric
317/// comparison. Merely counting how many dot-separated parts parse as a
318/// number *anywhere* in the string is not enough: `"0.3x.0"` and
319/// `"0. 3.0"` both contain two parseable numeric parts (`0` and `0`) and
320/// would be silently misread as version `0.0`, and `"0.2.0 "` /
321/// `"0.2.0;"` would be misread as `0.2` — all turning the gate OFF when
322/// it must stay ON for anything that isn't a clean `major.minor.patch`.
323///
324/// `pub` (not merely `pub(crate)`): `RegistryServer::publish_verified_in_tenant`
325/// (server.rs) reuses this exact predicate to gate the §5 step 2
326/// did:web self-sign check on the same version boundary as the §4
327/// shape gate above — a second, independent version-comparison helper
328/// would risk drifting from this one's fail-closed polarity. It is
329/// also the version predicate an external registry implementer needs
330/// to decide whether [`check_revocation_supersession`] applies to a
331/// given publish — the two are promoted to `pub` together so a rule
332/// is never reachable without the gate that decides when to call it.
333pub fn key_revocation_gate_applies(acdp_version: &str) -> bool {
334    if !is_well_formed_version(acdp_version) {
335        return true;
336    }
337    let mut parts = acdp_version.split('.');
338    let major: u64 = match parts.next().and_then(|p| p.parse().ok()) {
339        Some(m) => m,
340        None => return true,
341    };
342    let minor: u64 = match parts.next().and_then(|p| p.parse().ok()) {
343        Some(m) => m,
344        None => return true,
345    };
346    major > 0 || minor >= 3
347}
348
349/// RFC-ACDP-0014 §4 `supersedes` row for `key-revocation` contexts.
350///
351/// Verbatim (§4): "A revocation context MAY be superseded only by
352/// another `key-revocation` context from the same signer class (e.g.
353/// to widen — never narrow — the compromise window by moving T
354/// earlier). Consumers MUST treat the earliest `compromised_since`
355/// across a revocation lineage as effective."
356///
357/// This function enforces exactly the *type* and *signer-class*
358/// halves of that sentence — nothing else. It does NOT compare
359/// `compromised_since` in either direction: the RFC's "widen, never
360/// narrow" clause is illustrative of *why* a producer would supersede
361/// a revocation, not an additional publish-time constraint — per §4:58
362/// the monotonicity protection belongs on the consumer side, as the
363/// earliest-T rule. [`acdp_types::revocation::effective_boundary`]
364/// implements that fold correctly, and — as of issue #226 — assembling
365/// its input from a registry is wired end-to-end on the consumer side:
366/// `acdp_client::revocation::{find_revocations, find_registry_attested_revocations,
367/// find_revocations_in_lineage}` each walk a candidate's full lineage
368/// (via `GET /lineages/{id}`, including superseded and retracted
369/// members) rather than trusting a single search-visible one, so a
370/// consumer that feeds `effective_boundary`'s input from one of those
371/// helpers gets the earliest-T guarantee genuinely, not merely
372/// aspirationally. Nothing about that consumer-side guarantee changes
373/// this function's own scope, which stays deliberately narrow: gating
374/// the *publish-time* direction too (rejecting a narrowing
375/// `compromised_since` here) would let an attacker who has learned a
376/// key is compromised deny its legitimate producer the ability to
377/// publish a corrected, earlier-T revocation superseding a prior one
378/// that understated the window — RFC-ACDP-0014 §4:58's normative verb
379/// ("Consumers MUST …") already places the monotonicity obligation on
380/// the consumer side, not the publish path.
381///
382/// "Signer class" is [`acdp_types::revocation::RevocationTrustClass`]
383/// (`ProducerSigned` vs. `RegistryAttested`) — **not** same-DID; RFC-ACDP-0014
384/// §13 explicitly blesses cross-producer registry-attested revocations
385/// superseding one another.
386///
387/// Caller contract (this function does NOT re-derive these on its
388/// own):
389/// - `prev` is the current, non-superseded version of the lineage the
390///   incoming request's `supersedes` names — the store has already
391///   confirmed the target exists, belongs to the same tenant, is
392///   owned by the requester, and is not already superseded (§4's "arm
393///   5" concerns, entirely outside this function's scope).
394/// - Call this only when [`key_revocation_gate_applies`] returns
395///   `true` for the registry's advertised `acdp_version` — pre-0.3.0
396///   registries have no `key-revocation` vocabulary to enforce this
397///   against.
398///
399/// Arms (see the Phase 5 plan for the full table):
400///
401/// **Arm 1** — `prev` key-revocation, `req` key-revocation, same class
402/// ⇒ `Ok` (regardless of `compromised_since` direction — arm 6 is just
403/// a special case of this).
404///
405/// **Arm 2** — `prev` key-revocation, `req` key-revocation, different
406/// class ⇒ `SchemaViolation`.
407///
408/// **Arm 3** — `prev` key-revocation, `req` NOT a key-revocation ⇒
409/// `SchemaViolation` — the security payload: without this, the holder
410/// of a compromised key could re-point the lineage head away from the
411/// revocation with an ordinary body, since #207's §5 step 2
412/// not-self-signed check only fires for `is_key_revocation()` bodies.
413///
414/// **Arm 4** — `prev` NOT a key-revocation ⇒ `Ok` unconditionally —
415/// out of scope for this §4 row; whatever `req` is, nothing here
416/// constrains it.
417///
418/// **Arm 6b** — `prev` is (interim-form) a key-revocation but
419/// `KeyRevocation::from_body(prev)` fails to parse (a malformed
420/// pre-0.3.0-stored body) ⇒ arm 3's type rule still applies (`req`
421/// must be a key-revocation), but the signer-class comparison is
422/// skipped since there is no parsed `prev` class to compare against —
423/// allow. This arm is unreachable on a ≥ 0.3.0 registry: every publish
424/// path routes through `validate_post_schema`, which runs
425/// `KeyRevocation::from_publish_request(req)?` when
426/// [`key_revocation_gate_applies`] is true, and `Body::from_publish_request`
427/// (`acdp_types::body`) copies verbatim the exact five fields
428/// `KeyRevocation::from_parts` reads — so a `Body` stored through that
429/// path always has `from_body(stored) ≡ from_publish_request(req)`,
430/// meaning `from_body` cannot fail there either. A future normalizing
431/// change to `Body` that broke that equivalence would turn this arm
432/// into a live escape hatch — see the inline comment at the match arm
433/// below.
434pub fn check_revocation_supersession(prev: &Body, req: &PublishRequest) -> Result<(), AcdpError> {
435    if !prev.context_type.is_key_revocation() {
436        // Arm 4: whatever `prev` is, this §4 row does not constrain
437        // its supersession.
438        return Ok(());
439    }
440
441    if !req.context_type.is_key_revocation() {
442        // Arm 3: the security payload. `prev` is a safety broadcast;
443        // only another key-revocation may take over its lineage head.
444        return Err(AcdpError::SchemaViolation(format!(
445            "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
446             another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
447             from agent_id '{}' has type '{}'",
448            prev.ctx_id,
449            req.agent_id,
450            context_type_label(&req.context_type),
451        )));
452    }
453
454    // Both PREV and IN are key-revocations. Arms 1/2/6/6b turn on the
455    // signer class, which requires parsing PREV's metadata.
456    let prev_revocation = match KeyRevocation::from_body(prev) {
457        Ok(r) => r,
458        Err(_) => {
459            // Arm 6b: PREV was stored as a key-revocation (by type) but
460            // does not shape-validate today — most plausibly a
461            // pre-0.3.0 body admitted before this rule existed. Arm 3's
462            // type rule already passed above; there is no parsed class
463            // to compare IN against, so allow rather than fail closed
464            // on a predecessor this function did not admit.
465            //
466            // Load-bearing equivalence: on ≥ 0.3.0 this branch is
467            // unreachable, because `from_body(stored) ≡
468            // from_publish_request(req)` — `Body::from_publish_request`
469            // copies verbatim the same five fields
470            // `KeyRevocation::from_parts` reads, and the publish gate
471            // already required `from_publish_request` to succeed. If a
472            // future change to `Body::from_publish_request` ever stops
473            // copying one of those fields verbatim, this arm silently
474            // becomes reachable again as an allow-anything escape
475            // hatch for a well-formed stored revocation.
476            return Ok(());
477        }
478    };
479
480    let incoming_revocation = KeyRevocation::from_publish_request(req)?;
481
482    if prev_revocation.trust_class == incoming_revocation.trust_class {
483        // Arms 1 and 6: same signer class, any `compromised_since`
484        // direction.
485        Ok(())
486    } else {
487        // Arm 2: signer class changed across the supersession.
488        Err(AcdpError::SchemaViolation(format!(
489            "ctx_id '{}' is a key-revocation with signer class {:?}; the incoming \
490             supersession from agent_id '{}' is a key-revocation with signer class {:?} \
491             — a revocation MAY only be superseded by another key-revocation from the \
492             same signer class (RFC-ACDP-0014 §4)",
493            prev.ctx_id, prev_revocation.trust_class, req.agent_id, incoming_revocation.trust_class,
494        )))
495    }
496}
497
498/// Human-readable label for a [`acdp_types::primitives::ContextType`]
499/// for use in error messages only (mirrors the `serde_json` round-trip
500/// `acdp_types::revocation` already uses for the same purpose).
501fn context_type_label(context_type: &acdp_types::primitives::ContextType) -> String {
502    serde_json::to_value(context_type)
503        .ok()
504        .and_then(|v| v.as_str().map(str::to_owned))
505        .unwrap_or_else(|| "<unrepresentable>".into())
506}
507
508/// Assign registry identifiers after successful validation per
509/// RFC-ACDP-0001 §5.6.
510///
511/// For first-version publications (`supersedes == None`,
512/// `first_version_ctx_id == None`), `lineage_id` is derived from the newly
513/// assigned `ctx_id`. For supersession (`supersedes == Some(_)`), the
514/// caller MUST supply the v1 `ctx_id` of the lineage so `lineage_id` is
515/// derived from it — using the new ctx_id would orphan the supersession
516/// from its lineage.
517///
518/// Returns `SchemaViolation` if `supersedes` is set but
519/// `first_version_ctx_id` is not.
520pub fn assign_identifiers(
521    authority: &str,
522    supersedes: &Option<CtxId>,
523    first_version_ctx_id: Option<&CtxId>,
524    _validated: &ValidatedPublish,
525) -> Result<(CtxId, LineageId), AcdpError> {
526    let uuid = uuid::Uuid::new_v4();
527    let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
528    let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
529        (None, _) => &ctx_id,
530        (Some(_), Some(v1)) => v1,
531        (Some(_), None) => {
532            return Err(AcdpError::SchemaViolation(
533                "supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
534            ));
535        }
536    };
537    let lineage_id = derive_lineage_id(lineage_source);
538    Ok((ctx_id, lineage_id))
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use acdp_crypto::SigningKey;
545    use acdp_producer::Producer;
546    use acdp_types::{
547        capabilities::Limits,
548        primitives::{AgentDid, ContextType, Visibility},
549        revocation::RevocationTrustClass,
550    };
551
552    fn test_caps() -> CapabilitiesDocument {
553        CapabilitiesDocument {
554            acdp_version: "0.1.0".into(),
555            registry_did: "did:web:registry.example.com".into(),
556            supported_signature_algorithms: vec!["ed25519".into()],
557            supported_did_methods: vec!["did:web".into()],
558            profiles: vec!["acdp-registry-core".into()],
559            limits: Limits {
560                max_payload_bytes: 1_048_576,
561                max_embedded_bytes: 65_536,
562                idempotency_key_ttl_seconds: None,
563                max_publish_per_minute: None,
564            },
565            read_authentication_methods: vec![],
566            anonymous_public_reads: true,
567            supports_idempotency_key: false,
568            extensions: Default::default(),
569        }
570    }
571
572    fn test_request() -> PublishRequest {
573        let key = SigningKey::from_bytes(&[0u8; 32]);
574        let p = Producer::new(
575            key,
576            AgentDid::new("did:web:agents.example.com:test-producer"),
577            "did:web:agents.example.com:test-producer#key-1",
578        );
579        p.publish_request()
580            .title("Golden test vector — minimal first version")
581            .context_type(ContextType::DataSnapshot)
582            .visibility(Visibility::Public)
583            .build()
584            .unwrap()
585    }
586
587    #[test]
588    fn happy_path_validates() {
589        let caps = test_caps();
590        let v = PublishValidator::new(&caps);
591        let req = test_request();
592        let raw_len = serde_json::to_vec(&req).unwrap().len();
593        v.validate_post_schema(&req, raw_len).unwrap();
594    }
595
596    #[test]
597    fn payload_too_large_rejected() {
598        let mut caps = test_caps();
599        caps.limits.max_payload_bytes = 10;
600        let v = PublishValidator::new(&caps);
601        let req = test_request();
602        let err = v.validate_post_schema(&req, 1024).unwrap_err();
603        assert!(matches!(err, AcdpError::SchemaViolation(_)));
604    }
605
606    #[test]
607    fn unsupported_algorithm_rejected() {
608        let mut caps = test_caps();
609        caps.supported_signature_algorithms = vec!["secp256k1".into()];
610        let v = PublishValidator::new(&caps);
611        let req = test_request();
612        let err = v.validate_post_schema(&req, 1024).unwrap_err();
613        assert!(matches!(err, AcdpError::SchemaViolation(_)));
614    }
615
616    #[test]
617    fn key_id_without_fragment_rejected() {
618        let caps = test_caps();
619        let v = PublishValidator::new(&caps);
620        let mut req = test_request();
621        req.signature.key_id = "did:web:agents.example.com:test-producer".into();
622        let err = v.validate_post_schema(&req, 1024).unwrap_err();
623        assert!(matches!(err, AcdpError::KeyResolution(_)));
624    }
625
626    #[test]
627    fn key_id_did_must_match_agent_id() {
628        let caps = test_caps();
629        let v = PublishValidator::new(&caps);
630        let mut req = test_request();
631        req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
632        let err = v.validate_post_schema(&req, 1024).unwrap_err();
633        assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
634    }
635
636    #[test]
637    fn tampered_hash_detected() {
638        let caps = test_caps();
639        let v = PublishValidator::new(&caps);
640        let mut req = test_request();
641        req.title = "tampered title".into();
642        let err = v.validate_post_schema(&req, 1024).unwrap_err();
643        assert!(matches!(err, AcdpError::HashMismatch { .. }));
644    }
645
646    #[test]
647    fn assign_identifiers_first_version_derives_lineage_from_new_id() {
648        let v = ValidatedPublish {
649            recomputed_hash: ContentHash("sha256:abcd".into()),
650        };
651        let (ctx_id, lineage_id) =
652            assign_identifiers("registry.example.com", &None, None, &v).unwrap();
653        let expected = derive_lineage_id(&ctx_id);
654        assert_eq!(lineage_id, expected);
655    }
656
657    #[test]
658    fn assign_identifiers_supersession_uses_v1_ctx_id() {
659        let v = ValidatedPublish {
660            recomputed_hash: ContentHash("sha256:abcd".into()),
661        };
662        let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
663        let supersedes = Some(CtxId(
664            "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
665        ));
666        let (_new_id, lineage_id) =
667            assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
668        assert_eq!(lineage_id, derive_lineage_id(&v1));
669    }
670
671    #[test]
672    fn cross_registry_supersession_rejected() {
673        let caps = test_caps();
674        let v = PublishValidator::for_authority(&caps, "registry.example.com");
675        // Build a v2 request that supersedes a context on a different registry
676        let key = SigningKey::from_bytes(&[0u8; 32]);
677        let p = Producer::new(
678            key,
679            AgentDid::new("did:web:agents.example.com:test-producer"),
680            "did:web:agents.example.com:test-producer#key-1",
681        );
682        let other_reg =
683            CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
684        let req = p
685            .supersede(other_reg)
686            .version(2)
687            .title("v2")
688            .context_type(ContextType::DataSnapshot)
689            .build()
690            .unwrap();
691        let raw_len = serde_json::to_vec(&req).unwrap().len();
692        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
693        match err {
694            AcdpError::SupersededTarget { reason, .. } => {
695                assert_eq!(
696                    reason,
697                    acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
698                );
699            }
700            other => panic!("expected SupersededTarget, got {other:?}"),
701        }
702    }
703
704    #[test]
705    fn same_registry_supersession_passes_authority_check() {
706        let caps = test_caps();
707        let v = PublishValidator::for_authority(&caps, "registry.example.com");
708        let key = SigningKey::from_bytes(&[0u8; 32]);
709        let p = Producer::new(
710            key,
711            AgentDid::new("did:web:agents.example.com:test-producer"),
712            "did:web:agents.example.com:test-producer#key-1",
713        );
714        let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
715        let req = p
716            .supersede(same)
717            .version(2)
718            .title("v2")
719            .context_type(ContextType::DataSnapshot)
720            .build()
721            .unwrap();
722        let raw_len = serde_json::to_vec(&req).unwrap().len();
723        v.validate_post_schema(&req, raw_len).unwrap();
724    }
725
726    #[test]
727    fn assign_identifiers_supersession_without_v1_id_rejected() {
728        let v = ValidatedPublish {
729            recomputed_hash: ContentHash("sha256:abcd".into()),
730        };
731        let supersedes = Some(CtxId("acdp://x/y".into()));
732        let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
733        assert!(matches!(err, AcdpError::SchemaViolation(_)));
734    }
735
736    // ── Phase 6: RFC-ACDP-0014 §4 key-revocation publish-time gate ─────
737
738    fn test_caps_v030() -> CapabilitiesDocument {
739        CapabilitiesDocument {
740            acdp_version: "0.3.0".into(),
741            ..test_caps()
742        }
743    }
744
745    fn test_caps_v020() -> CapabilitiesDocument {
746        CapabilitiesDocument {
747            acdp_version: "0.2.0".into(),
748            ..test_caps()
749        }
750    }
751
752    const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
753    const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
754    const REVOCATION_FP: &str =
755        "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
756    const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
757
758    fn valid_revocation_metadata() -> serde_json::Value {
759        serde_json::json!({
760            "revoked_key_fingerprint": REVOCATION_FP,
761            "compromised_since": REVOCATION_SINCE,
762        })
763    }
764
765    /// Builds a signed `key-revocation` `PublishRequest` published under
766    /// `agent_did`, with the given `metadata` and wire `acdp_version`
767    /// field (independent of the *registry's* `caps.acdp_version` under
768    /// test).
769    fn build_revocation_request(
770        agent_did: &str,
771        metadata: serde_json::Value,
772        acdp_version: &str,
773    ) -> PublishRequest {
774        let key = SigningKey::from_bytes(&[0u8; 32]);
775        let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
776        p.publish_request()
777            .title("Key revocation test")
778            .context_type(ContextType::KeyRevocation)
779            .visibility(Visibility::Public)
780            .acdp_version(acdp_version)
781            .metadata(metadata)
782            .build()
783            .unwrap()
784    }
785
786    // Arm 1: controller absent, agent_id != registry_did ⇒ OK
787    // (producer-signed, defaulted).
788    #[test]
789    fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
790        let caps = test_caps_v030();
791        let v = PublishValidator::new(&caps);
792        let req = build_revocation_request(
793            REVOCATION_PRODUCER_DID,
794            valid_revocation_metadata(),
795            "0.3.0",
796        );
797        let raw_len = serde_json::to_vec(&req).unwrap().len();
798        v.validate_post_schema(&req, raw_len).unwrap();
799    }
800
801    // Arm 2: controller present and == agent_id ⇒ OK (producer-signed,
802    // explicit).
803    #[test]
804    fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
805        let caps = test_caps_v030();
806        let v = PublishValidator::new(&caps);
807        let mut meta = valid_revocation_metadata();
808        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
809        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
810        let raw_len = serde_json::to_vec(&req).unwrap().len();
811        v.validate_post_schema(&req, raw_len).unwrap();
812    }
813
814    // Arm 3: controller present, != agent_id, agent_id == registry_did ⇒
815    // OK (§6 registry-attested).
816    #[test]
817    fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
818        let caps = test_caps_v030();
819        let registry_did = caps.registry_did.clone();
820        let v = PublishValidator::new(&caps);
821        let mut meta = valid_revocation_metadata();
822        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
823        let req = build_revocation_request(&registry_did, meta, "0.3.0");
824        let raw_len = serde_json::to_vec(&req).unwrap().len();
825        v.validate_post_schema(&req, raw_len).unwrap();
826    }
827
828    // Arm 4: controller present, != agent_id, agent_id != registry_did ⇒
829    // SchemaViolation.
830    #[test]
831    fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
832        let caps = test_caps_v030();
833        let v = PublishValidator::new(&caps);
834        let mut meta = valid_revocation_metadata();
835        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
836        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
837        let raw_len = serde_json::to_vec(&req).unwrap().len();
838        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
839        assert!(matches!(err, AcdpError::SchemaViolation(_)));
840    }
841
842    #[test]
843    fn revocation_arm4_accepted_at_0_2_0_positive_control() {
844        let caps = test_caps_v020();
845        let v = PublishValidator::new(&caps);
846        let mut meta = valid_revocation_metadata();
847        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
848        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
849        let raw_len = serde_json::to_vec(&req).unwrap().len();
850        v.validate_post_schema(&req, raw_len).unwrap();
851    }
852
853    // Arm 5 — the one everyone misses: controller absent, agent_id ==
854    // registry_did ⇒ SchemaViolation. Indistinguishable from arm 1/2 by
855    // inspecting the returned `KeyRevocation` alone (`from_parts`
856    // collapses an absent controller to `(agent_id.clone(),
857    // ProducerSigned)`), so the gate must read presence off
858    // `req.metadata` directly.
859    #[test]
860    fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
861        let caps = test_caps_v030();
862        let registry_did = caps.registry_did.clone();
863        let v = PublishValidator::new(&caps);
864        let req = build_revocation_request(&registry_did, valid_revocation_metadata(), "0.3.0");
865        let raw_len = serde_json::to_vec(&req).unwrap().len();
866        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
867        assert!(matches!(err, AcdpError::SchemaViolation(_)));
868    }
869
870    #[test]
871    fn revocation_arm5_accepted_at_0_2_0_positive_control() {
872        let caps = test_caps_v020();
873        let registry_did = caps.registry_did.clone();
874        let v = PublishValidator::new(&caps);
875        let req = build_revocation_request(&registry_did, valid_revocation_metadata(), "0.3.0");
876        let raw_len = serde_json::to_vec(&req).unwrap().len();
877        v.validate_post_schema(&req, raw_len).unwrap();
878    }
879
880    #[test]
881    fn revocation_non_public_visibility_rejected_at_0_3_0() {
882        let caps = test_caps_v030();
883        let v = PublishValidator::new(&caps);
884        let key = SigningKey::from_bytes(&[0u8; 32]);
885        let p = Producer::new(
886            key,
887            AgentDid::new(REVOCATION_PRODUCER_DID),
888            format!("{REVOCATION_PRODUCER_DID}#key-1"),
889        );
890        let req = p
891            .publish_request()
892            .title("Key revocation test")
893            .context_type(ContextType::KeyRevocation)
894            .visibility(Visibility::Restricted)
895            .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
896            .acdp_version("0.3.0")
897            .metadata(valid_revocation_metadata())
898            .build()
899            .unwrap();
900        let raw_len = serde_json::to_vec(&req).unwrap().len();
901        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
902        assert!(matches!(err, AcdpError::SchemaViolation(_)));
903    }
904
905    #[test]
906    fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
907        let caps = test_caps_v020();
908        let v = PublishValidator::new(&caps);
909        let key = SigningKey::from_bytes(&[0u8; 32]);
910        let p = Producer::new(
911            key,
912            AgentDid::new(REVOCATION_PRODUCER_DID),
913            format!("{REVOCATION_PRODUCER_DID}#key-1"),
914        );
915        let req = p
916            .publish_request()
917            .title("Key revocation test")
918            .context_type(ContextType::KeyRevocation)
919            .visibility(Visibility::Restricted)
920            .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
921            .acdp_version("0.3.0")
922            .metadata(valid_revocation_metadata())
923            .build()
924            .unwrap();
925        let raw_len = serde_json::to_vec(&req).unwrap().len();
926        v.validate_post_schema(&req, raw_len).unwrap();
927    }
928
929    #[test]
930    fn revocation_missing_fingerprint_rejected_at_0_3_0() {
931        let caps = test_caps_v030();
932        let v = PublishValidator::new(&caps);
933        let mut meta = valid_revocation_metadata();
934        meta.as_object_mut()
935            .unwrap()
936            .remove("revoked_key_fingerprint");
937        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
938        let raw_len = serde_json::to_vec(&req).unwrap().len();
939        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
940        assert!(matches!(err, AcdpError::SchemaViolation(_)));
941    }
942
943    #[test]
944    fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
945        let caps = test_caps_v020();
946        let v = PublishValidator::new(&caps);
947        let mut meta = valid_revocation_metadata();
948        meta.as_object_mut()
949            .unwrap()
950            .remove("revoked_key_fingerprint");
951        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
952        let raw_len = serde_json::to_vec(&req).unwrap().len();
953        v.validate_post_schema(&req, raw_len).unwrap();
954    }
955
956    #[test]
957    fn revocation_missing_compromised_since_rejected_at_0_3_0() {
958        let caps = test_caps_v030();
959        let v = PublishValidator::new(&caps);
960        let mut meta = valid_revocation_metadata();
961        meta.as_object_mut().unwrap().remove("compromised_since");
962        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
963        let raw_len = serde_json::to_vec(&req).unwrap().len();
964        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
965        assert!(matches!(err, AcdpError::SchemaViolation(_)));
966    }
967
968    #[test]
969    fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
970        let caps = test_caps_v020();
971        let v = PublishValidator::new(&caps);
972        let mut meta = valid_revocation_metadata();
973        meta.as_object_mut().unwrap().remove("compromised_since");
974        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
975        let raw_len = serde_json::to_vec(&req).unwrap().len();
976        v.validate_post_schema(&req, raw_len).unwrap();
977    }
978
979    #[test]
980    fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
981        let caps = test_caps_v030();
982        let v = PublishValidator::new(&caps);
983        let mut meta = valid_revocation_metadata();
984        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
985        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
986        let raw_len = serde_json::to_vec(&req).unwrap().len();
987        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
988        assert!(matches!(err, AcdpError::SchemaViolation(_)));
989    }
990
991    #[test]
992    fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
993        let caps = test_caps_v020();
994        let v = PublishValidator::new(&caps);
995        let mut meta = valid_revocation_metadata();
996        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
997        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
998        let raw_len = serde_json::to_vec(&req).unwrap().len();
999        v.validate_post_schema(&req, raw_len).unwrap();
1000    }
1001
1002    #[test]
1003    fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
1004        let caps = test_caps_v030();
1005        let v = PublishValidator::new(&caps);
1006        let mut meta = valid_revocation_metadata();
1007        meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1008        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1009        let raw_len = serde_json::to_vec(&req).unwrap().len();
1010        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1011        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1012    }
1013
1014    #[test]
1015    fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
1016        let caps = test_caps_v020();
1017        let v = PublishValidator::new(&caps);
1018        let mut meta = valid_revocation_metadata();
1019        meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1020        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1021        let raw_len = serde_json::to_vec(&req).unwrap().len();
1022        v.validate_post_schema(&req, raw_len).unwrap();
1023    }
1024
1025    #[test]
1026    fn revocation_reason_over_limit_rejected_at_0_3_0() {
1027        let caps = test_caps_v030();
1028        let v = PublishValidator::new(&caps);
1029        let mut meta = valid_revocation_metadata();
1030        meta["reason"] =
1031            serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1032        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1033        let raw_len = serde_json::to_vec(&req).unwrap().len();
1034        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1035        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1036    }
1037
1038    #[test]
1039    fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
1040        let caps = test_caps_v020();
1041        let v = PublishValidator::new(&caps);
1042        let mut meta = valid_revocation_metadata();
1043        meta["reason"] =
1044            serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1045        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1046        let raw_len = serde_json::to_vec(&req).unwrap().len();
1047        v.validate_post_schema(&req, raw_len).unwrap();
1048    }
1049
1050    // Malformed `acdp_version` must turn the gate ON (fail closed), not
1051    // off — `key_revocation_gate_applies` treats anything that is not a
1052    // well-formed `major.minor.patch` string as malformed rather than
1053    // reinterpreting it as some other version.
1054    #[test]
1055    fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
1056        let mut caps = test_caps_v030();
1057        caps.acdp_version = "not-a-version".into();
1058        let v = PublishValidator::new(&caps);
1059        let mut meta = valid_revocation_metadata();
1060        meta.as_object_mut()
1061            .unwrap()
1062            .remove("revoked_key_fingerprint");
1063        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1064        let raw_len = serde_json::to_vec(&req).unwrap().len();
1065        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1066        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1067    }
1068
1069    #[test]
1070    fn revocation_gate_fails_closed_on_empty_acdp_version() {
1071        let mut caps = test_caps_v030();
1072        caps.acdp_version = "".into();
1073        let v = PublishValidator::new(&caps);
1074        let mut meta = valid_revocation_metadata();
1075        meta.as_object_mut()
1076            .unwrap()
1077            .remove("revoked_key_fingerprint");
1078        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1079        let raw_len = serde_json::to_vec(&req).unwrap().len();
1080        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1081        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1082    }
1083
1084    // Non-key-revocation bodies are entirely unaffected by the gate,
1085    // even under a 0.3.0 registry.
1086    #[test]
1087    fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
1088        let caps = test_caps_v030();
1089        let v = PublishValidator::new(&caps);
1090        let req = test_request();
1091        let raw_len = serde_json::to_vec(&req).unwrap().len();
1092        v.validate_post_schema(&req, raw_len).unwrap();
1093    }
1094
1095    // `key_revocation_gate_applies` well-formedness truth table.
1096    //
1097    // The gate must NOT silently reinterpret a malformed `acdp_version`
1098    // string as whatever version its first two parseable numeric
1099    // fragments happen to spell — that reinterpretation is exactly the
1100    // bug being fixed here. Every entry left of `=>` is malformed (or,
1101    // for the last three rows, well-formed-and-comparable) and the
1102    // right-hand side is the required gate outcome.
1103    #[test]
1104    fn key_revocation_gate_truth_table() {
1105        let cases: &[(&str, bool)] = &[
1106            // Malformed: a typo'd patch segment must not be silently
1107            // read as "0.3" truncated down to "0.0".
1108            ("0.3x.0", true),
1109            // Malformed: an embedded space breaks the numeric parse of
1110            // that segment, and must not be read as "0.0".
1111            ("0. 3.0", true),
1112            // Malformed: trailing whitespace/punctuation after a
1113            // perfectly-formed "0.2.0" must not let the first two
1114            // fragments ("0", "2") stand in for the whole string.
1115            ("0.2.0 ", true),
1116            ("0.2.0;", true),
1117            // Malformed: a non-numeric minor segment.
1118            ("0.x.1", true),
1119            // Malformed: a unicode digit (ARABIC-INDIC THREE, U+0663)
1120            // fails `char::is_ascii_digit`, so this segment is not
1121            // ASCII-digit-only and the whole string is not well-formed.
1122            ("0.\u{0663}.0", true),
1123            // Already-covered malformed cases, kept here too so the
1124            // whole table lives in one place.
1125            ("not-a-version", true),
1126            ("", true),
1127            // Well-formed and >= 0.3.0 ⇒ gate ON.
1128            ("0.3.0", true),
1129            ("0.4.0", true),
1130            ("1.0.0", true),
1131            // Well-formed and < 0.3.0 ⇒ gate OFF.
1132            ("0.2.9", false),
1133            ("0.2.0", false),
1134        ];
1135        for (input, expected) in cases {
1136            assert_eq!(
1137                key_revocation_gate_applies(input),
1138                *expected,
1139                "input {input:?} should gate {}",
1140                if *expected { "ON" } else { "OFF" }
1141            );
1142        }
1143    }
1144
1145    /// Like `build_revocation_request`, but lets the test pick the
1146    /// `ContextType` — used to publish the RFC-ACDP-0014 §10 interim
1147    /// `acdp:key-revocation` custom form through the gate, since
1148    /// `build_revocation_request` always uses the standard
1149    /// `ContextType::KeyRevocation`.
1150    fn build_revocation_request_with_type(
1151        agent_did: &str,
1152        metadata: serde_json::Value,
1153        acdp_version: &str,
1154        context_type: ContextType,
1155    ) -> PublishRequest {
1156        let key = SigningKey::from_bytes(&[0u8; 32]);
1157        let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1158        p.publish_request()
1159            .title("Key revocation test (interim §10 type)")
1160            .context_type(context_type)
1161            .visibility(Visibility::Public)
1162            .acdp_version(acdp_version)
1163            .metadata(metadata)
1164            .build()
1165            .unwrap()
1166    }
1167
1168    // §10: a >= 0.3.0 registry ACCEPTS the interim `acdp:key-revocation`
1169    // custom type — not just the standard `key-revocation` type — and
1170    // applies the same §4 shape validation to it, since
1171    // `ContextType::is_key_revocation()` treats both forms as
1172    // equivalent and the gate is keyed off that predicate.
1173    #[test]
1174    fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
1175        let caps = test_caps_v030();
1176        let v = PublishValidator::new(&caps);
1177        let req = build_revocation_request_with_type(
1178            REVOCATION_PRODUCER_DID,
1179            valid_revocation_metadata(),
1180            "0.3.0",
1181            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1182        );
1183        let raw_len = serde_json::to_vec(&req).unwrap().len();
1184        v.validate_post_schema(&req, raw_len).unwrap();
1185    }
1186
1187    #[test]
1188    fn revocation_interim_custom_type_violation_rejected_at_0_3_0() {
1189        let caps = test_caps_v030();
1190        let v = PublishValidator::new(&caps);
1191        let mut meta = valid_revocation_metadata();
1192        meta.as_object_mut()
1193            .unwrap()
1194            .remove("revoked_key_fingerprint");
1195        let req = build_revocation_request_with_type(
1196            REVOCATION_PRODUCER_DID,
1197            meta,
1198            "0.3.0",
1199            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1200        );
1201        let raw_len = serde_json::to_vec(&req).unwrap().len();
1202        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1203        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1204    }
1205
1206    // ── Phase 5 (#216a): RFC-ACDP-0014 §4 `supersedes` rule —
1207    // `check_revocation_supersession`. Dead code until Phase 6 wires it
1208    // in; these tests exercise it directly. ─────────────────────────────
1209
1210    /// Materializes the `Body` a registry would have stored from `req`,
1211    /// so `check_revocation_supersession`'s `prev: &Body` parameter can
1212    /// be exercised without a real store.
1213    fn body_from_request(req: &PublishRequest) -> Body {
1214        Body::from_publish_request(
1215            req,
1216            CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
1217            LineageId(format!("lin:sha256:{}", "0".repeat(64))),
1218            "registry.example.com",
1219            chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1220                .unwrap()
1221                .with_timezone(&chrono::Utc),
1222        )
1223    }
1224
1225    // Arm 1: PREV key-revocation, IN key-revocation, SAME signer class
1226    // ⇒ allow, regardless of `compromised_since` direction — here IN's
1227    // T is EARLIER than PREV's.
1228    #[test]
1229    fn revocation_supersession_same_class_allowed_t_earlier() {
1230        let prev_req = build_revocation_request(
1231            REVOCATION_PRODUCER_DID,
1232            valid_revocation_metadata(), // T = 2026-05-01
1233            "0.3.0",
1234        );
1235        let prev = body_from_request(&prev_req);
1236
1237        let mut meta = valid_revocation_metadata();
1238        meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); // earlier
1239        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1240
1241        check_revocation_supersession(&prev, &req)
1242            .expect("same signer class supersession must be allowed regardless of T direction");
1243    }
1244
1245    // Arm 2: PREV key-revocation, IN key-revocation, DIFFERENT signer
1246    // class (producer-signed → registry-attested) ⇒ reject
1247    // SchemaViolation.
1248    #[test]
1249    fn revocation_supersession_different_class_rejected() {
1250        let prev_req = build_revocation_request(
1251            REVOCATION_PRODUCER_DID,
1252            valid_revocation_metadata(), // no controller ⇒ ProducerSigned
1253            "0.3.0",
1254        );
1255        let prev = body_from_request(&prev_req);
1256
1257        let registry_did = test_caps().registry_did;
1258        let mut meta = valid_revocation_metadata();
1259        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1260        let req = build_revocation_request(&registry_did, meta, "0.3.0"); // RegistryAttested
1261
1262        let err = check_revocation_supersession(&prev, &req).unwrap_err();
1263        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1264    }
1265
1266    // Arm 3: PREV key-revocation, IN NOT a key-revocation ⇒ reject
1267    // SchemaViolation. The security payload: without this, the holder
1268    // of the compromised key could re-point the lineage head away from
1269    // its own revocation with an ordinary body.
1270    #[test]
1271    fn revocation_superseded_by_non_revocation_rejected() {
1272        let prev_req = build_revocation_request(
1273            REVOCATION_PRODUCER_DID,
1274            valid_revocation_metadata(),
1275            "0.3.0",
1276        );
1277        let prev = body_from_request(&prev_req);
1278        let req = test_request(); // ordinary DataSnapshot body
1279
1280        let err = check_revocation_supersession(&prev, &req).unwrap_err();
1281        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1282    }
1283
1284    // Arm 4: PREV NOT a key-revocation ⇒ allow unconditionally,
1285    // whatever IN is — out of scope for this §4 row (RFC §4 constrains
1286    // only what may supersede a revocation, not what a revocation may
1287    // supersede).
1288    #[test]
1289    fn non_revocation_predecessor_superseded_by_revocation_allowed() {
1290        let prev_req = test_request();
1291        let prev = body_from_request(&prev_req);
1292        let req = build_revocation_request(
1293            REVOCATION_PRODUCER_DID,
1294            valid_revocation_metadata(),
1295            "0.3.0",
1296        );
1297
1298        check_revocation_supersession(&prev, &req)
1299            .expect("a non-revocation predecessor is out of scope for this §4 row");
1300    }
1301
1302    // Arm 6: same code path as arm 1, but exercising the genuinely
1303    // distinct direction — NARROWING the compromise window by moving T
1304    // LATER (arm 1 already covers "same class, T earlier"; a test that
1305    // also moves T earlier would just be arm 1 again). This is the arm
1306    // carrying the intentional residual risk: `check_revocation_supersession`
1307    // does not compare `compromised_since` direction at all, so a
1308    // narrowing supersession is allowed at publish. That is
1309    // spec-correct per §4:58 (the monotonicity protection belongs on
1310    // the consumer side via `effective_boundary`) and, as of issue
1311    // #226, that consumer-side guarantee is now wired end-to-end —
1312    // `acdp_client::revocation::find_revocations` /
1313    // `find_registry_attested_revocations` / `find_revocations_in_lineage`
1314    // walk the full lineage (superseded and retracted members
1315    // included) rather than trusting a single search-visible member —
1316    // see the doc comment above `check_revocation_supersession`.
1317    #[test]
1318    fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
1319        let mut prev_meta = valid_revocation_metadata();
1320        prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
1321        let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
1322        let prev = body_from_request(&prev_req);
1323
1324        let mut meta = valid_revocation_metadata();
1325        meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); // later — narrows
1326        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1327
1328        check_revocation_supersession(&prev, &req).expect(
1329            "narrowing the compromise window (T moved later) is allowed at publish time; \
1330             this function enforces only type + signer class, not compromised_since \
1331             direction (RFC-ACDP-0014 §4:58)",
1332        );
1333    }
1334
1335    // Arm 6b: PREV is a key-revocation by type but its stored body
1336    // fails `KeyRevocation::from_body` (malformed pre-0.3.0 body with
1337    // no metadata object at all) ⇒ arm 3's type rule still applies (IN
1338    // must be a key-revocation) but the signer-class comparison is
1339    // skipped since there is no parsed PREV class to compare.
1340    #[test]
1341    fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
1342        let key = SigningKey::from_bytes(&[0u8; 32]);
1343        let p = Producer::new(
1344            key,
1345            AgentDid::new(REVOCATION_PRODUCER_DID),
1346            format!("{REVOCATION_PRODUCER_DID}#key-1"),
1347        );
1348        let prev_req = p
1349            .publish_request()
1350            .title("Malformed pre-0.3.0 key-revocation (no metadata)")
1351            .context_type(ContextType::KeyRevocation)
1352            .visibility(Visibility::Public)
1353            .acdp_version("0.2.0")
1354            .build()
1355            .unwrap();
1356        let prev = body_from_request(&prev_req);
1357        assert!(
1358            KeyRevocation::from_body(&prev).is_err(),
1359            "fixture must actually fail from_body, or this test proves nothing"
1360        );
1361
1362        let req = build_revocation_request(
1363            REVOCATION_OTHER_PRODUCER_DID,
1364            valid_revocation_metadata(),
1365            "0.3.0",
1366        );
1367
1368        check_revocation_supersession(&prev, &req).expect(
1369            "arm 6b: a malformed predecessor skips the class comparison but a \
1370             well-formed key-revocation successor is still allowed",
1371        );
1372    }
1373
1374    // Arm 6b + arm 3: the other half of arm 6b's criterion. The test
1375    // above only proves the signer-class comparison is skipped for a
1376    // malformed predecessor; it does NOT prove arm 3's type rule still
1377    // applies to one. This is the half that carries the security
1378    // weight: an unparseable stored revocation must still not be
1379    // supersedable by an ordinary (non-key-revocation) context.
1380    #[test]
1381    fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
1382        let key = SigningKey::from_bytes(&[0u8; 32]);
1383        let p = Producer::new(
1384            key,
1385            AgentDid::new(REVOCATION_PRODUCER_DID),
1386            format!("{REVOCATION_PRODUCER_DID}#key-1"),
1387        );
1388        let prev_req = p
1389            .publish_request()
1390            .title("Malformed pre-0.3.0 key-revocation (no metadata)")
1391            .context_type(ContextType::KeyRevocation)
1392            .visibility(Visibility::Public)
1393            .acdp_version("0.2.0")
1394            .build()
1395            .unwrap();
1396        let prev = body_from_request(&prev_req);
1397        assert!(
1398            KeyRevocation::from_body(&prev).is_err(),
1399            "fixture must actually fail from_body, or this test proves nothing"
1400        );
1401
1402        let req = test_request(); // ordinary DataSnapshot body, not a key-revocation
1403
1404        let err = check_revocation_supersession(&prev, &req).unwrap_err();
1405        assert!(
1406            matches!(err, AcdpError::SchemaViolation(_)),
1407            "arm 3's type rule must still reject a non-revocation successor even when the \
1408             predecessor is malformed and the class comparison is skipped"
1409        );
1410    }
1411
1412    // Arm 2, isolating CLASS from DID (part 1 of 2): same-DID class
1413    // flip → reject. PREV and IN are published under the exact same
1414    // agent_id (the registry's own DID), so a same-DID criterion would
1415    // treat this as no change and allow it — but the controller field
1416    // differs, flipping the class from ProducerSigned (controller ==
1417    // agent_id, explicit — RFC-ACDP-0014 §5 rule 3) to RegistryAttested
1418    // (controller != agent_id — §6). Both fixtures independently pass
1419    // `check_revocation_controller` (verified below against
1420    // `KeyRevocation::from_parts`'s §5/§6 classification), so this is a
1421    // legitimately admissible pair, not merely abstractly constructible.
1422    #[test]
1423    fn revocation_supersession_same_did_class_flip_rejected() {
1424        let caps = test_caps_v030();
1425        let v = PublishValidator::new(&caps);
1426        let registry_did = caps.registry_did.clone();
1427
1428        let mut prev_meta = valid_revocation_metadata();
1429        prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
1430        let prev_req = build_revocation_request(&registry_did, prev_meta, "0.3.0"); // ProducerSigned (controller == agent_id)
1431        let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
1432        assert_eq!(
1433            prev_revocation.trust_class,
1434            RevocationTrustClass::ProducerSigned
1435        );
1436        v.check_revocation_controller(&prev_req, &prev_revocation)
1437            .expect("PREV fixture must be a legitimately publishable revocation");
1438        let prev = body_from_request(&prev_req);
1439
1440        let mut meta = valid_revocation_metadata();
1441        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1442        let req = build_revocation_request(&registry_did, meta, "0.3.0"); // RegistryAttested (controller != agent_id)
1443        let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
1444        assert_eq!(
1445            in_revocation.trust_class,
1446            RevocationTrustClass::RegistryAttested
1447        );
1448        v.check_revocation_controller(&req, &in_revocation)
1449            .expect("IN fixture must be a legitimately publishable revocation");
1450
1451        let err = check_revocation_supersession(&prev, &req).unwrap_err();
1452        assert!(
1453            matches!(err, AcdpError::SchemaViolation(_)),
1454            "same agent_id on both sides must NOT be enough to allow this supersession — \
1455             the criterion is signer class, not DID"
1456        );
1457    }
1458
1459    // Arm 2, isolating CLASS from DID (part 2 of 2): cross-DID, same
1460    // class → allow. This is RFC-ACDP-0014 §13's cross-producer case,
1461    // currently verified nowhere else: PREV and IN are published under
1462    // different agent_id values (cross-DID) but classify to the same
1463    // signer class (both ProducerSigned, controller absent/defaulted),
1464    // so the supersession must be allowed. Together with the test
1465    // above, this pins the criterion to trust class, not identity.
1466    #[test]
1467    fn revocation_supersession_cross_did_same_class_allowed() {
1468        let prev_req = build_revocation_request(
1469            REVOCATION_PRODUCER_DID,
1470            valid_revocation_metadata(), // no controller ⇒ ProducerSigned
1471            "0.3.0",
1472        );
1473        let prev = body_from_request(&prev_req);
1474
1475        let req = build_revocation_request(
1476            REVOCATION_OTHER_PRODUCER_DID, // different agent_id ⇒ cross-DID
1477            valid_revocation_metadata(),   // no controller ⇒ ProducerSigned
1478            "0.3.0",
1479        );
1480
1481        check_revocation_supersession(&prev, &req).expect(
1482            "cross-DID, same signer class (ProducerSigned) must be allowed — \
1483             RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
1484             class, not DID",
1485        );
1486    }
1487}