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