Skip to main content

acdp_client/
revocation_verdict.rs

1//! Typed, attributable revocation verdicts (RFC-ACDP-0014 §7/§8, 0.5.0
2//! "Attribution", "Producer scope", "Discovery outcome classes",
3//! "Caching").
4//!
5//! One assessment runs for every verification that applies a
6//! [`RevocationPolicy`]: [`VerifiedContext`](crate::VerifiedContext)'s
7//! `fetch*` family and [`find_revocations_verdict`] share it, so a re-check
8//! sees the same known revocations, cache, discovery budget, and failure
9//! policy as the original verification.
10//!
11//! The assessment has three outcomes, never collapsed:
12//!
13//! - [`RevocationVerdict::Revoked`] — a verified, in-scope revocation names
14//!   the signing key; [`RevocationEffect`] says which §7 step applied.
15//! - [`RevocationVerdict::NotRevoked`] — none does. `complete` says whether
16//!   every configured discovery lookup finished; `observed_at` reports a
17//!   cached absence's age.
18//! - [`RevocationVerdict::Unknown`] — discovery could not finish, for a
19//!   typed [`UnknownReason`]. `unknown` is never treated as not-revoked and
20//!   is never cached.
21
22use std::time::{Duration, SystemTime};
23
24use acdp_did::WebResolver;
25use acdp_primitives::error::AcdpError;
26use acdp_types::primitives::AgentDid;
27use acdp_types::receipt::ReceiptKeyStatus;
28use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
29use chrono::{DateTime, Utc};
30
31pub use acdp_types::revocation::{
32    RevocationEffect, RevocationRefusal, RevocationSource, SourcedRevocation, UnknownReason,
33};
34
35use crate::registry::{DiscoveryBudget, RegistryClient};
36use crate::revocation::{find_registry_attested_lookup, find_revocations_lookup, Lookup};
37use crate::verified::{DiscoveryFailurePolicy, DiscoveryOutcome, RevocationPolicy};
38
39/// RFC-ACDP-0014 §8 "Caching" RECOMMENDED bound on how long a cached
40/// complete absence (a negative) may suppress a discovery lookup: 300 s.
41/// Both [`RevocationDiscovery`](crate::RevocationDiscovery) named
42/// constructors use it.
43pub const RECOMMENDED_NEGATIVE_FRESHNESS: Duration = Duration::from_secs(300);
44
45/// The typed revocation verdict for one context's signing key.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum RevocationVerdict {
49    /// A verified, in-scope revocation names the signing key.
50    Revoked {
51        /// §7 step 2 ([`RevocationEffect::PreCompromise`], accepted), step 3
52        /// ([`RevocationEffect::Revoked`]) or step 4
53        /// ([`RevocationEffect::Unplaceable`]).
54        effect: RevocationEffect,
55        /// Effective boundary T (the §4 earliest `compromised_since`).
56        boundary: DateTime<Utc>,
57        /// The revocation that fixes `boundary`, attributed.
58        revocation: SourcedRevocation,
59        /// The receipt-attested publish time compared, if any.
60        placement: Option<DateTime<Utc>>,
61    },
62    /// No verified, in-scope revocation names the signing key.
63    NotRevoked {
64        /// `true` when §8 discovery was configured and every configured
65        /// lookup completed untruncated (live or from a fresh cached
66        /// absence). `false` when discovery was off: only the caller's
67        /// known revocations and cached facts were consulted.
68        complete: bool,
69        /// Whether the registry-attested class was among the completed
70        /// lookups.
71        registry_attested_checked: bool,
72        /// When a cached complete absence answered at least one lookup,
73        /// the earliest time such an absence was observed; `None` when
74        /// every lookup ran live.
75        observed_at: Option<SystemTime>,
76    },
77    /// Discovery could not establish the key's status.
78    Unknown {
79        /// Why.
80        reason: UnknownReason,
81        /// The failure, rendered.
82        detail: String,
83    },
84}
85
86impl RevocationVerdict {
87    /// Whether the verdict refuses the context (§7 step 3 or 4).
88    #[must_use]
89    pub fn is_refusal(&self) -> bool {
90        matches!(
91            self,
92            RevocationVerdict::Revoked {
93                effect: RevocationEffect::Revoked | RevocationEffect::Unplaceable,
94                ..
95            }
96        )
97    }
98
99    /// Whether the verdict is `unknown`.
100    #[must_use]
101    pub fn is_unknown(&self) -> bool {
102        matches!(self, RevocationVerdict::Unknown { .. })
103    }
104
105    /// The `unknown` reason, if any.
106    #[must_use]
107    pub fn unknown_reason(&self) -> Option<UnknownReason> {
108        match self {
109            RevocationVerdict::Unknown { reason, .. } => Some(*reason),
110            _ => None,
111        }
112    }
113}
114
115/// A complete revocation assessment: the verdict, out-of-scope hints, and
116/// what discovery did.
117#[derive(Debug, Clone)]
118#[non_exhaustive]
119pub struct RevocationAssessment {
120    /// The verdict.
121    pub verdict: RevocationVerdict,
122    /// Verified revocations that do NOT speak for the producer (RFC-ACDP-0014
123    /// §7 "Producer scope", 0.5.0) — reported, never applied.
124    pub hints: Vec<SourcedRevocation>,
125    /// §8 discovery: `None` when not configured; `Some(Ok)` with what each
126    /// trust class found; `Some(Err(AcdpError::RevocationUnknown { .. }))`
127    /// when discovery could not finish.
128    pub discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
129    /// An in-scope revocation names the key and the context is placed
130    /// before T — even when the verdict is `Unknown` (discovery could not
131    /// rule out an earlier T). Drives the historical signature rule.
132    pub(crate) pre_compromise: bool,
133    pub(crate) on_failure: DiscoveryFailurePolicy,
134}
135
136impl RevocationAssessment {
137    /// Apply the policy: a refusal becomes [`AcdpError::KeyRevoked`]; an
138    /// `unknown` verdict becomes [`AcdpError::RevocationUnknown`] under
139    /// [`DiscoveryFailurePolicy::FailClosed`] and is returned as `Ok` under
140    /// [`DiscoveryFailurePolicy::ProceedWithKnown`].
141    pub fn enforce(self) -> Result<Self, AcdpError> {
142        if let RevocationVerdict::Revoked {
143            effect: effect @ (RevocationEffect::Revoked | RevocationEffect::Unplaceable),
144            boundary,
145            revocation,
146            placement,
147        } = &self.verdict
148        {
149            return Err(AcdpError::KeyRevoked(Box::new(RevocationRefusal::new(
150                *effect,
151                revocation.revoked_key_fingerprint.clone(),
152                *boundary,
153                revocation.clone(),
154                *placement,
155                refusal_detail(*effect, *boundary, *placement),
156            ))));
157        }
158        if self.verdict.is_unknown() && self.on_failure == DiscoveryFailurePolicy::FailClosed {
159            if let Some(Err(e)) = &self.discovery {
160                return Err(e.clone());
161            }
162        }
163        Ok(self)
164    }
165
166    /// Whether the signing key is placed before an in-scope compromise
167    /// boundary (§7 step 2): the signature must be verified under the
168    /// RFC-ACDP-0010 §10 historical rule.
169    #[must_use]
170    pub fn pre_compromise(&self) -> bool {
171        self.pre_compromise
172    }
173}
174
175fn fmt_ms(t: DateTime<Utc>) -> String {
176    t.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
177}
178
179fn refusal_detail(
180    effect: RevocationEffect,
181    boundary: DateTime<Utc>,
182    placement: Option<DateTime<Utc>>,
183) -> String {
184    match (effect, placement) {
185        (RevocationEffect::Revoked, Some(t)) => format!(
186            "the receipt-attested publish time {} is at/after the compromise boundary {}, so \
187             the signature is not attributable to the producer — fail closed regardless of \
188             DID-document state or receipt validity (RFC-ACDP-0014 §7 step 3)",
189            fmt_ms(t),
190            fmt_ms(boundary),
191        ),
192        (RevocationEffect::Unplaceable, Some(_)) => format!(
193            "the context's receipt is signed by a historical receipt key (verificationMethod \
194             only), which cannot place a revoked-key context before the compromise boundary \
195             {} under the strict profile — unplaceable, fail closed (RFC-ACDP-0014 §7 steps 1 \
196             and 4)",
197            fmt_ms(boundary),
198        ),
199        _ => format!(
200            "the context has no verified registry receipt, so its publish time cannot be \
201             placed relative to the compromise boundary {} — an unplaceable revoked-key \
202             signature is exactly the artifact an attacker mints freely; the strict profile \
203             fails closed (RFC-ACDP-0014 §7 step 4)",
204            fmt_ms(boundary),
205        ),
206    }
207}
208
209/// Revocations gathered for one producer, before the signing key is known.
210#[derive(Debug)]
211pub(crate) struct Gathered {
212    in_scope: Vec<SourcedRevocation>,
213    hints: Vec<SourcedRevocation>,
214    discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
215    complete: bool,
216    registry_attested_checked: bool,
217    observed_at: Option<SystemTime>,
218    on_failure: DiscoveryFailurePolicy,
219}
220
221impl Gathered {
222    /// Whether any in-scope revocation exists — the caller resolves the
223    /// signing key's fingerprint only then.
224    pub(crate) fn has_in_scope(&self) -> bool {
225        !self.in_scope.is_empty()
226    }
227}
228
229fn source_rank(s: &RevocationSource) -> u8 {
230    match s {
231        RevocationSource::Known => 0,
232        RevocationSource::Discovered { .. } => 1,
233        _ => 2,
234    }
235}
236
237/// Collect known, cached, and (when configured) discovered revocations for
238/// `agent_id`, scope them, and record what discovery did. Never fails: a
239/// discovery failure is captured as `unknown`.
240pub(crate) async fn gather(
241    client: &RegistryClient,
242    resolver: &WebResolver,
243    agent_id: &AgentDid,
244    policy: &RevocationPolicy,
245) -> Gathered {
246    let vantage = client.authority();
247    let include_attested = policy.discover.is_some_and(|d| d.include_registry_attested);
248    let on_failure = policy
249        .discover
250        .map_or(DiscoveryFailurePolicy::FailClosed, |d| d.on_failure);
251
252    let mut all: Vec<SourcedRevocation> = policy
253        .known
254        .iter()
255        .map(|r| r.sourced(RevocationSource::Known))
256        .collect();
257    // Facts survive every failure below: read before discovery runs.
258    if let Some((cache, _)) = client.revocation_cache() {
259        all.extend(
260            cache
261                .facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
262                .into_iter()
263                .map(|(r, origin)| r.sourced(RevocationSource::Cached { origin })),
264        );
265    }
266
267    let mut gathered = Gathered {
268        in_scope: Vec::new(),
269        hints: Vec::new(),
270        discovery: None,
271        complete: false,
272        registry_attested_checked: false,
273        observed_at: None,
274        on_failure,
275    };
276
277    if let Some(discovery) = &policy.discover {
278        // One combined budget for both lookups, on a client clone, so
279        // unrelated traffic on `client` is never charged to it.
280        let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
281        let dclient = client
282            .with_discovery_budget(budget)
283            .with_revocation_freshness(discovery.freshness);
284        let fut = async {
285            tokio::try_join!(
286                find_revocations_lookup(&dclient, resolver, agent_id),
287                async {
288                    if include_attested {
289                        find_registry_attested_lookup(&dclient, resolver, agent_id)
290                            .await
291                            .map(Some)
292                    } else {
293                        Ok(None)
294                    }
295                },
296            )
297        };
298        let failure = match tokio::time::timeout(discovery.total_timeout, fut).await {
299            Ok(Ok((signed, attested))) => {
300                let outcome = DiscoveryOutcome {
301                    producer_signed: signed.revocations.len(),
302                    registry_attested: attested.as_ref().map(|l| l.revocations.len()),
303                };
304                gathered.observed_at = [Some(&signed), attested.as_ref()]
305                    .into_iter()
306                    .flatten()
307                    .filter_map(|l: &Lookup| l.cached_since)
308                    .min();
309                gathered.complete = true;
310                gathered.registry_attested_checked = attested.is_some();
311                let authority = vantage.clone().unwrap_or_default();
312                for l in std::iter::once(signed).chain(attested) {
313                    all.extend(l.revocations.iter().map(|r: &KeyRevocation| {
314                        r.sourced(RevocationSource::Discovered {
315                            authority: authority.clone(),
316                        })
317                    }));
318                }
319                gathered.discovery = Some(Ok(outcome));
320                None
321            }
322            Ok(Err(e)) => Some(e),
323            Err(_elapsed) => Some(AcdpError::CrossRegistryResolutionFailed(format!(
324                "revocation auto-discovery exceeded total_timeout={:?}",
325                discovery.total_timeout
326            ))),
327        };
328        if let Some(source) = failure {
329            let reason = source.unknown_reason();
330            let detail = format!(
331                "RFC-ACDP-0014 §8 discovery for {agent_id} at {} could not finish: {source}",
332                vantage.as_deref().unwrap_or("<no authority>")
333            );
334            gathered.discovery = Some(Err(AcdpError::RevocationUnknown {
335                reason,
336                source: Box::new(source),
337                detail,
338            }));
339        }
340    }
341
342    (gathered.in_scope, gathered.hints) = scope(all, agent_id);
343    gathered
344}
345
346/// RFC-ACDP-0014 §7 producer scope: split revocations into those that
347/// speak for `agent_id` and out-of-scope hints (deduplicated).
348fn scope(
349    all: Vec<SourcedRevocation>,
350    agent_id: &AgentDid,
351) -> (Vec<SourcedRevocation>, Vec<SourcedRevocation>) {
352    let (mut in_scope, mut hints) = (Vec::new(), Vec::new());
353    for r in all {
354        if r.speaks_for(agent_id) {
355            in_scope.push(r);
356        } else if !hints.contains(&r) {
357            hints.push(r);
358        }
359    }
360    (in_scope, hints)
361}
362
363/// Decide the verdict for `signing_key_fingerprint` (`None` when there is
364/// no in-scope revocation, so no fingerprint was resolved).
365pub(crate) fn decide(
366    gathered: Gathered,
367    signing_key_fingerprint: Option<&str>,
368    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
369    accept_historical_receipt_key: bool,
370) -> RevocationAssessment {
371    // §4: the earliest boundary across every in-scope revocation naming the
372    // key; ties prefer producer-signed, then the most direct source.
373    let fixing = signing_key_fingerprint.and_then(|fp| {
374        gathered
375            .in_scope
376            .iter()
377            .filter(|r| r.revoked_key_fingerprint == fp)
378            .min_by_key(|r| {
379                (
380                    r.compromised_since,
381                    r.trust_class != RevocationTrustClass::ProducerSigned,
382                    source_rank(&r.source),
383                )
384            })
385            .cloned()
386    });
387    let named = fixing.map(|revocation| {
388        let boundary = revocation.compromised_since;
389        let (effect, placement) = match receipt_placement {
390            Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
391                (RevocationEffect::Unplaceable, Some(t))
392            }
393            Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
394            Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
395            None => (RevocationEffect::Unplaceable, None),
396        };
397        RevocationVerdict::Revoked {
398            effect,
399            boundary,
400            revocation,
401            placement,
402        }
403    });
404
405    let pre_compromise = matches!(
406        named,
407        Some(RevocationVerdict::Revoked {
408            effect: RevocationEffect::PreCompromise,
409            ..
410        })
411    );
412    let unknown = match &gathered.discovery {
413        Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
414            Some(RevocationVerdict::Unknown {
415                reason: *reason,
416                detail: detail.clone(),
417            })
418        }
419        _ => None,
420    };
421    // A refusal is definitive; `unknown` outranks an acceptance (an
422    // unfinished discovery could hide an earlier boundary).
423    let verdict = match (named, unknown) {
424        (Some(v), _) if v.is_refusal() => v,
425        (_, Some(u)) => u,
426        (Some(v), None) => v,
427        (None, None) => RevocationVerdict::NotRevoked {
428            complete: gathered.complete,
429            registry_attested_checked: gathered.registry_attested_checked,
430            observed_at: gathered.observed_at,
431        },
432    };
433    RevocationAssessment {
434        verdict,
435        hints: gathered.hints,
436        discovery: gathered.discovery,
437        pre_compromise,
438        on_failure: gathered.on_failure,
439    }
440}
441
442/// Assess `signing_key_fingerprint`'s revocation status for `agent_id`
443/// under `policy` — the same assessment
444/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
445/// runs, for re-checking an already-verified context without refetching
446/// it. Shares `policy.known`, `client`'s attached
447/// [`RevocationCache`](crate::RevocationCache), and `policy.discover`'s
448/// budget, deadline, freshness, and failure policy.
449///
450/// `receipt_placement` is the context's verified receipt `created_at` and
451/// receipt-key status (see
452/// [`VerifiedContext::verified_receipt`](crate::VerifiedContext::verified_receipt)
453/// and [`VerifiedContext::receipt_key_status`](crate::VerifiedContext::receipt_key_status)),
454/// or `None` when it has no verified receipt.
455///
456/// Returns the typed assessment and never fails on a verdict: call
457/// [`RevocationAssessment::enforce`] to turn a refusal into
458/// [`AcdpError::KeyRevoked`] and an `unknown` (under `FailClosed`) into
459/// [`AcdpError::RevocationUnknown`]. `Err` only for a malformed `agent_id`.
460pub async fn find_revocations_verdict(
461    client: &RegistryClient,
462    resolver: &WebResolver,
463    agent_id: &AgentDid,
464    signing_key_fingerprint: &str,
465    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
466    policy: &RevocationPolicy,
467) -> Result<RevocationAssessment, AcdpError> {
468    let agent_id = AgentDid::parse(agent_id.as_str())?;
469    let gathered = gather(client, resolver, &agent_id, policy).await;
470    Ok(decide(
471        gathered,
472        Some(signing_key_fingerprint),
473        receipt_placement,
474        policy.accept_historical_receipt_key_for_pre_t,
475    ))
476}
477
478/// Classify `revocations` (all treated as caller-supplied) for
479/// `signing_key_fingerprint` — the synchronous core of
480/// [`classify_under_revocation_placed`](crate::classify_under_revocation_placed).
481/// No producer-scope filter: the caller chose these revocations.
482pub(crate) fn decide_known(
483    revocations: &[KeyRevocation],
484    signing_key_fingerprint: &str,
485    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
486    accept_historical_receipt_key: bool,
487) -> RevocationAssessment {
488    let gathered = Gathered {
489        in_scope: revocations
490            .iter()
491            .map(|r| r.sourced(RevocationSource::Known))
492            .collect(),
493        hints: Vec::new(),
494        discovery: None,
495        complete: false,
496        registry_attested_checked: false,
497        observed_at: None,
498        on_failure: DiscoveryFailurePolicy::FailClosed,
499    };
500    decide(
501        gathered,
502        Some(signing_key_fingerprint),
503        receipt_placement,
504        accept_historical_receipt_key,
505    )
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use acdp_types::primitives::{ContextType, CtxId, Visibility};
512    use serde_json::{json, Value};
513
514    fn spec_dir() -> Option<std::path::PathBuf> {
515        let candidates = [
516            std::env::var("ACDP_SPEC_DIR")
517                .ok()
518                .map(std::path::PathBuf::from),
519            Some(
520                std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
521                    .join("../../../agentcontextdistributionprotocol"),
522            ),
523        ];
524        candidates
525            .into_iter()
526            .flatten()
527            .find(|p| p.join("schemas/conformance").exists())
528    }
529
530    /// One `rev-007` member as acdp would hold it after discovery: built
531    /// as a publish request and parsed by `KeyRevocation`, which applies
532    /// the §4/§10 type rule and derives the trust class. `None` when acdp
533    /// would not treat it as a revocation at all.
534    fn member(m: &Value) -> Option<SourcedRevocation> {
535        let agent = AgentDid::new(m["agent_id"].as_str().unwrap());
536        let mut meta = json!({ "revoked_key_fingerprint": m["revoked_key_fingerprint"] });
537        for k in ["compromised_since", "revoked_key_controller"] {
538            if let Some(v) = m.get(k) {
539                meta[k] = v.clone();
540            }
541        }
542        let req = acdp_producer::Producer::new(
543            acdp_crypto::SigningKey::from_bytes(&[0x42; 32]),
544            agent.clone(),
545            format!("{agent}#key-9"),
546        )
547        .publish_request()
548        .title("rev-007 member")
549        .context_type(serde_json::from_value::<ContextType>(m["type"].clone()).ok()?)
550        .visibility(Visibility::Public)
551        .metadata(meta)
552        .build()
553        .ok()?;
554        let mut r = KeyRevocation::from_publish_request(&req).ok()?;
555        r.ctx_id = Some(CtxId(m["ctx_id"].as_str().unwrap().to_owned()));
556        Some(r.sourced(RevocationSource::Discovered {
557            authority: "registry.example.com".into(),
558        }))
559    }
560
561    /// rev-007 (spec aa9701c): every known-answer vector through `scope`
562    /// and `decide`, the code `find_revocations_verdict` and
563    /// `VerifiedContext` run.
564    #[test]
565    fn rev_007_classification_known_answers() {
566        let Some(spec) = spec_dir() else {
567            assert!(
568                std::env::var("ACDP_REQUIRE_CONFORMANCE").is_err(),
569                "ACDP_REQUIRE_CONFORMANCE is set but the spec checkout was not found"
570            );
571            eprintln!("ACDP spec not found; skipping rev-007");
572            return;
573        };
574        let path = spec.join("schemas/conformance/rev-007-classification-known-answers.json");
575        let fixture: Value =
576            serde_json::from_slice(&std::fs::read(&path).expect("rev-007 fixture")).unwrap();
577        let mut disagreements = Vec::new();
578        for v in fixture["vectors"].as_array().unwrap() {
579            let i = &v["input"];
580            let agent = AgentDid::new(i["context"]["agent_id"].as_str().unwrap());
581            let fp = i["context"]["key_fingerprint"].as_str().unwrap();
582            let all = i["revocations"]
583                .as_array()
584                .unwrap()
585                .iter()
586                .filter_map(member)
587                .collect();
588            let (in_scope, hints) = scope(all, &agent);
589            let unknown = i["unknown_lookups"].as_u64().unwrap() > 0;
590            let discovery = if unknown {
591                Err(AcdpError::RevocationUnknown {
592                    reason: UnknownReason::Transient,
593                    source: Box::new(AcdpError::Http("lookup failed".into())),
594                    detail: "rev-007 unknown lookup".into(),
595                })
596            } else {
597                Ok(DiscoveryOutcome {
598                    producer_signed: in_scope.len(),
599                    registry_attested: Some(0),
600                })
601            };
602            let placement = match &i["receipt"] {
603                r if r.is_null() || r["verified"] == false => None,
604                r => Some((
605                    r["created_at"].as_str().unwrap().parse().unwrap(),
606                    match r["key_status"].as_str().unwrap() {
607                        "current" => ReceiptKeyStatus::Current,
608                        _ => ReceiptKeyStatus::Historical,
609                    },
610                )),
611            };
612            let gathered = Gathered {
613                in_scope,
614                hints,
615                complete: !unknown,
616                registry_attested_checked: !unknown,
617                discovery: Some(discovery),
618                observed_at: None,
619                on_failure: DiscoveryFailurePolicy::FailClosed,
620            };
621            let got = match decide(gathered, Some(fp), placement, false).verdict {
622                RevocationVerdict::Revoked {
623                    effect,
624                    boundary,
625                    revocation,
626                    ..
627                } => json!({
628                    "key_revocation": match effect {
629                        RevocationEffect::PreCompromise => "pre-compromise",
630                        RevocationEffect::Revoked => "revoked",
631                        _ => "unplaceable",
632                    },
633                    "boundary": fmt_ms(boundary),
634                    "revocation_ctx_id": revocation.ctx_id.map(|c| c.0),
635                }),
636                RevocationVerdict::NotRevoked { .. } => json!({
637                    "key_revocation": "not-revoked", "boundary": null, "revocation_ctx_id": null
638                }),
639                RevocationVerdict::Unknown { .. } => json!({
640                    "key_revocation": "unknown", "boundary": null, "revocation_ctx_id": null
641                }),
642            };
643            if got != v["expected"] {
644                disagreements.push(format!(
645                    "{}: acdp {got}, expected {}",
646                    v["name"], v["expected"]
647                ));
648            }
649        }
650        assert!(disagreements.is_empty(), "{}", disagreements.join("\n"));
651    }
652}