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::{AgentDid, ContentHash, ContextType, 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 §10: a registry advertising acdp_version >= 0.5.0
217        // MUST reject any *new* publish typed as the interim
218        // `acdp:key-revocation` form outright — unconditionally, whatever
219        // `supersedes` carries or what its target's type is. The interim
220        // form is retired at 0.5.0 in favor of the standard
221        // `key-revocation` context_type; this check must run before the
222        // §4 gate below, since `is_key_revocation()` still treats the
223        // interim form as revocation-equivalent and would otherwise
224        // accept it.
225        if is_interim_key_revocation_form(&req.context_type)
226            && key_revocation_retirement_gate_applies(&self.caps.acdp_version)
227        {
228            return Err(AcdpError::SchemaViolation(format!(
229                "context_type '{}' (the interim key-revocation form) is retired for \
230                 registries advertising acdp_version >= 0.5.0 (RFC-ACDP-0014 §10); \
231                 publish using the standard 'key-revocation' context_type instead",
232                ContextType::KEY_REVOCATION_INTERIM
233            )));
234        }
235
236        // RFC-ACDP-0014 §5 step 2 (self-sign) and §5 rule 3 / §6
237        // (controller binding): version-gated MUSTs with NO §10
238        // interim-form carve-out — that carve-out text is scoped
239        // explicitly to "§4 shape validation" (see the comment on the
240        // standard-type-only gate just below), and says nothing about
241        // §5 or §6. So while a `[0.3.0, 0.5.0)` registry must not
242        // §4-shape-validate the interim `acdp:key-revocation` form, it
243        // still MUST enforce these two identity/trust rules against it.
244        // Run leniently here — tolerant of an otherwise-malformed body,
245        // since this registry has no license to reject the interim form
246        // on shape grounds — so a self-signed or misattributed interim
247        // revocation is still rejected even though its shape is never
248        // fully validated. The standard type gets the same guarantees,
249        // more strictly, from the full §4 gate immediately below, so
250        // this only needs to run for the interim spelling.
251        if is_interim_key_revocation_form(&req.context_type)
252            && key_revocation_gate_applies(&self.caps.acdp_version)
253        {
254            KeyRevocation::check_not_self_signed_did_key_lenient(req)?;
255            self.check_revocation_controller_lenient(req)?;
256        }
257
258        // RFC-ACDP-0014 §4 publish-time gate: registries advertising
259        // acdp_version >= 0.3.0 MUST reject malformed key-revocation
260        // bodies with schema_violation. See `key_revocation_gate_applies`
261        // for the fail-closed polarity on a malformed acdp_version.
262        //
263        // Scoped to the *standard* `key-revocation` context_type only —
264        // deliberately NOT `ContextType::is_key_revocation()`, which also
265        // matches the interim `acdp:key-revocation` custom form. §10 is
266        // explicit that in `[0.3.0, 0.5.0)` a registry "neither rejects
267        // nor §4-validates the interim form": it is architecturally an
268        // opaque custom type there (RFC-ACDP-0002 §5), and this RFC
269        // "deliberately does not extend §4 shape validation to a custom
270        // type." At >= 0.5.0 the interim form is rejected outright by the
271        // retirement gate above, before it can ever reach this check.
272        if matches!(req.context_type, ContextType::KeyRevocation)
273            && key_revocation_gate_applies(&self.caps.acdp_version)
274        {
275            let revocation = KeyRevocation::from_publish_request(req)?;
276            self.check_revocation_controller(req, &revocation)?;
277        }
278
279        // Steps 7–8 (key resolution + signature verification) require async
280        // DID resolution; the caller should invoke Verifier::verify_body for those.
281        Ok(ValidatedPublish {
282            recomputed_hash: recomputed,
283        })
284    }
285
286    /// RFC-ACDP-0014 §4/§6 controller-class rule — the one clause
287    /// `KeyRevocation::from_publish_request` cannot enforce on its own
288    /// because it needs the registry's own identity
289    /// (`caps.registry_did`), which lives only here.
290    ///
291    /// Five arms (§4 makes the controller OPTIONAL — defaulting to
292    /// `agent_id` — on producer-signed revocations; §6 makes it REQUIRED
293    /// and different on registry-attested ones):
294    ///
295    /// 1. absent, `agent_id != registry_did` ⇒ OK (producer-signed, defaulted).
296    /// 2. present, `== agent_id` ⇒ OK (producer-signed, explicit).
297    /// 3. present, `!= agent_id`, `agent_id == registry_did` ⇒ OK (§6 registry-attested).
298    /// 4. present, `!= agent_id`, `agent_id != registry_did` ⇒ `SchemaViolation`.
299    /// 5. absent, `agent_id == registry_did` ⇒ `SchemaViolation` — §4 and §6 step 2
300    ///    both make the controller REQUIRED on registry-attested revocations; without
301    ///    this arm a registry publishing under its own DID with no controller would be
302    ///    silently classified `ProducerSigned` by `from_parts`, i.e. treated as revoking
303    ///    its own key.
304    ///
305    /// Arm 5 is indistinguishable from arm 2 by inspecting the returned
306    /// `KeyRevocation` alone — `from_parts` collapses an absent controller
307    /// to `(agent_id.clone(), ProducerSigned)`, exactly what arm 2
308    /// produces. So presence is read directly off `req.metadata` here,
309    /// not inferred from the parsed struct.
310    fn check_revocation_controller(
311        &self,
312        req: &PublishRequest,
313        revocation: &KeyRevocation,
314    ) -> Result<(), AcdpError> {
315        let controller_present = req
316            .metadata
317            .as_ref()
318            .and_then(|m| m.as_object())
319            .is_some_and(|m| m.contains_key("revoked_key_controller"));
320
321        let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
322        let controller_differs = revocation.revoked_key_controller != req.agent_id;
323
324        if controller_present && controller_differs && !agent_is_registry {
325            // Arm 4.
326            return Err(AcdpError::SchemaViolation(format!(
327                "metadata.revoked_key_controller '{}' differs from agent_id '{}', but \
328                 agent_id is not this registry's own DID ('{}'); a controller different \
329                 from agent_id is only valid on a §6 registry-attested revocation \
330                 (RFC-ACDP-0014 §4, §6)",
331                revocation.revoked_key_controller, req.agent_id, self.caps.registry_did
332            )));
333        }
334
335        if !controller_present && agent_is_registry {
336            // Arm 5.
337            return Err(AcdpError::SchemaViolation(format!(
338                "key-revocation published under this registry's own DID ('{}') has no \
339                 metadata.revoked_key_controller; a registry-attested revocation MUST \
340                 name the affected producer's DID as the controller (RFC-ACDP-0014 §4, §6)",
341                self.caps.registry_did
342            )));
343        }
344
345        Ok(())
346    }
347
348    /// [`Self::check_revocation_controller`]'s arms 4 and 5, decoupled
349    /// from full §4 shape validation — see the interim-form branch in
350    /// [`Self::validate_post_schema`] for why: §5 rule 3 / §6's
351    /// controller-binding obligation has no §10 interim-form carve-out,
352    /// so it must still be enforced against a body this registry is
353    /// otherwise not §4-shape-validating.
354    ///
355    /// Reads `metadata.revoked_key_controller` directly off `req`
356    /// (rather than a parsed [`KeyRevocation`], which the interim form
357    /// deliberately never produces here) and tolerates a value that
358    /// fails to parse as a DID — that's left for full §4 validation
359    /// (standard type) or signature verification to reject; this check
360    /// only fires on an unambiguous arm-4/arm-5 violation. Arms 1–3 need
361    /// no lenient counterpart: they're never a rejection.
362    fn check_revocation_controller_lenient(&self, req: &PublishRequest) -> Result<(), AcdpError> {
363        let controller_raw = req
364            .metadata
365            .as_ref()
366            .and_then(|m| m.as_object())
367            .and_then(|m| m.get("revoked_key_controller"))
368            .and_then(|v| v.as_str());
369
370        let agent_is_registry = req.agent_id.as_str() == self.caps.registry_did;
371
372        match controller_raw {
373            Some(raw) => {
374                let Ok(controller) = AgentDid::parse(raw) else {
375                    return Ok(());
376                };
377                if controller != req.agent_id && !agent_is_registry {
378                    // Arm 4.
379                    return Err(AcdpError::SchemaViolation(format!(
380                        "metadata.revoked_key_controller '{raw}' differs from agent_id '{}', \
381                         but agent_id is not this registry's own DID ('{}'); a controller \
382                         different from agent_id is only valid on a §6 registry-attested \
383                         revocation (RFC-ACDP-0014 §5, §6)",
384                        req.agent_id, self.caps.registry_did
385                    )));
386                }
387                Ok(())
388            }
389            None => {
390                if agent_is_registry {
391                    // Arm 5.
392                    return Err(AcdpError::SchemaViolation(format!(
393                        "key-revocation published under this registry's own DID ('{}') has \
394                         no metadata.revoked_key_controller; a registry-attested revocation \
395                         MUST name the affected producer's DID as the controller \
396                         (RFC-ACDP-0014 §6)",
397                        self.caps.registry_did
398                    )));
399                }
400                Ok(())
401            }
402        }
403    }
404}
405
406/// True when `v` is a well-formed `major.minor.patch` version string:
407/// exactly three non-empty, all-ASCII-digit, dot-separated parts. Mirrors
408/// `acdp_validation::validate_semver_pattern`'s notion of well-formedness
409/// (kept as a private, local copy here rather than a shared export, since
410/// this gate's fail-closed polarity on malformed input is specific to an
411/// admission check and should not be exposed as a general-purpose helper).
412fn is_well_formed_version(v: &str) -> bool {
413    let parts: Vec<&str> = v.split('.').collect();
414    parts.len() == 3
415        && parts
416            .iter()
417            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
418}
419
420/// Parses `major` and `minor` out of a string already confirmed
421/// well-formed by [`is_well_formed_version`] (exactly 3 non-empty,
422/// all-ASCII-digit, dot-separated parts) — so `split('.')` is guaranteed
423/// to yield exactly 3 numeric-looking parts here, and the only way
424/// `str::parse::<u64>` can fail on one is genuine overflow (a digit
425/// string too large for `u64`, e.g. 20+ digits), never a format issue —
426/// `is_well_formed_version` already ruled that out. Saturates to
427/// `u64::MAX` on overflow rather than treating it as "unparseable":
428/// every caller only compares this against small thresholds like `3` or
429/// `5`, so an astronomically large major/minor component must still
430/// compare as astronomically large, not fall through to whatever
431/// fail-closed default the caller uses for a *format* failure — those
432/// are different failure modes and conflating them previously made
433/// `advertises_0_5_0_or_higher` answer `false` for a version that was, if
434/// anything, unambiguously `>= 0.5.0`.
435fn parse_major_minor_saturating(v: &str) -> (u64, u64) {
436    let mut parts = v.split('.');
437    let major = parts.next().unwrap_or("0").parse().unwrap_or(u64::MAX);
438    let minor = parts.next().unwrap_or("0").parse().unwrap_or(u64::MAX);
439    (major, minor)
440}
441
442/// RFC-ACDP-0014 §4 version gate, fail-closed.
443///
444/// A malformed `acdp_version` must turn the gate ON, never OFF. This
445/// checks well-formedness first (exactly three non-empty, all-digit,
446/// dot-separated parts — same criteria as
447/// `acdp_validation::validate_semver_pattern`) before doing any numeric
448/// comparison. Merely counting how many dot-separated parts parse as a
449/// number *anywhere* in the string is not enough: `"0.3x.0"` and
450/// `"0. 3.0"` both contain two parseable numeric parts (`0` and `0`) and
451/// would be silently misread as version `0.0`, and `"0.2.0 "` /
452/// `"0.2.0;"` would be misread as `0.2` — all turning the gate OFF when
453/// it must stay ON for anything that isn't a clean `major.minor.patch`.
454///
455/// `pub` (not merely `pub(crate)`): `RegistryServer::publish_verified_in_tenant`
456/// (server.rs) reuses this exact predicate to gate the §5 step 2
457/// did:web self-sign check on the same version boundary as the §4
458/// shape gate above — a second, independent version-comparison helper
459/// would risk drifting from this one's fail-closed polarity. It is
460/// also the version predicate an external registry implementer needs
461/// to decide whether [`check_revocation_supersession`] applies to a
462/// given publish — the two are promoted to `pub` together so a rule
463/// is never reachable without the gate that decides when to call it.
464pub fn key_revocation_gate_applies(acdp_version: &str) -> bool {
465    if !is_well_formed_version(acdp_version) {
466        return true;
467    }
468    let (major, minor) = parse_major_minor_saturating(acdp_version);
469    major > 0 || minor >= 3
470}
471
472/// True only for the *interim* `acdp:key-revocation` custom `context_type`
473/// — unlike [`ContextType::is_key_revocation`], this excludes the standard
474/// `ContextType::KeyRevocation` form. RFC-ACDP-0014 §10 retires only the
475/// interim spelling at 0.5.0, not the `key-revocation` type itself.
476fn is_interim_key_revocation_form(ct: &ContextType) -> bool {
477    matches!(ct, ContextType::Custom(s) if s == ContextType::KEY_REVOCATION_INTERIM)
478}
479
480/// RFC-ACDP-0014 §10 version gate (interim-form retirement), fail-closed
481/// on the same well-formedness rule as [`key_revocation_gate_applies`] —
482/// see that function's doc comment for why malformed input must turn the
483/// gate ON, never OFF. Threshold is `>= 0.5.0` instead of `>= 0.3.0`.
484///
485/// **Not** reused for §4 Arm 3's error-code selection — see
486/// [`advertises_0_5_0_or_higher`], which answers a related but distinct
487/// question with the opposite polarity on malformed input.
488fn key_revocation_retirement_gate_applies(acdp_version: &str) -> bool {
489    if !is_well_formed_version(acdp_version) {
490        return true;
491    }
492    let (major, minor) = parse_major_minor_saturating(acdp_version);
493    major > 0 || minor >= 5
494}
495
496/// Strictly "is this a well-formed `acdp_version` string that parses to
497/// `>= 0.5.0`" — used only to gate Arm 3's error-code choice in
498/// [`check_revocation_supersession`], and deliberately does **not** fail
499/// closed toward `true` on malformed input the way
500/// [`key_revocation_retirement_gate_applies`] does.
501///
502/// The two functions answer different questions. §10's gate decides
503/// whether to *reject at all*, where fail-closed-toward-rejecting is the
504/// safe direction. This function instead decides which of two rejection
505/// codes to emit — Arm 3 rejects unconditionally either way — and
506/// `registries/error-codes.md` states `revocation_type_mismatch` "MUST
507/// NOT be emitted by implementations declaring `acdp_version < 0.5.0`". A
508/// malformed version string is not a legitimate `>= 0.5.0` declaration, so
509/// this returns `false` on malformed input (falling back to the
510/// long-established `schema_violation` code) rather than `true` — the
511/// conservative choice is the one that can't violate that MUST NOT.
512fn advertises_0_5_0_or_higher(acdp_version: &str) -> bool {
513    if !is_well_formed_version(acdp_version) {
514        return false;
515    }
516    let (major, minor) = parse_major_minor_saturating(acdp_version);
517    major > 0 || minor >= 5
518}
519
520/// RFC-ACDP-0014 §4 `supersedes` row for `key-revocation` contexts.
521///
522/// Verbatim (§4): "A revocation context MAY be superseded only by
523/// another `key-revocation` context from the same signer class (e.g.
524/// to widen — never narrow — the compromise window by moving T
525/// earlier). Consumers MUST treat the earliest `compromised_since`
526/// across a revocation lineage as effective."
527///
528/// This function enforces exactly the *type* and *signer-class*
529/// halves of that sentence — nothing else. It does NOT compare
530/// `compromised_since` in either direction: the RFC's "widen, never
531/// narrow" clause is illustrative of *why* a producer would supersede
532/// a revocation, not an additional publish-time constraint — per §4:58
533/// the monotonicity protection belongs on the consumer side, as the
534/// earliest-T rule. [`acdp_types::revocation::effective_boundary`]
535/// implements that fold correctly, and — as of issue #226 — assembling
536/// its input from a registry is wired end-to-end on the consumer side:
537/// `acdp_client::revocation::{find_revocations, find_registry_attested_revocations,
538/// find_revocations_in_lineage}` each walk a candidate's full lineage
539/// (via `GET /lineages/{id}`, including superseded and retracted
540/// members) rather than trusting a single search-visible one, so a
541/// consumer that feeds `effective_boundary`'s input from one of those
542/// helpers gets the earliest-T guarantee genuinely, not merely
543/// aspirationally. Nothing about that consumer-side guarantee changes
544/// this function's own scope, which stays deliberately narrow: gating
545/// the *publish-time* direction too (rejecting a narrowing
546/// `compromised_since` here) would let an attacker who has learned a
547/// key is compromised deny its legitimate producer the ability to
548/// publish a corrected, earlier-T revocation superseding a prior one
549/// that understated the window — RFC-ACDP-0014 §4:58's normative verb
550/// ("Consumers MUST …") already places the monotonicity obligation on
551/// the consumer side, not the publish path.
552///
553/// "Signer class" is [`acdp_types::revocation::RevocationTrustClass`]
554/// (`ProducerSigned` vs. `RegistryAttested`) — **not** same-DID; RFC-ACDP-0014
555/// §13 explicitly blesses cross-producer registry-attested revocations
556/// superseding one another.
557///
558/// Caller contract (this function does NOT re-derive these on its
559/// own):
560/// - `prev` is the current, non-superseded version of the lineage the
561///   incoming request's `supersedes` names — the store has already
562///   confirmed the target exists, belongs to the same tenant, is
563///   owned by the requester, and is not already superseded (§4's "arm
564///   5" concerns, entirely outside this function's scope).
565/// - Call this only when [`key_revocation_gate_applies`] returns
566///   `true` for the registry's advertised `acdp_version` — pre-0.3.0
567///   registries have no `key-revocation` vocabulary to enforce this
568///   against.
569/// - `acdp_version` is the registry's own advertised version (i.e. the
570///   same string passed to [`key_revocation_gate_applies`] above) — used
571///   only to pick Arm 3's error code, per RFC-ACDP-0014 §10.
572///
573/// Arms (see the Phase 5 plan for the full table):
574///
575/// **Arm 1** — `prev` key-revocation, `req` key-revocation, same class
576/// ⇒ `Ok` (regardless of `compromised_since` direction — arm 6 is just
577/// a special case of this).
578///
579/// **Arm 2** — `prev` key-revocation, `req` key-revocation, different
580/// class ⇒ `SchemaViolation`.
581///
582/// **Arm 3** — `prev` key-revocation, `req` NOT a key-revocation ⇒
583/// `SupersededTarget`/`RevocationTypeMismatch` at `acdp_version >= 0.5.0`,
584/// `SchemaViolation` below it (RFC-ACDP-0014 §10) — the security payload:
585/// without this, the holder of a compromised key could re-point the
586/// lineage head away from the revocation with an ordinary body, since
587/// #207's §5 step 2 not-self-signed check only fires for
588/// `is_key_revocation()` bodies.
589///
590/// **Arm 4** — `prev` NOT a key-revocation ⇒ `Ok` unconditionally —
591/// out of scope for this §4 row; whatever `req` is, nothing here
592/// constrains it.
593///
594/// **Arm 6b** — `prev` is (interim-form) a key-revocation but
595/// `KeyRevocation::from_body(prev)` fails to parse (a malformed
596/// pre-0.3.0-stored body) ⇒ arm 3's type rule still applies (`req`
597/// must be a key-revocation), but the signer-class comparison is
598/// skipped since there is no parsed `prev` class to compare against —
599/// allow. This arm is unreachable on a ≥ 0.3.0 registry: every publish
600/// path routes through `validate_post_schema`, which runs
601/// `KeyRevocation::from_publish_request(req)?` when
602/// [`key_revocation_gate_applies`] is true, and `Body::from_publish_request`
603/// (`acdp_types::body`) copies verbatim the exact five fields
604/// `KeyRevocation::from_parts` reads — so a `Body` stored through that
605/// path always has `from_body(stored) ≡ from_publish_request(req)`,
606/// meaning `from_body` cannot fail there either. A future normalizing
607/// change to `Body` that broke that equivalence would turn this arm
608/// into a live escape hatch — see the inline comment at the match arm
609/// below.
610pub fn check_revocation_supersession(
611    prev: &Body,
612    req: &PublishRequest,
613    acdp_version: &str,
614) -> Result<(), AcdpError> {
615    if !prev.context_type.is_key_revocation() {
616        // Arm 4: whatever `prev` is, this §4 row does not constrain
617        // its supersession.
618        return Ok(());
619    }
620
621    if !req.context_type.is_key_revocation() {
622        // Arm 3: the security payload. `prev` is a safety broadcast;
623        // only another key-revocation may take over its lineage head.
624        //
625        // RFC-ACDP-0014 §10 (0.5.0 registry amendments): a registry
626        // advertising acdp_version >= 0.5.0 MUST reject this case with
627        // SupersededTarget/revocation_type_mismatch instead of the
628        // historical schema_violation. The rejection itself is
629        // deliberately unconditional on the version — only the wire
630        // code changes below 0.5.0 (see this plan's Open Questions for
631        // why weakening the rejection itself below 0.5.0 would be a
632        // security regression, not spec compliance). Uses
633        // `advertises_0_5_0_or_higher`, NOT the §10 gate above — a
634        // malformed `acdp_version` must still reject (fail-closed) but
635        // must NOT claim the new, more specific error code.
636        if advertises_0_5_0_or_higher(acdp_version) {
637            return Err(AcdpError::SupersededTarget {
638                reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
639                message: format!(
640                    "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
641                     another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
642                     from agent_id '{}' has type '{}'",
643                    prev.ctx_id,
644                    req.agent_id,
645                    context_type_label(&req.context_type),
646                ),
647            });
648        }
649        return Err(AcdpError::SchemaViolation(format!(
650            "ctx_id '{}' is a key-revocation context and MAY only be superseded by \
651             another key-revocation context (RFC-ACDP-0014 §4); the incoming publish \
652             from agent_id '{}' has type '{}'",
653            prev.ctx_id,
654            req.agent_id,
655            context_type_label(&req.context_type),
656        )));
657    }
658
659    // Both PREV and IN are key-revocations. Arms 1/2/6/6b turn on the
660    // signer class, which requires parsing PREV's metadata.
661    let prev_revocation = match KeyRevocation::from_body(prev) {
662        Ok(r) => r,
663        Err(_) => {
664            // Arm 6b: PREV was stored as a key-revocation (by type) but
665            // does not shape-validate today — most plausibly a
666            // pre-0.3.0 body admitted before this rule existed. Arm 3's
667            // type rule already passed above; there is no parsed class
668            // to compare IN against, so allow rather than fail closed
669            // on a predecessor this function did not admit.
670            //
671            // Load-bearing equivalence: on ≥ 0.3.0 this branch is
672            // unreachable, because `from_body(stored) ≡
673            // from_publish_request(req)` — `Body::from_publish_request`
674            // copies verbatim the same five fields
675            // `KeyRevocation::from_parts` reads, and the publish gate
676            // already required `from_publish_request` to succeed. If a
677            // future change to `Body::from_publish_request` ever stops
678            // copying one of those fields verbatim, this arm silently
679            // becomes reachable again as an allow-anything escape
680            // hatch for a well-formed stored revocation.
681            return Ok(());
682        }
683    };
684
685    let incoming_revocation = KeyRevocation::from_publish_request(req)?;
686
687    if prev_revocation.trust_class == incoming_revocation.trust_class {
688        // Arms 1 and 6: same signer class, any `compromised_since`
689        // direction.
690        Ok(())
691    } else {
692        // Arm 2: signer class changed across the supersession.
693        Err(AcdpError::SchemaViolation(format!(
694            "ctx_id '{}' is a key-revocation with signer class {:?}; the incoming \
695             supersession from agent_id '{}' is a key-revocation with signer class {:?} \
696             — a revocation MAY only be superseded by another key-revocation from the \
697             same signer class (RFC-ACDP-0014 §4)",
698            prev.ctx_id, prev_revocation.trust_class, req.agent_id, incoming_revocation.trust_class,
699        )))
700    }
701}
702
703/// Human-readable label for a [`acdp_types::primitives::ContextType`]
704/// for use in error messages only (mirrors the `serde_json` round-trip
705/// `acdp_types::revocation` already uses for the same purpose).
706fn context_type_label(context_type: &acdp_types::primitives::ContextType) -> String {
707    serde_json::to_value(context_type)
708        .ok()
709        .and_then(|v| v.as_str().map(str::to_owned))
710        .unwrap_or_else(|| "<unrepresentable>".into())
711}
712
713/// Assign registry identifiers after successful validation per
714/// RFC-ACDP-0001 §5.6.
715///
716/// For first-version publications (`supersedes == None`,
717/// `first_version_ctx_id == None`), `lineage_id` is derived from the newly
718/// assigned `ctx_id`. For supersession (`supersedes == Some(_)`), the
719/// caller MUST supply the v1 `ctx_id` of the lineage so `lineage_id` is
720/// derived from it — using the new ctx_id would orphan the supersession
721/// from its lineage.
722///
723/// Returns `SchemaViolation` if `supersedes` is set but
724/// `first_version_ctx_id` is not.
725pub fn assign_identifiers(
726    authority: &str,
727    supersedes: &Option<CtxId>,
728    first_version_ctx_id: Option<&CtxId>,
729    _validated: &ValidatedPublish,
730) -> Result<(CtxId, LineageId), AcdpError> {
731    let uuid = uuid::Uuid::new_v4();
732    let ctx_id = CtxId(format!("acdp://{authority}/{uuid}"));
733    let lineage_source: &CtxId = match (supersedes, first_version_ctx_id) {
734        (None, _) => &ctx_id,
735        (Some(_), Some(v1)) => v1,
736        (Some(_), None) => {
737            return Err(AcdpError::SchemaViolation(
738                "supersession assignment requires the v1 ctx_id to derive lineage_id".into(),
739            ));
740        }
741    };
742    let lineage_id = derive_lineage_id(lineage_source);
743    Ok((ctx_id, lineage_id))
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use acdp_crypto::SigningKey;
750    use acdp_producer::Producer;
751    use acdp_types::{
752        capabilities::Limits, primitives::Visibility, revocation::RevocationTrustClass,
753    };
754
755    fn test_caps() -> CapabilitiesDocument {
756        CapabilitiesDocument {
757            acdp_version: "0.1.0".into(),
758            registry_did: "did:web:registry.example.com".into(),
759            supported_signature_algorithms: vec!["ed25519".into()],
760            supported_did_methods: vec!["did:web".into()],
761            profiles: vec!["acdp-registry-core".into()],
762            limits: Limits {
763                max_payload_bytes: 1_048_576,
764                max_embedded_bytes: 65_536,
765                idempotency_key_ttl_seconds: None,
766                max_publish_per_minute: None,
767            },
768            read_authentication_methods: vec![],
769            anonymous_public_reads: true,
770            supports_idempotency_key: false,
771            extensions: Default::default(),
772        }
773    }
774
775    fn test_request() -> PublishRequest {
776        let key = SigningKey::from_bytes(&[0u8; 32]);
777        let p = Producer::new(
778            key,
779            AgentDid::new("did:web:agents.example.com:test-producer"),
780            "did:web:agents.example.com:test-producer#key-1",
781        );
782        p.publish_request()
783            .title("Golden test vector — minimal first version")
784            .context_type(ContextType::DataSnapshot)
785            .visibility(Visibility::Public)
786            .build()
787            .unwrap()
788    }
789
790    #[test]
791    fn happy_path_validates() {
792        let caps = test_caps();
793        let v = PublishValidator::new(&caps);
794        let req = test_request();
795        let raw_len = serde_json::to_vec(&req).unwrap().len();
796        v.validate_post_schema(&req, raw_len).unwrap();
797    }
798
799    #[test]
800    fn payload_too_large_rejected() {
801        let mut caps = test_caps();
802        caps.limits.max_payload_bytes = 10;
803        let v = PublishValidator::new(&caps);
804        let req = test_request();
805        let err = v.validate_post_schema(&req, 1024).unwrap_err();
806        assert!(matches!(err, AcdpError::SchemaViolation(_)));
807    }
808
809    #[test]
810    fn unsupported_algorithm_rejected() {
811        let mut caps = test_caps();
812        caps.supported_signature_algorithms = vec!["secp256k1".into()];
813        let v = PublishValidator::new(&caps);
814        let req = test_request();
815        let err = v.validate_post_schema(&req, 1024).unwrap_err();
816        assert!(matches!(err, AcdpError::SchemaViolation(_)));
817    }
818
819    #[test]
820    fn key_id_without_fragment_rejected() {
821        let caps = test_caps();
822        let v = PublishValidator::new(&caps);
823        let mut req = test_request();
824        req.signature.key_id = "did:web:agents.example.com:test-producer".into();
825        let err = v.validate_post_schema(&req, 1024).unwrap_err();
826        assert!(matches!(err, AcdpError::KeyResolution(_)));
827    }
828
829    #[test]
830    fn key_id_did_must_match_agent_id() {
831        let caps = test_caps();
832        let v = PublishValidator::new(&caps);
833        let mut req = test_request();
834        req.signature.key_id = "did:web:other.example.com:attacker#key-1".into();
835        let err = v.validate_post_schema(&req, 1024).unwrap_err();
836        assert!(matches!(err, AcdpError::KeyNotAuthorized(_)));
837    }
838
839    #[test]
840    fn tampered_hash_detected() {
841        let caps = test_caps();
842        let v = PublishValidator::new(&caps);
843        let mut req = test_request();
844        req.title = "tampered title".into();
845        let err = v.validate_post_schema(&req, 1024).unwrap_err();
846        assert!(matches!(err, AcdpError::HashMismatch { .. }));
847    }
848
849    #[test]
850    fn assign_identifiers_first_version_derives_lineage_from_new_id() {
851        let v = ValidatedPublish {
852            recomputed_hash: ContentHash("sha256:abcd".into()),
853        };
854        let (ctx_id, lineage_id) =
855            assign_identifiers("registry.example.com", &None, None, &v).unwrap();
856        let expected = derive_lineage_id(&ctx_id);
857        assert_eq!(lineage_id, expected);
858    }
859
860    #[test]
861    fn assign_identifiers_supersession_uses_v1_ctx_id() {
862        let v = ValidatedPublish {
863            recomputed_hash: ContentHash("sha256:abcd".into()),
864        };
865        let v1 = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
866        let supersedes = Some(CtxId(
867            "acdp://registry.example.com/12345678-1234-4321-8123-123456781299".into(),
868        ));
869        let (_new_id, lineage_id) =
870            assign_identifiers("registry.example.com", &supersedes, Some(&v1), &v).unwrap();
871        assert_eq!(lineage_id, derive_lineage_id(&v1));
872    }
873
874    #[test]
875    fn cross_registry_supersession_rejected() {
876        let caps = test_caps();
877        let v = PublishValidator::for_authority(&caps, "registry.example.com");
878        // Build a v2 request that supersedes a context on a different registry
879        let key = SigningKey::from_bytes(&[0u8; 32]);
880        let p = Producer::new(
881            key,
882            AgentDid::new("did:web:agents.example.com:test-producer"),
883            "did:web:agents.example.com:test-producer#key-1",
884        );
885        let other_reg =
886            CtxId("acdp://other.example.com/12345678-1234-4321-8123-123456781234".into());
887        let req = p
888            .supersede(other_reg)
889            .version(2)
890            .title("v2")
891            .context_type(ContextType::DataSnapshot)
892            .build()
893            .unwrap();
894        let raw_len = serde_json::to_vec(&req).unwrap().len();
895        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
896        match err {
897            AcdpError::SupersededTarget { reason, .. } => {
898                assert_eq!(
899                    reason,
900                    acdp_primitives::error::SupersessionReason::CrossRegistrySupersessionUnsupported
901                );
902            }
903            other => panic!("expected SupersededTarget, got {other:?}"),
904        }
905    }
906
907    #[test]
908    fn same_registry_supersession_passes_authority_check() {
909        let caps = test_caps();
910        let v = PublishValidator::for_authority(&caps, "registry.example.com");
911        let key = SigningKey::from_bytes(&[0u8; 32]);
912        let p = Producer::new(
913            key,
914            AgentDid::new("did:web:agents.example.com:test-producer"),
915            "did:web:agents.example.com:test-producer#key-1",
916        );
917        let same = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
918        let req = p
919            .supersede(same)
920            .version(2)
921            .title("v2")
922            .context_type(ContextType::DataSnapshot)
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 assign_identifiers_supersession_without_v1_id_rejected() {
931        let v = ValidatedPublish {
932            recomputed_hash: ContentHash("sha256:abcd".into()),
933        };
934        let supersedes = Some(CtxId("acdp://x/y".into()));
935        let err = assign_identifiers("registry.example.com", &supersedes, None, &v).unwrap_err();
936        assert!(matches!(err, AcdpError::SchemaViolation(_)));
937    }
938
939    // ── Phase 6: RFC-ACDP-0014 §4 key-revocation publish-time gate ─────
940
941    fn test_caps_v030() -> CapabilitiesDocument {
942        CapabilitiesDocument {
943            acdp_version: "0.3.0".into(),
944            ..test_caps()
945        }
946    }
947
948    fn test_caps_v020() -> CapabilitiesDocument {
949        CapabilitiesDocument {
950            acdp_version: "0.2.0".into(),
951            ..test_caps()
952        }
953    }
954
955    fn test_caps_v050() -> CapabilitiesDocument {
956        CapabilitiesDocument {
957            acdp_version: "0.5.0".into(),
958            ..test_caps()
959        }
960    }
961
962    const REVOCATION_PRODUCER_DID: &str = "did:web:agents.example.com:test-producer";
963    const REVOCATION_OTHER_PRODUCER_DID: &str = "did:web:agents.example.com:other-producer";
964    const REVOCATION_FP: &str =
965        "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
966    const REVOCATION_SINCE: &str = "2026-05-01T00:00:00.000Z";
967
968    fn valid_revocation_metadata() -> serde_json::Value {
969        serde_json::json!({
970            "revoked_key_fingerprint": REVOCATION_FP,
971            "compromised_since": REVOCATION_SINCE,
972        })
973    }
974
975    /// Builds a signed `key-revocation` `PublishRequest` published under
976    /// `agent_did`, with the given `metadata` and wire `acdp_version`
977    /// field (independent of the *registry's* `caps.acdp_version` under
978    /// test).
979    fn build_revocation_request(
980        agent_did: &str,
981        metadata: serde_json::Value,
982        acdp_version: &str,
983    ) -> PublishRequest {
984        let key = SigningKey::from_bytes(&[0u8; 32]);
985        let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
986        p.publish_request()
987            .title("Key revocation test")
988            .context_type(ContextType::KeyRevocation)
989            .visibility(Visibility::Public)
990            .acdp_version(acdp_version)
991            .metadata(metadata)
992            .build()
993            .unwrap()
994    }
995
996    // Arm 1: controller absent, agent_id != registry_did ⇒ OK
997    // (producer-signed, defaulted).
998    #[test]
999    fn revocation_arm1_absent_controller_accepted_at_0_3_0() {
1000        let caps = test_caps_v030();
1001        let v = PublishValidator::new(&caps);
1002        let req = build_revocation_request(
1003            REVOCATION_PRODUCER_DID,
1004            valid_revocation_metadata(),
1005            "0.3.0",
1006        );
1007        let raw_len = serde_json::to_vec(&req).unwrap().len();
1008        v.validate_post_schema(&req, raw_len).unwrap();
1009    }
1010
1011    // Arm 2: controller present and == agent_id ⇒ OK (producer-signed,
1012    // explicit).
1013    #[test]
1014    fn revocation_arm2_explicit_matching_controller_accepted_at_0_3_0() {
1015        let caps = test_caps_v030();
1016        let v = PublishValidator::new(&caps);
1017        let mut meta = valid_revocation_metadata();
1018        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1019        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1020        let raw_len = serde_json::to_vec(&req).unwrap().len();
1021        v.validate_post_schema(&req, raw_len).unwrap();
1022    }
1023
1024    // Arm 3: controller present, != agent_id, agent_id == registry_did ⇒
1025    // OK (§6 registry-attested).
1026    #[test]
1027    fn revocation_arm3_registry_attested_accepted_at_0_3_0() {
1028        let caps = test_caps_v030();
1029        let registry_did = caps.registry_did.clone();
1030        let v = PublishValidator::new(&caps);
1031        let mut meta = valid_revocation_metadata();
1032        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1033        let req = build_revocation_request(&registry_did, meta, "0.3.0");
1034        let raw_len = serde_json::to_vec(&req).unwrap().len();
1035        v.validate_post_schema(&req, raw_len).unwrap();
1036    }
1037
1038    // Arm 4: controller present, != agent_id, agent_id != registry_did ⇒
1039    // SchemaViolation.
1040    #[test]
1041    fn revocation_arm4_mismatched_controller_rejected_at_0_3_0() {
1042        let caps = test_caps_v030();
1043        let v = PublishValidator::new(&caps);
1044        let mut meta = valid_revocation_metadata();
1045        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1046        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1047        let raw_len = serde_json::to_vec(&req).unwrap().len();
1048        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1049        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1050    }
1051
1052    #[test]
1053    fn revocation_arm4_accepted_at_0_2_0_positive_control() {
1054        let caps = test_caps_v020();
1055        let v = PublishValidator::new(&caps);
1056        let mut meta = valid_revocation_metadata();
1057        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_OTHER_PRODUCER_DID);
1058        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1059        let raw_len = serde_json::to_vec(&req).unwrap().len();
1060        v.validate_post_schema(&req, raw_len).unwrap();
1061    }
1062
1063    // Arm 5 — the one everyone misses: controller absent, agent_id ==
1064    // registry_did ⇒ SchemaViolation. Indistinguishable from arm 1/2 by
1065    // inspecting the returned `KeyRevocation` alone (`from_parts`
1066    // collapses an absent controller to `(agent_id.clone(),
1067    // ProducerSigned)`), so the gate must read presence off
1068    // `req.metadata` directly.
1069    #[test]
1070    fn revocation_arm5_absent_controller_under_registry_did_rejected_at_0_3_0() {
1071        let caps = test_caps_v030();
1072        let registry_did = caps.registry_did.clone();
1073        let v = PublishValidator::new(&caps);
1074        let req = build_revocation_request(&registry_did, valid_revocation_metadata(), "0.3.0");
1075        let raw_len = serde_json::to_vec(&req).unwrap().len();
1076        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1077        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1078    }
1079
1080    #[test]
1081    fn revocation_arm5_accepted_at_0_2_0_positive_control() {
1082        let caps = test_caps_v020();
1083        let registry_did = caps.registry_did.clone();
1084        let v = PublishValidator::new(&caps);
1085        let req = build_revocation_request(&registry_did, valid_revocation_metadata(), "0.3.0");
1086        let raw_len = serde_json::to_vec(&req).unwrap().len();
1087        v.validate_post_schema(&req, raw_len).unwrap();
1088    }
1089
1090    #[test]
1091    fn revocation_non_public_visibility_rejected_at_0_3_0() {
1092        let caps = test_caps_v030();
1093        let v = PublishValidator::new(&caps);
1094        let key = SigningKey::from_bytes(&[0u8; 32]);
1095        let p = Producer::new(
1096            key,
1097            AgentDid::new(REVOCATION_PRODUCER_DID),
1098            format!("{REVOCATION_PRODUCER_DID}#key-1"),
1099        );
1100        let req = p
1101            .publish_request()
1102            .title("Key revocation test")
1103            .context_type(ContextType::KeyRevocation)
1104            .visibility(Visibility::Restricted)
1105            .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1106            .acdp_version("0.3.0")
1107            .metadata(valid_revocation_metadata())
1108            .build()
1109            .unwrap();
1110        let raw_len = serde_json::to_vec(&req).unwrap().len();
1111        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1112        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1113    }
1114
1115    #[test]
1116    fn revocation_non_public_visibility_accepted_at_0_2_0_positive_control() {
1117        let caps = test_caps_v020();
1118        let v = PublishValidator::new(&caps);
1119        let key = SigningKey::from_bytes(&[0u8; 32]);
1120        let p = Producer::new(
1121            key,
1122            AgentDid::new(REVOCATION_PRODUCER_DID),
1123            format!("{REVOCATION_PRODUCER_DID}#key-1"),
1124        );
1125        let req = p
1126            .publish_request()
1127            .title("Key revocation test")
1128            .context_type(ContextType::KeyRevocation)
1129            .visibility(Visibility::Restricted)
1130            .audience(vec![AgentDid::new(REVOCATION_PRODUCER_DID)])
1131            .acdp_version("0.3.0")
1132            .metadata(valid_revocation_metadata())
1133            .build()
1134            .unwrap();
1135        let raw_len = serde_json::to_vec(&req).unwrap().len();
1136        v.validate_post_schema(&req, raw_len).unwrap();
1137    }
1138
1139    #[test]
1140    fn revocation_missing_fingerprint_rejected_at_0_3_0() {
1141        let caps = test_caps_v030();
1142        let v = PublishValidator::new(&caps);
1143        let mut meta = valid_revocation_metadata();
1144        meta.as_object_mut()
1145            .unwrap()
1146            .remove("revoked_key_fingerprint");
1147        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1148        let raw_len = serde_json::to_vec(&req).unwrap().len();
1149        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1150        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1151    }
1152
1153    #[test]
1154    fn revocation_missing_fingerprint_accepted_at_0_2_0_positive_control() {
1155        let caps = test_caps_v020();
1156        let v = PublishValidator::new(&caps);
1157        let mut meta = valid_revocation_metadata();
1158        meta.as_object_mut()
1159            .unwrap()
1160            .remove("revoked_key_fingerprint");
1161        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1162        let raw_len = serde_json::to_vec(&req).unwrap().len();
1163        v.validate_post_schema(&req, raw_len).unwrap();
1164    }
1165
1166    #[test]
1167    fn revocation_missing_compromised_since_rejected_at_0_3_0() {
1168        let caps = test_caps_v030();
1169        let v = PublishValidator::new(&caps);
1170        let mut meta = valid_revocation_metadata();
1171        meta.as_object_mut().unwrap().remove("compromised_since");
1172        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1173        let raw_len = serde_json::to_vec(&req).unwrap().len();
1174        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1175        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1176    }
1177
1178    #[test]
1179    fn revocation_missing_compromised_since_accepted_at_0_2_0_positive_control() {
1180        let caps = test_caps_v020();
1181        let v = PublishValidator::new(&caps);
1182        let mut meta = valid_revocation_metadata();
1183        meta.as_object_mut().unwrap().remove("compromised_since");
1184        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1185        let raw_len = serde_json::to_vec(&req).unwrap().len();
1186        v.validate_post_schema(&req, raw_len).unwrap();
1187    }
1188
1189    #[test]
1190    fn revocation_malformed_fingerprint_rejected_at_0_3_0() {
1191        let caps = test_caps_v030();
1192        let v = PublishValidator::new(&caps);
1193        let mut meta = valid_revocation_metadata();
1194        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1195        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1196        let raw_len = serde_json::to_vec(&req).unwrap().len();
1197        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1198        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1199    }
1200
1201    #[test]
1202    fn revocation_malformed_fingerprint_accepted_at_0_2_0_positive_control() {
1203        let caps = test_caps_v020();
1204        let v = PublishValidator::new(&caps);
1205        let mut meta = valid_revocation_metadata();
1206        meta["revoked_key_fingerprint"] = serde_json::json!("not-a-fingerprint");
1207        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1208        let raw_len = serde_json::to_vec(&req).unwrap().len();
1209        v.validate_post_schema(&req, raw_len).unwrap();
1210    }
1211
1212    #[test]
1213    fn revocation_non_canonical_compromised_since_rejected_at_0_3_0() {
1214        let caps = test_caps_v030();
1215        let v = PublishValidator::new(&caps);
1216        let mut meta = valid_revocation_metadata();
1217        meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1218        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1219        let raw_len = serde_json::to_vec(&req).unwrap().len();
1220        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1221        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1222    }
1223
1224    #[test]
1225    fn revocation_non_canonical_compromised_since_accepted_at_0_2_0_positive_control() {
1226        let caps = test_caps_v020();
1227        let v = PublishValidator::new(&caps);
1228        let mut meta = valid_revocation_metadata();
1229        meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00Z");
1230        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1231        let raw_len = serde_json::to_vec(&req).unwrap().len();
1232        v.validate_post_schema(&req, raw_len).unwrap();
1233    }
1234
1235    #[test]
1236    fn revocation_reason_over_limit_rejected_at_0_3_0() {
1237        let caps = test_caps_v030();
1238        let v = PublishValidator::new(&caps);
1239        let mut meta = valid_revocation_metadata();
1240        meta["reason"] =
1241            serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1242        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1243        let raw_len = serde_json::to_vec(&req).unwrap().len();
1244        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1245        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1246    }
1247
1248    #[test]
1249    fn revocation_reason_over_limit_accepted_at_0_2_0_positive_control() {
1250        let caps = test_caps_v020();
1251        let v = PublishValidator::new(&caps);
1252        let mut meta = valid_revocation_metadata();
1253        meta["reason"] =
1254            serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS + 1));
1255        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1256        let raw_len = serde_json::to_vec(&req).unwrap().len();
1257        v.validate_post_schema(&req, raw_len).unwrap();
1258    }
1259
1260    // rev-003 scenario N: the bound is INCLUSIVE — a reason of EXACTLY
1261    // `MAX_REASON_CHARS` MUST be accepted at 0.3.0. Paired with the
1262    // rejection test above (which uses `MAX_REASON_CHARS + 1`); without
1263    // this positive control a registry using `>= MAX_REASON_CHARS`
1264    // instead of `>` would pass the rejection test above while wrongly
1265    // rejecting exactly this length — the off-by-one this pair exists
1266    // to catch.
1267    #[test]
1268    fn revocation_reason_at_exactly_the_limit_accepted_at_0_3_0() {
1269        let caps = test_caps_v030();
1270        let v = PublishValidator::new(&caps);
1271        let mut meta = valid_revocation_metadata();
1272        meta["reason"] = serde_json::json!("x".repeat(acdp_types::revocation::MAX_REASON_CHARS));
1273        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1274        let raw_len = serde_json::to_vec(&req).unwrap().len();
1275        v.validate_post_schema(&req, raw_len)
1276            .expect("a reason of exactly MAX_REASON_CHARS must be accepted, not rejected");
1277    }
1278
1279    // Malformed `acdp_version` must turn the gate ON (fail closed), not
1280    // off — `key_revocation_gate_applies` treats anything that is not a
1281    // well-formed `major.minor.patch` string as malformed rather than
1282    // reinterpreting it as some other version.
1283    #[test]
1284    fn revocation_gate_fails_closed_on_unparseable_acdp_version() {
1285        let mut caps = test_caps_v030();
1286        caps.acdp_version = "not-a-version".into();
1287        let v = PublishValidator::new(&caps);
1288        let mut meta = valid_revocation_metadata();
1289        meta.as_object_mut()
1290            .unwrap()
1291            .remove("revoked_key_fingerprint");
1292        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1293        let raw_len = serde_json::to_vec(&req).unwrap().len();
1294        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1295        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1296    }
1297
1298    #[test]
1299    fn revocation_gate_fails_closed_on_empty_acdp_version() {
1300        let mut caps = test_caps_v030();
1301        caps.acdp_version = "".into();
1302        let v = PublishValidator::new(&caps);
1303        let mut meta = valid_revocation_metadata();
1304        meta.as_object_mut()
1305            .unwrap()
1306            .remove("revoked_key_fingerprint");
1307        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1308        let raw_len = serde_json::to_vec(&req).unwrap().len();
1309        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1310        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1311    }
1312
1313    // Non-key-revocation bodies are entirely unaffected by the gate,
1314    // even under a 0.3.0 registry.
1315    #[test]
1316    fn non_key_revocation_body_unaffected_by_gate_at_0_3_0() {
1317        let caps = test_caps_v030();
1318        let v = PublishValidator::new(&caps);
1319        let req = test_request();
1320        let raw_len = serde_json::to_vec(&req).unwrap().len();
1321        v.validate_post_schema(&req, raw_len).unwrap();
1322    }
1323
1324    // `key_revocation_gate_applies` well-formedness truth table.
1325    //
1326    // The gate must NOT silently reinterpret a malformed `acdp_version`
1327    // string as whatever version its first two parseable numeric
1328    // fragments happen to spell — that reinterpretation is exactly the
1329    // bug being fixed here. Every entry left of `=>` is malformed (or,
1330    // for the last three rows, well-formed-and-comparable) and the
1331    // right-hand side is the required gate outcome.
1332    #[test]
1333    fn key_revocation_gate_truth_table() {
1334        let cases: &[(&str, bool)] = &[
1335            // Malformed: a typo'd patch segment must not be silently
1336            // read as "0.3" truncated down to "0.0".
1337            ("0.3x.0", true),
1338            // Malformed: an embedded space breaks the numeric parse of
1339            // that segment, and must not be read as "0.0".
1340            ("0. 3.0", true),
1341            // Malformed: trailing whitespace/punctuation after a
1342            // perfectly-formed "0.2.0" must not let the first two
1343            // fragments ("0", "2") stand in for the whole string.
1344            ("0.2.0 ", true),
1345            ("0.2.0;", true),
1346            // Malformed: a non-numeric minor segment.
1347            ("0.x.1", true),
1348            // Malformed: a unicode digit (ARABIC-INDIC THREE, U+0663)
1349            // fails `char::is_ascii_digit`, so this segment is not
1350            // ASCII-digit-only and the whole string is not well-formed.
1351            ("0.\u{0663}.0", true),
1352            // Already-covered malformed cases, kept here too so the
1353            // whole table lives in one place.
1354            ("not-a-version", true),
1355            ("", true),
1356            // Well-formed and >= 0.3.0 ⇒ gate ON.
1357            ("0.3.0", true),
1358            ("0.4.0", true),
1359            ("1.0.0", true),
1360            // Well-formed and < 0.3.0 ⇒ gate OFF.
1361            ("0.2.9", false),
1362            ("0.2.0", false),
1363        ];
1364        for (input, expected) in cases {
1365            assert_eq!(
1366                key_revocation_gate_applies(input),
1367                *expected,
1368                "input {input:?} should gate {}",
1369                if *expected { "ON" } else { "OFF" }
1370            );
1371        }
1372    }
1373
1374    /// Same shape as `key_revocation_gate_truth_table`, but exercising
1375    /// the two 0.5.0-threshold gates side by side on the same inputs —
1376    /// including edge cases the original 4-assertion coverage for these
1377    /// two functions never reached: leading zeros, a pre-release/build
1378    /// suffix on an otherwise well-formed patch segment, a 4th version
1379    /// component, and a major segment too large to fit in `u64` (only
1380    /// `is_well_formed_version`'s narrower "all ASCII digits" check gates
1381    /// entry to the numeric comparison — it says nothing about range).
1382    /// Each row states both gates' expected outcome, since they
1383    /// deliberately disagree on malformed input (opposite fail-closed
1384    /// polarity — see both functions' doc comments) and this table is
1385    /// exactly where that disagreement should be visible at a glance.
1386    #[test]
1387    fn zero_five_zero_threshold_gates_truth_table() {
1388        let cases: &[(&str, bool, bool)] = &[
1389            // Well-formed, on both sides of the 0.5.0 line.
1390            ("0.5.0", true, true),
1391            ("0.4.9", false, false),
1392            ("1.0.0", true, true),
1393            ("0.5.1", true, true),
1394            // Leading zeros: "0.05.0"/"000.005.000" are still all-ASCII-digit
1395            // per-segment, so `is_well_formed_version` accepts them, and
1396            // `str::parse::<u64>` reads leading zeros as ordinary decimal
1397            // (05 == 5) — both gates must read these exactly like "0.5.0".
1398            ("0.05.0", true, true),
1399            ("000.005.000", true, true),
1400            // A pre-release/build suffix on the patch segment is not an
1401            // all-ASCII-digit segment, so the whole string is malformed —
1402            // both gates must disagree with their usual opposite polarity.
1403            ("0.5.0-alpha", true, false),
1404            // A 4th component makes `split('.')` yield 4 parts, failing
1405            // the `parts.len() == 3` check — malformed, same as above.
1406            ("0.5.0.1", true, false),
1407            // A major segment with far more digits than `u64` can hold.
1408            // This is *not* the same failure mode as a non-digit segment:
1409            // it is syntactically well-formed (all ASCII digits) and
1410            // numerically unambiguous — enormously larger than any real
1411            // threshold in this file — so it must NOT fall through to
1412            // either gate's "malformed" fallback. Both read it as
1413            // unambiguously >= 0.5.0.
1414            ("99999999999999999999.0.0", true, true),
1415        ];
1416        for (input, retirement_expected, advertises_expected) in cases {
1417            assert_eq!(
1418                key_revocation_retirement_gate_applies(input),
1419                *retirement_expected,
1420                "§10 retirement gate: input {input:?} should gate {}",
1421                if *retirement_expected { "ON" } else { "OFF" }
1422            );
1423            assert_eq!(
1424                advertises_0_5_0_or_higher(input),
1425                *advertises_expected,
1426                "advertises_0_5_0_or_higher: input {input:?} should be {advertises_expected}"
1427            );
1428        }
1429    }
1430
1431    /// Like `build_revocation_request`, but lets the test pick the
1432    /// `ContextType` — used to publish the RFC-ACDP-0014 §10 interim
1433    /// `acdp:key-revocation` custom form through the gate, since
1434    /// `build_revocation_request` always uses the standard
1435    /// `ContextType::KeyRevocation`.
1436    fn build_revocation_request_with_type(
1437        agent_did: &str,
1438        metadata: serde_json::Value,
1439        acdp_version: &str,
1440        context_type: ContextType,
1441    ) -> PublishRequest {
1442        let key = SigningKey::from_bytes(&[0u8; 32]);
1443        let p = Producer::new(key, AgentDid::new(agent_did), format!("{agent_did}#key-1"));
1444        p.publish_request()
1445            .title("Key revocation test (interim §10 type)")
1446            .context_type(context_type)
1447            .visibility(Visibility::Public)
1448            .acdp_version(acdp_version)
1449            .metadata(metadata)
1450            .build()
1451            .unwrap()
1452    }
1453
1454    // §10: a >= 0.3.0 (and < 0.5.0) registry treats the interim
1455    // `acdp:key-revocation` custom type as an ordinary, architecturally
1456    // opaque custom context_type (RFC-ACDP-0002 §5) — it does NOT apply
1457    // §4 shape validation to it. §10 states this explicitly: "Registries
1458    // advertising acdp_version in [0.3.0, 0.5.0) neither reject nor
1459    // §4-validate the interim form: ... this RFC deliberately does not
1460    // extend §4 shape validation to a custom type." The gate is keyed off
1461    // `ContextType::KeyRevocation` specifically, not
1462    // `ContextType::is_key_revocation()`, so the interim form never
1463    // reaches `KeyRevocation::from_publish_request` in this window.
1464    #[test]
1465    fn revocation_interim_custom_type_valid_body_accepted_at_0_3_0() {
1466        let caps = test_caps_v030();
1467        let v = PublishValidator::new(&caps);
1468        let req = build_revocation_request_with_type(
1469            REVOCATION_PRODUCER_DID,
1470            valid_revocation_metadata(),
1471            "0.3.0",
1472            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1473        );
1474        let raw_len = serde_json::to_vec(&req).unwrap().len();
1475        v.validate_post_schema(&req, raw_len).unwrap();
1476    }
1477
1478    // Same §10 opaque-custom-type treatment applies even when the body
1479    // would fail §4 shape validation under the standard type — a
1480    // `[0.3.0, 0.5.0)` registry has no basis to inspect the interim
1481    // form's metadata shape at all, so a "violation" here is not
1482    // observable at this version boundary (issue #295).
1483    #[test]
1484    fn revocation_interim_custom_type_malformed_body_accepted_at_0_3_0() {
1485        let caps = test_caps_v030();
1486        let v = PublishValidator::new(&caps);
1487        let mut meta = valid_revocation_metadata();
1488        meta.as_object_mut()
1489            .unwrap()
1490            .remove("revoked_key_fingerprint");
1491        let req = build_revocation_request_with_type(
1492            REVOCATION_PRODUCER_DID,
1493            meta,
1494            "0.3.0",
1495            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1496        );
1497        let raw_len = serde_json::to_vec(&req).unwrap().len();
1498        v.validate_post_schema(&req, raw_len).unwrap();
1499    }
1500
1501    fn did_key_producer_fixture(seed: [u8; 32]) -> (SigningKey, String, String, String) {
1502        let key = SigningKey::from_bytes(&seed);
1503        let public_key = key.verifying_key_bytes();
1504        let did = acdp_did::key::did_key_from_ed25519(&public_key);
1505        let key_id = acdp_did::key::did_key_url(&did).unwrap();
1506        let fingerprint = acdp_crypto::fingerprint::fingerprint_ed25519(&public_key);
1507        (key, did, key_id, fingerprint)
1508    }
1509
1510    fn caps_v030_with_did_key() -> CapabilitiesDocument {
1511        CapabilitiesDocument {
1512            acdp_version: "0.3.0".into(),
1513            supported_did_methods: vec!["did:web".into(), "did:key".into()],
1514            ..test_caps()
1515        }
1516    }
1517
1518    // Regression for a bug introduced by the #295 fix itself: narrowing
1519    // the §4 gate to the standard type only (so it stops calling
1520    // `KeyRevocation::from_publish_request`/`from_parts` for the
1521    // interim form) also silently dropped `from_parts`'s embedded
1522    // did:key §5-step-2 self-sign sub-check for that form — since §5
1523    // has no §10 interim-form carve-out (unlike §4), that's a real
1524    // regression, not a side effect of the fix's actual scope. Restored
1525    // via `check_not_self_signed_did_key_lenient` in the interim-form
1526    // branch above.
1527    #[test]
1528    fn revocation_interim_custom_type_did_key_self_signed_rejected_at_0_3_0() {
1529        let (key, did, key_id, fingerprint) = did_key_producer_fixture([9u8; 32]);
1530        let mut meta = valid_revocation_metadata();
1531        meta["revoked_key_fingerprint"] = serde_json::json!(fingerprint);
1532
1533        let req = Producer::new(key, AgentDid::new(&did), key_id)
1534            .publish_request()
1535            .title("self-signed interim revocation")
1536            .context_type(ContextType::Custom(
1537                ContextType::KEY_REVOCATION_INTERIM.into(),
1538            ))
1539            .visibility(Visibility::Public)
1540            .acdp_version("0.3.0")
1541            .metadata(meta)
1542            .build()
1543            .unwrap();
1544
1545        let caps = caps_v030_with_did_key();
1546        let v = PublishValidator::new(&caps);
1547        let raw_len = serde_json::to_vec(&req).unwrap().len();
1548        assert!(matches!(
1549            v.validate_post_schema(&req, raw_len),
1550            Err(AcdpError::KeyNotAuthorized(_))
1551        ));
1552    }
1553
1554    // Positive control for the test above: same did:key interim-form
1555    // shape, but the signing key's fingerprint differs from the
1556    // revoked one — accepted. Without this, the negative test could be
1557    // passing for an unrelated reason (e.g. did:key producers being
1558    // rejected outright on the interim form).
1559    #[test]
1560    fn revocation_interim_custom_type_did_key_different_key_accepted_at_0_3_0() {
1561        let (key, did, key_id, _fingerprint) = did_key_producer_fixture([10u8; 32]);
1562        // valid_revocation_metadata's fingerprint is all-'a', unrelated
1563        // to the [10u8; 32] key.
1564        let meta = valid_revocation_metadata();
1565
1566        let req = Producer::new(key, AgentDid::new(&did), key_id)
1567            .publish_request()
1568            .title("non-self-signed interim revocation")
1569            .context_type(ContextType::Custom(
1570                ContextType::KEY_REVOCATION_INTERIM.into(),
1571            ))
1572            .visibility(Visibility::Public)
1573            .acdp_version("0.3.0")
1574            .metadata(meta)
1575            .build()
1576            .unwrap();
1577
1578        let caps = caps_v030_with_did_key();
1579        let v = PublishValidator::new(&caps);
1580        let raw_len = serde_json::to_vec(&req).unwrap().len();
1581        v.validate_post_schema(&req, raw_len)
1582            .expect("did:key signer whose fingerprint differs from the revoked key must pass");
1583    }
1584
1585    // Same regression class as the self-sign test above, but for §5
1586    // rule 3 / §6's controller-binding obligation (arm 4): a
1587    // `revoked_key_controller` naming neither the publisher nor a
1588    // registry-attested relationship must still be rejected on the
1589    // interim form, even though this registry never §4-shape-validates
1590    // it. Restored via `check_revocation_controller_lenient`.
1591    #[test]
1592    fn revocation_interim_custom_type_mismatched_controller_rejected_at_0_3_0() {
1593        let mut meta = valid_revocation_metadata();
1594        meta["revoked_key_controller"] = serde_json::json!("did:web:someone-else.example.com");
1595        let req = build_revocation_request_with_type(
1596            REVOCATION_PRODUCER_DID,
1597            meta,
1598            "0.3.0",
1599            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1600        );
1601        let caps = test_caps_v030();
1602        let v = PublishValidator::new(&caps);
1603        let raw_len = serde_json::to_vec(&req).unwrap().len();
1604        assert!(matches!(
1605            v.validate_post_schema(&req, raw_len),
1606            Err(AcdpError::SchemaViolation(_))
1607        ));
1608    }
1609
1610    // Positive control: a registry-attested interim revocation (agent_id
1611    // is this registry's own DID, controller names the affected
1612    // producer) is still accepted — arm 3, not arm 4/5.
1613    #[test]
1614    fn revocation_interim_custom_type_registry_attested_controller_accepted_at_0_3_0() {
1615        let caps = test_caps_v030();
1616        let mut meta = valid_revocation_metadata();
1617        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1618        let req = build_revocation_request_with_type(
1619            &caps.registry_did,
1620            meta,
1621            "0.3.0",
1622            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1623        );
1624        let v = PublishValidator::new(&caps);
1625        let raw_len = serde_json::to_vec(&req).unwrap().len();
1626        v.validate_post_schema(&req, raw_len)
1627            .expect("registry-attested interim revocation with a named controller must pass");
1628    }
1629
1630    // Arm 5 on the interim form: published under the registry's own DID
1631    // with NO controller at all must still be rejected — §6 makes the
1632    // controller REQUIRED on a registry-attested revocation, no §10
1633    // carve-out applies.
1634    #[test]
1635    fn revocation_interim_custom_type_registry_attested_missing_controller_rejected_at_0_3_0() {
1636        let caps = test_caps_v030();
1637        let meta = valid_revocation_metadata();
1638        let req = build_revocation_request_with_type(
1639            &caps.registry_did,
1640            meta,
1641            "0.3.0",
1642            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1643        );
1644        let v = PublishValidator::new(&caps);
1645        let raw_len = serde_json::to_vec(&req).unwrap().len();
1646        assert!(matches!(
1647            v.validate_post_schema(&req, raw_len),
1648            Err(AcdpError::SchemaViolation(_))
1649        ));
1650    }
1651
1652    // ── Phase 4 (#279+RFC-0014-wave): RFC-ACDP-0014 §10 — interim-form
1653    // retirement at acdp_version >= 0.5.0. ───────────────────────────────
1654
1655    // §10: a >= 0.5.0 registry rejects a *new* publish typed as the
1656    // interim `acdp:key-revocation` form outright, even though the body
1657    // is otherwise perfectly valid (same fixture that's accepted at 0.3.0
1658    // above) and carries no `supersedes` at all (acceptance criterion 4).
1659    #[test]
1660    fn revocation_interim_custom_type_rejected_unconditionally_at_0_5_0() {
1661        let caps = test_caps_v050();
1662        let v = PublishValidator::new(&caps);
1663        let req = build_revocation_request_with_type(
1664            REVOCATION_PRODUCER_DID,
1665            valid_revocation_metadata(),
1666            "0.5.0",
1667            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1668        );
1669        let raw_len = serde_json::to_vec(&req).unwrap().len();
1670        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1671        assert!(
1672            matches!(err, AcdpError::SchemaViolation(_)),
1673            "the interim form must be rejected unconditionally at >= 0.5.0, got {err:?}"
1674        );
1675    }
1676
1677    // §10's own gate must independently fail closed on a malformed
1678    // `acdp_version`, matching §4's `key_revocation_gate_applies` — this
1679    // was previously only inferred from the two functions sharing an
1680    // identical well-formedness check (`is_well_formed_version`), never
1681    // exercised directly against `key_revocation_retirement_gate_applies`.
1682    #[test]
1683    fn interim_form_retirement_gate_fails_closed_on_malformed_acdp_version() {
1684        let mut caps = test_caps_v050();
1685        caps.acdp_version = "not-a-version".into();
1686        let v = PublishValidator::new(&caps);
1687        let req = build_revocation_request_with_type(
1688            REVOCATION_PRODUCER_DID,
1689            valid_revocation_metadata(),
1690            "0.5.0",
1691            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1692        );
1693        let raw_len = serde_json::to_vec(&req).unwrap().len();
1694        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1695        assert!(
1696            matches!(err, AcdpError::SchemaViolation(_)),
1697            "a malformed acdp_version must fail closed toward retiring the interim form, got {err:?}"
1698        );
1699    }
1700
1701    // §10, the other half of acceptance criterion 4: the interim form is
1702    // rejected unconditionally regardless of whether the publish carries a
1703    // `supersedes` target — unlike Arm 3, this is a flat retirement of the
1704    // *type*, not a supersession rule, so it must reject even a v2
1705    // interim-form publish superseding a v1 interim-form context.
1706    #[test]
1707    fn revocation_interim_custom_type_rejected_with_supersedes_at_0_5_0() {
1708        let caps = test_caps_v050();
1709        let v = PublishValidator::new(&caps);
1710        let key = SigningKey::from_bytes(&[0u8; 32]);
1711        let p = Producer::new(
1712            key,
1713            AgentDid::new(REVOCATION_PRODUCER_DID),
1714            format!("{REVOCATION_PRODUCER_DID}#key-1"),
1715        );
1716        let target =
1717            CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000002".into());
1718        let req = p
1719            .supersede(target)
1720            .version(2)
1721            .title("Key revocation test (interim §10 type, v2 supersedes)")
1722            .context_type(ContextType::Custom(
1723                ContextType::KEY_REVOCATION_INTERIM.into(),
1724            ))
1725            .visibility(Visibility::Public)
1726            .acdp_version("0.5.0")
1727            .metadata(valid_revocation_metadata())
1728            .build()
1729            .unwrap();
1730        let raw_len = serde_json::to_vec(&req).unwrap().len();
1731        let err = v.validate_post_schema(&req, raw_len).unwrap_err();
1732        assert!(
1733            matches!(err, AcdpError::SchemaViolation(_)),
1734            "the interim form must be rejected even when it carries a supersedes target, got {err:?}"
1735        );
1736    }
1737
1738    // §10 does not over-reject: the *standard* `key-revocation`
1739    // context_type (not the interim custom spelling) must still be
1740    // accepted at acdp_version >= 0.5.0 — only the interim spelling is
1741    // retired (acceptance criterion 5, standard-form half).
1742    #[test]
1743    fn revocation_standard_type_still_accepted_at_0_5_0() {
1744        let caps = test_caps_v050();
1745        let v = PublishValidator::new(&caps);
1746        let req = build_revocation_request(
1747            REVOCATION_PRODUCER_DID,
1748            valid_revocation_metadata(),
1749            "0.5.0",
1750        );
1751        let raw_len = serde_json::to_vec(&req).unwrap().len();
1752        v.validate_post_schema(&req, raw_len).expect(
1753            "the standard key-revocation type is not retired by §10, only the interim spelling is",
1754        );
1755    }
1756
1757    // ── Phase 5 (#216a): RFC-ACDP-0014 §4 `supersedes` rule —
1758    // `check_revocation_supersession`. Dead code until Phase 6 wires it
1759    // in; these tests exercise it directly. ─────────────────────────────
1760
1761    /// Materializes the `Body` a registry would have stored from `req`,
1762    /// so `check_revocation_supersession`'s `prev: &Body` parameter can
1763    /// be exercised without a real store.
1764    fn body_from_request(req: &PublishRequest) -> Body {
1765        Body::from_publish_request(
1766            req,
1767            CtxId("acdp://registry.example.com/00000000-0000-4000-8000-000000000001".into()),
1768            LineageId(format!("lin:sha256:{}", "0".repeat(64))),
1769            "registry.example.com",
1770            chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00.000Z")
1771                .unwrap()
1772                .with_timezone(&chrono::Utc),
1773        )
1774    }
1775
1776    // Arm 1: PREV key-revocation, IN key-revocation, SAME signer class
1777    // ⇒ allow, regardless of `compromised_since` direction — here IN's
1778    // T is EARLIER than PREV's.
1779    #[test]
1780    fn revocation_supersession_same_class_allowed_t_earlier() {
1781        let prev_req = build_revocation_request(
1782            REVOCATION_PRODUCER_DID,
1783            valid_revocation_metadata(), // T = 2026-05-01
1784            "0.3.0",
1785        );
1786        let prev = body_from_request(&prev_req);
1787
1788        let mut meta = valid_revocation_metadata();
1789        meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); // earlier
1790        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1791
1792        check_revocation_supersession(&prev, &req, "0.3.0")
1793            .expect("same signer class supersession must be allowed regardless of T direction");
1794    }
1795
1796    // Arm 2: PREV key-revocation, IN key-revocation, DIFFERENT signer
1797    // class (producer-signed → registry-attested) ⇒ reject
1798    // SchemaViolation.
1799    #[test]
1800    fn revocation_supersession_different_class_rejected() {
1801        let prev_req = build_revocation_request(
1802            REVOCATION_PRODUCER_DID,
1803            valid_revocation_metadata(), // no controller ⇒ ProducerSigned
1804            "0.3.0",
1805        );
1806        let prev = body_from_request(&prev_req);
1807
1808        let registry_did = test_caps().registry_did;
1809        let mut meta = valid_revocation_metadata();
1810        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
1811        let req = build_revocation_request(&registry_did, meta, "0.3.0"); // RegistryAttested
1812
1813        let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1814        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1815    }
1816
1817    // Arm 3: PREV key-revocation, IN NOT a key-revocation ⇒ reject
1818    // SchemaViolation. The security payload: without this, the holder
1819    // of the compromised key could re-point the lineage head away from
1820    // its own revocation with an ordinary body.
1821    #[test]
1822    fn revocation_superseded_by_non_revocation_rejected() {
1823        let prev_req = build_revocation_request(
1824            REVOCATION_PRODUCER_DID,
1825            valid_revocation_metadata(),
1826            "0.3.0",
1827        );
1828        let prev = body_from_request(&prev_req);
1829        let req = test_request(); // ordinary DataSnapshot body
1830
1831        let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
1832        assert!(matches!(err, AcdpError::SchemaViolation(_)));
1833    }
1834
1835    // Arm 3, RFC-ACDP-0014 §10: identical fixture to the test above, but at
1836    // a registry advertising acdp_version >= 0.5.0 — the wire code changes
1837    // to SupersededTarget/RevocationTypeMismatch, the rejection itself
1838    // unchanged (Phase 4 acceptance criterion 2).
1839    #[test]
1840    fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_at_0_5_0() {
1841        let prev_req = build_revocation_request(
1842            REVOCATION_PRODUCER_DID,
1843            valid_revocation_metadata(),
1844            "0.3.0",
1845        );
1846        let prev = body_from_request(&prev_req);
1847        let req = test_request(); // ordinary DataSnapshot body
1848
1849        let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
1850        assert!(
1851            matches!(
1852                err,
1853                AcdpError::SupersededTarget {
1854                    reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
1855                    ..
1856                }
1857            ),
1858            "expected SupersededTarget/RevocationTypeMismatch at acdp_version >= 0.5.0, got {err:?}"
1859        );
1860    }
1861
1862    // Same fixture again, one version short of the 0.5.0 boundary — pins
1863    // the exact threshold (Phase 4 acceptance criterion 3: unchanged below
1864    // 0.5.0).
1865    #[test]
1866    fn revocation_superseded_by_non_revocation_still_schema_violation_below_0_5_0() {
1867        let prev_req = build_revocation_request(
1868            REVOCATION_PRODUCER_DID,
1869            valid_revocation_metadata(),
1870            "0.3.0",
1871        );
1872        let prev = body_from_request(&prev_req);
1873        let req = test_request(); // ordinary DataSnapshot body
1874
1875        let err = check_revocation_supersession(&prev, &req, "0.4.9").unwrap_err();
1876        assert!(
1877            matches!(err, AcdpError::SchemaViolation(_)),
1878            "0.4.9 is below the 0.5.0 boundary; expected the unchanged SchemaViolation, got {err:?}"
1879        );
1880    }
1881
1882    // rev-003 scenario P: identical to O above, except PREV is published
1883    // under the RFC-ACDP-0014 §10 INTERIM `acdp:key-revocation` form
1884    // rather than the standard type. `check_revocation_supersession`'s
1885    // Arm 3 gate is `prev.context_type.is_key_revocation()`, which
1886    // treats both forms as equivalent triggering predecessor types — an
1887    // implementation that special-cases the standard type string and
1888    // misses the interim one would pass O while failing here.
1889    #[test]
1890    fn revocation_superseded_by_non_revocation_rejected_as_revocation_type_mismatch_interim_predecessor_at_0_5_0(
1891    ) {
1892        let prev_req = build_revocation_request_with_type(
1893            REVOCATION_PRODUCER_DID,
1894            valid_revocation_metadata(),
1895            "0.3.0",
1896            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into()),
1897        );
1898        let prev = body_from_request(&prev_req);
1899        let req = test_request(); // ordinary DataSnapshot body
1900
1901        let err = check_revocation_supersession(&prev, &req, "0.5.0").unwrap_err();
1902        assert!(
1903            matches!(
1904                err,
1905                AcdpError::SupersededTarget {
1906                    reason: acdp_primitives::error::SupersessionReason::RevocationTypeMismatch,
1907                    ..
1908                }
1909            ),
1910            "an interim-typed predecessor must trigger the same rejection as a \
1911             standard-typed one, got {err:?}"
1912        );
1913    }
1914
1915    // rev-003 scenario R: the positive control pinning that the (0.5.0)
1916    // predecessor-keyed rule does not over-reject the legitimate case —
1917    // a key-revocation properly superseding a key-revocation, widening
1918    // the boundary — specifically AT the 0.5.0 boundary itself.
1919    // `revocation_supersession_same_class_allowed_t_earlier` above pins
1920    // the identical shape but only at 0.3.0; without a dedicated 0.5.0
1921    // test, a registry that (incorrectly) rejected every supersession of
1922    // a key-revocation target once acdp_version >= 0.5.0 — not only
1923    // non-revocation ones — would pass O/P for the wrong reason
1924    // (over-rejection) and nothing here would catch it.
1925    #[test]
1926    fn revocation_supersession_same_class_allowed_at_0_5_0() {
1927        let prev_req = build_revocation_request(
1928            REVOCATION_PRODUCER_DID,
1929            valid_revocation_metadata(), // T = 2026-05-01
1930            "0.5.0",
1931        );
1932        let prev = body_from_request(&prev_req);
1933
1934        let mut meta = valid_revocation_metadata();
1935        meta["compromised_since"] = serde_json::json!("2026-04-01T00:00:00.000Z"); // earlier, widening
1936        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.5.0");
1937
1938        check_revocation_supersession(&prev, &req, "0.5.0").expect(
1939            "a same-class key-revocation supersession must still be allowed at 0.5.0 — \
1940             the (0.5.0) rule targets non-revocation successors only",
1941        );
1942    }
1943
1944    // Arm 4: PREV NOT a key-revocation ⇒ allow unconditionally,
1945    // whatever IN is — out of scope for this §4 row (RFC §4 constrains
1946    // only what may supersede a revocation, not what a revocation may
1947    // supersede).
1948    #[test]
1949    fn non_revocation_predecessor_superseded_by_revocation_allowed() {
1950        let prev_req = test_request();
1951        let prev = body_from_request(&prev_req);
1952        let req = build_revocation_request(
1953            REVOCATION_PRODUCER_DID,
1954            valid_revocation_metadata(),
1955            "0.3.0",
1956        );
1957
1958        check_revocation_supersession(&prev, &req, "0.3.0")
1959            .expect("a non-revocation predecessor is out of scope for this §4 row");
1960    }
1961
1962    // Arm 6: same code path as arm 1, but exercising the genuinely
1963    // distinct direction — NARROWING the compromise window by moving T
1964    // LATER (arm 1 already covers "same class, T earlier"; a test that
1965    // also moves T earlier would just be arm 1 again). This is the arm
1966    // carrying the intentional residual risk: `check_revocation_supersession`
1967    // does not compare `compromised_since` direction at all, so a
1968    // narrowing supersession is allowed at publish. That is
1969    // spec-correct per §4:58 (the monotonicity protection belongs on
1970    // the consumer side via `effective_boundary`) and, as of issue
1971    // #226, that consumer-side guarantee is now wired end-to-end —
1972    // `acdp_client::revocation::find_revocations` /
1973    // `find_registry_attested_revocations` / `find_revocations_in_lineage`
1974    // walk the full lineage (superseded and retracted members
1975    // included) rather than trusting a single search-visible member —
1976    // see the doc comment above `check_revocation_supersession`.
1977    #[test]
1978    fn revocation_supersession_same_class_narrowing_t_allowed_at_publish() {
1979        let mut prev_meta = valid_revocation_metadata();
1980        prev_meta["compromised_since"] = serde_json::json!("2026-05-01T00:00:00.000Z");
1981        let prev_req = build_revocation_request(REVOCATION_PRODUCER_DID, prev_meta, "0.3.0");
1982        let prev = body_from_request(&prev_req);
1983
1984        let mut meta = valid_revocation_metadata();
1985        meta["compromised_since"] = serde_json::json!("2026-06-01T00:00:00.000Z"); // later — narrows
1986        let req = build_revocation_request(REVOCATION_PRODUCER_DID, meta, "0.3.0");
1987
1988        check_revocation_supersession(&prev, &req, "0.3.0").expect(
1989            "narrowing the compromise window (T moved later) is allowed at publish time; \
1990             this function enforces only type + signer class, not compromised_since \
1991             direction (RFC-ACDP-0014 §4:58)",
1992        );
1993    }
1994
1995    // Arm 6b: PREV is a key-revocation by type but its stored body
1996    // fails `KeyRevocation::from_body` (malformed pre-0.3.0 body with
1997    // no metadata object at all) ⇒ arm 3's type rule still applies (IN
1998    // must be a key-revocation) but the signer-class comparison is
1999    // skipped since there is no parsed PREV class to compare.
2000    #[test]
2001    fn revocation_supersession_malformed_predecessor_skips_class_comparison() {
2002        let key = SigningKey::from_bytes(&[0u8; 32]);
2003        let p = Producer::new(
2004            key,
2005            AgentDid::new(REVOCATION_PRODUCER_DID),
2006            format!("{REVOCATION_PRODUCER_DID}#key-1"),
2007        );
2008        let prev_req = p
2009            .publish_request()
2010            .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2011            .context_type(ContextType::KeyRevocation)
2012            .visibility(Visibility::Public)
2013            .acdp_version("0.2.0")
2014            .build()
2015            .unwrap();
2016        let prev = body_from_request(&prev_req);
2017        assert!(
2018            KeyRevocation::from_body(&prev).is_err(),
2019            "fixture must actually fail from_body, or this test proves nothing"
2020        );
2021
2022        let req = build_revocation_request(
2023            REVOCATION_OTHER_PRODUCER_DID,
2024            valid_revocation_metadata(),
2025            "0.3.0",
2026        );
2027
2028        check_revocation_supersession(&prev, &req, "0.3.0").expect(
2029            "arm 6b: a malformed predecessor skips the class comparison but a \
2030             well-formed key-revocation successor is still allowed",
2031        );
2032    }
2033
2034    // Arm 6b + arm 3: the other half of arm 6b's criterion. The test
2035    // above only proves the signer-class comparison is skipped for a
2036    // malformed predecessor; it does NOT prove arm 3's type rule still
2037    // applies to one. This is the half that carries the security
2038    // weight: an unparseable stored revocation must still not be
2039    // supersedable by an ordinary (non-key-revocation) context.
2040    #[test]
2041    fn revocation_supersession_malformed_predecessor_still_blocks_non_revocation_successor() {
2042        let key = SigningKey::from_bytes(&[0u8; 32]);
2043        let p = Producer::new(
2044            key,
2045            AgentDid::new(REVOCATION_PRODUCER_DID),
2046            format!("{REVOCATION_PRODUCER_DID}#key-1"),
2047        );
2048        let prev_req = p
2049            .publish_request()
2050            .title("Malformed pre-0.3.0 key-revocation (no metadata)")
2051            .context_type(ContextType::KeyRevocation)
2052            .visibility(Visibility::Public)
2053            .acdp_version("0.2.0")
2054            .build()
2055            .unwrap();
2056        let prev = body_from_request(&prev_req);
2057        assert!(
2058            KeyRevocation::from_body(&prev).is_err(),
2059            "fixture must actually fail from_body, or this test proves nothing"
2060        );
2061
2062        let req = test_request(); // ordinary DataSnapshot body, not a key-revocation
2063
2064        let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2065        assert!(
2066            matches!(err, AcdpError::SchemaViolation(_)),
2067            "arm 3's type rule must still reject a non-revocation successor even when the \
2068             predecessor is malformed and the class comparison is skipped"
2069        );
2070    }
2071
2072    // Arm 2, isolating CLASS from DID (part 1 of 2): same-DID class
2073    // flip → reject. PREV and IN are published under the exact same
2074    // agent_id (the registry's own DID), so a same-DID criterion would
2075    // treat this as no change and allow it — but the controller field
2076    // differs, flipping the class from ProducerSigned (controller ==
2077    // agent_id, explicit — RFC-ACDP-0014 §5 rule 3) to RegistryAttested
2078    // (controller != agent_id — §6). Both fixtures independently pass
2079    // `check_revocation_controller` (verified below against
2080    // `KeyRevocation::from_parts`'s §5/§6 classification), so this is a
2081    // legitimately admissible pair, not merely abstractly constructible.
2082    #[test]
2083    fn revocation_supersession_same_did_class_flip_rejected() {
2084        let caps = test_caps_v030();
2085        let v = PublishValidator::new(&caps);
2086        let registry_did = caps.registry_did.clone();
2087
2088        let mut prev_meta = valid_revocation_metadata();
2089        prev_meta["revoked_key_controller"] = serde_json::json!(registry_did);
2090        let prev_req = build_revocation_request(&registry_did, prev_meta, "0.3.0"); // ProducerSigned (controller == agent_id)
2091        let prev_revocation = KeyRevocation::from_publish_request(&prev_req).unwrap();
2092        assert_eq!(
2093            prev_revocation.trust_class,
2094            RevocationTrustClass::ProducerSigned
2095        );
2096        v.check_revocation_controller(&prev_req, &prev_revocation)
2097            .expect("PREV fixture must be a legitimately publishable revocation");
2098        let prev = body_from_request(&prev_req);
2099
2100        let mut meta = valid_revocation_metadata();
2101        meta["revoked_key_controller"] = serde_json::json!(REVOCATION_PRODUCER_DID);
2102        let req = build_revocation_request(&registry_did, meta, "0.3.0"); // RegistryAttested (controller != agent_id)
2103        let in_revocation = KeyRevocation::from_publish_request(&req).unwrap();
2104        assert_eq!(
2105            in_revocation.trust_class,
2106            RevocationTrustClass::RegistryAttested
2107        );
2108        v.check_revocation_controller(&req, &in_revocation)
2109            .expect("IN fixture must be a legitimately publishable revocation");
2110
2111        let err = check_revocation_supersession(&prev, &req, "0.3.0").unwrap_err();
2112        assert!(
2113            matches!(err, AcdpError::SchemaViolation(_)),
2114            "same agent_id on both sides must NOT be enough to allow this supersession — \
2115             the criterion is signer class, not DID"
2116        );
2117    }
2118
2119    // Arm 2, isolating CLASS from DID (part 2 of 2): cross-DID, same
2120    // class → allow. This is RFC-ACDP-0014 §13's cross-producer case,
2121    // currently verified nowhere else: PREV and IN are published under
2122    // different agent_id values (cross-DID) but classify to the same
2123    // signer class (both ProducerSigned, controller absent/defaulted),
2124    // so the supersession must be allowed. Together with the test
2125    // above, this pins the criterion to trust class, not identity.
2126    #[test]
2127    fn revocation_supersession_cross_did_same_class_allowed() {
2128        let prev_req = build_revocation_request(
2129            REVOCATION_PRODUCER_DID,
2130            valid_revocation_metadata(), // no controller ⇒ ProducerSigned
2131            "0.3.0",
2132        );
2133        let prev = body_from_request(&prev_req);
2134
2135        let req = build_revocation_request(
2136            REVOCATION_OTHER_PRODUCER_DID, // different agent_id ⇒ cross-DID
2137            valid_revocation_metadata(),   // no controller ⇒ ProducerSigned
2138            "0.3.0",
2139        );
2140
2141        check_revocation_supersession(&prev, &req, "0.3.0").expect(
2142            "cross-DID, same signer class (ProducerSigned) must be allowed — \
2143             RFC-ACDP-0014 §13 blesses cross-producer supersession; the criterion is \
2144             class, not DID",
2145        );
2146    }
2147}