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    /// Keyless-signed candidates that could not be evaluated (§9.3).
220    unevaluated: Vec<crate::keyless::Unevaluated>,
221}
222
223impl Gathered {
224    /// Whether any in-scope revocation exists — the caller resolves the
225    /// signing key's fingerprint only then.
226    pub(crate) fn has_in_scope(&self) -> bool {
227        !self.in_scope.is_empty() || !self.unevaluated.is_empty()
228    }
229}
230
231fn source_rank(s: &RevocationSource) -> u8 {
232    match s {
233        RevocationSource::Known => 0,
234        RevocationSource::Discovered { .. } => 1,
235        _ => 2,
236    }
237}
238
239/// Collect known, cached, and (when configured) discovered revocations for
240/// `agent_id`, scope them, and record what discovery did. Never fails: a
241/// discovery failure is captured as `unknown`.
242pub(crate) async fn gather(
243    client: &RegistryClient,
244    resolver: &WebResolver,
245    agent_id: &AgentDid,
246    policy: &RevocationPolicy,
247    keyless: &crate::keyless::KeylessPolicy,
248) -> Gathered {
249    let vantage = client.authority();
250    let include_attested = policy.discover.is_some_and(|d| d.include_registry_attested);
251    let on_failure = policy
252        .discover
253        .map_or(DiscoveryFailurePolicy::FailClosed, |d| d.on_failure);
254
255    let mut all: Vec<SourcedRevocation> = policy
256        .known
257        .iter()
258        .map(|r| r.sourced(RevocationSource::Known))
259        .collect();
260    // Facts survive every failure below: read before discovery runs.
261    if let Some((cache, _)) = client.revocation_cache() {
262        all.extend(
263            cache
264                .facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
265                .into_iter()
266                .map(|(r, origin)| r.sourced(RevocationSource::Cached { origin })),
267        );
268    }
269
270    let mut gathered = Gathered {
271        in_scope: Vec::new(),
272        hints: Vec::new(),
273        discovery: None,
274        complete: false,
275        registry_attested_checked: false,
276        observed_at: None,
277        on_failure,
278        unevaluated: Vec::new(),
279    };
280    let mut keyless_hints = Vec::new();
281
282    if let Some(discovery) = &policy.discover {
283        // One combined budget for both lookups, on a client clone, so
284        // unrelated traffic on `client` is never charged to it.
285        let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
286        let dclient = client
287            .with_discovery_budget(budget)
288            .with_revocation_freshness(discovery.freshness);
289        let fut = async {
290            let (signed, attested) = tokio::try_join!(
291                find_revocations_lookup(&dclient, resolver, agent_id),
292                async {
293                    if include_attested {
294                        find_registry_attested_lookup(&dclient, resolver, agent_id)
295                            .await
296                            .map(Some)
297                    } else {
298                        Ok(None)
299                    }
300                },
301            )?;
302            // RFC-ACDP-0021 §9.3: keyless-signed candidates, on the same
303            // budget and deadline.
304            let keyless = crate::keyless::evaluate_revocations(
305                &dclient,
306                resolver,
307                agent_id,
308                &signed.deferred,
309                keyless,
310            )
311            .await;
312            Ok::<_, AcdpError>((signed, attested, keyless))
313        };
314        let failure = match tokio::time::timeout(discovery.total_timeout, fut).await {
315            Ok(Ok((signed, attested, keyless))) => {
316                let outcome = DiscoveryOutcome {
317                    producer_signed: signed.revocations.len(),
318                    registry_attested: attested.as_ref().map(|l| l.revocations.len()),
319                };
320                gathered.observed_at = [Some(&signed), attested.as_ref()]
321                    .into_iter()
322                    .flatten()
323                    .filter_map(|l: &Lookup| l.cached_since)
324                    .min();
325                gathered.complete = true;
326                gathered.registry_attested_checked = attested.is_some();
327                let authority = vantage.clone().unwrap_or_default();
328                for l in std::iter::once(signed).chain(attested) {
329                    all.extend(l.revocations.iter().map(|r: &KeyRevocation| {
330                        r.sourced(RevocationSource::Discovered {
331                            authority: authority.clone(),
332                        })
333                    }));
334                }
335                all.extend(keyless.applied.iter().map(|r| {
336                    r.sourced(RevocationSource::Discovered {
337                        authority: authority.clone(),
338                    })
339                }));
340                keyless_hints.extend(keyless.hints.iter().map(|r| {
341                    r.sourced(RevocationSource::Discovered {
342                        authority: authority.clone(),
343                    })
344                }));
345                gathered.unevaluated = keyless.unevaluated;
346                gathered.discovery = Some(Ok(outcome));
347                None
348            }
349            Ok(Err(e)) => Some(e),
350            Err(_elapsed) => Some(AcdpError::CrossRegistryResolutionFailed(format!(
351                "revocation auto-discovery exceeded total_timeout={:?}",
352                discovery.total_timeout
353            ))),
354        };
355        if let Some(source) = failure {
356            let reason = source.unknown_reason();
357            let detail = format!(
358                "RFC-ACDP-0014 §8 discovery for {agent_id} at {} could not finish: {source}",
359                vantage.as_deref().unwrap_or("<no authority>")
360            );
361            gathered.discovery = Some(Err(AcdpError::RevocationUnknown {
362                reason,
363                source: Box::new(source),
364                detail,
365            }));
366        }
367    }
368
369    (gathered.in_scope, gathered.hints) = scope(all, agent_id);
370    for h in keyless_hints {
371        if !gathered.hints.contains(&h) {
372            gathered.hints.push(h);
373        }
374    }
375    gathered
376}
377
378/// RFC-ACDP-0014 §7 producer scope: split revocations into those that
379/// speak for `agent_id` and out-of-scope hints (deduplicated).
380fn scope(
381    all: Vec<SourcedRevocation>,
382    agent_id: &AgentDid,
383) -> (Vec<SourcedRevocation>, Vec<SourcedRevocation>) {
384    let (mut in_scope, mut hints) = (Vec::new(), Vec::new());
385    for r in all {
386        if r.speaks_for(agent_id) {
387            in_scope.push(r);
388        } else if !hints.contains(&r) {
389            hints.push(r);
390        }
391    }
392    (in_scope, hints)
393}
394
395/// Decide the verdict for `signing_key_fingerprint` (`None` when there is
396/// no in-scope revocation, so no fingerprint was resolved).
397pub(crate) fn decide(
398    gathered: Gathered,
399    signing_key_fingerprint: Option<&str>,
400    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
401    accept_historical_receipt_key: bool,
402) -> RevocationAssessment {
403    // §4: the earliest boundary across every in-scope revocation naming the
404    // key; ties prefer producer-signed, then the most direct source.
405    let fixing = signing_key_fingerprint.and_then(|fp| {
406        gathered
407            .in_scope
408            .iter()
409            .filter(|r| r.revoked_key_fingerprint == fp)
410            .min_by_key(|r| {
411                (
412                    r.compromised_since,
413                    r.trust_class != RevocationTrustClass::ProducerSigned,
414                    source_rank(&r.source),
415                )
416            })
417            .cloned()
418    });
419    let named = fixing.map(|revocation| {
420        let boundary = revocation.compromised_since;
421        let (effect, placement) = match receipt_placement {
422            Some((t, ReceiptKeyStatus::Historical)) if !accept_historical_receipt_key => {
423                (RevocationEffect::Unplaceable, Some(t))
424            }
425            Some((t, _)) if t < boundary => (RevocationEffect::PreCompromise, Some(t)),
426            Some((t, _)) => (RevocationEffect::Revoked, Some(t)),
427            None => (RevocationEffect::Unplaceable, None),
428        };
429        RevocationVerdict::Revoked {
430            effect,
431            boundary,
432            revocation,
433            placement,
434        }
435    });
436
437    let pre_compromise = matches!(
438        named,
439        Some(RevocationVerdict::Revoked {
440            effect: RevocationEffect::PreCompromise,
441            ..
442        })
443    );
444    // RFC-ACDP-0021 §9.3: a keyless-signed revocation of this key that
445    // could not be evaluated leaves discovery's answer unknown.
446    let mut discovery = gathered.discovery;
447    if !matches!(discovery, Some(Err(_))) {
448        if let Some(u) = signing_key_fingerprint
449            .and_then(|fp| gathered.unevaluated.iter().find(|u| u.fingerprint == fp))
450        {
451            discovery = Some(Err(AcdpError::RevocationUnknown {
452                reason: UnknownReason::UnverifiableCandidate,
453                source: Box::new(AcdpError::RevocationCandidateUnverifiable(u.detail.clone())),
454                detail: u.detail.clone(),
455            }));
456        }
457    }
458    let unknown = match &discovery {
459        Some(Err(AcdpError::RevocationUnknown { reason, detail, .. })) => {
460            Some(RevocationVerdict::Unknown {
461                reason: *reason,
462                detail: detail.clone(),
463            })
464        }
465        _ => None,
466    };
467    // A refusal is definitive; `unknown` outranks an acceptance (an
468    // unfinished discovery could hide an earlier boundary).
469    let verdict = match (named, unknown) {
470        (Some(v), _) if v.is_refusal() => v,
471        (_, Some(u)) => u,
472        (Some(v), None) => v,
473        (None, None) => RevocationVerdict::NotRevoked {
474            complete: gathered.complete,
475            registry_attested_checked: gathered.registry_attested_checked,
476            observed_at: gathered.observed_at,
477        },
478    };
479    RevocationAssessment {
480        verdict,
481        hints: gathered.hints,
482        discovery,
483        pre_compromise,
484        on_failure: gathered.on_failure,
485    }
486}
487
488/// Assess `signing_key_fingerprint`'s revocation status for `agent_id`
489/// under `policy` — the same assessment
490/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
491/// runs, for re-checking an already-verified context without refetching
492/// it. Shares `policy.known`, `client`'s attached
493/// [`RevocationCache`](crate::RevocationCache), and `policy.discover`'s
494/// budget, deadline, freshness, and failure policy.
495///
496/// `receipt_placement` is the context's verified receipt `created_at` and
497/// receipt-key status (see
498/// [`VerifiedContext::verified_receipt`](crate::VerifiedContext::verified_receipt)
499/// and [`VerifiedContext::receipt_key_status`](crate::VerifiedContext::receipt_key_status)),
500/// or `None` when it has no verified receipt.
501///
502/// Returns the typed assessment and never fails on a verdict: call
503/// [`RevocationAssessment::enforce`] to turn a refusal into
504/// [`AcdpError::KeyRevoked`] and an `unknown` (under `FailClosed`) into
505/// [`AcdpError::RevocationUnknown`]. `Err` only for a malformed `agent_id`.
506pub async fn find_revocations_verdict(
507    client: &RegistryClient,
508    resolver: &WebResolver,
509    agent_id: &AgentDid,
510    signing_key_fingerprint: &str,
511    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
512    policy: &RevocationPolicy,
513) -> Result<RevocationAssessment, AcdpError> {
514    let agent_id = AgentDid::parse(agent_id.as_str())?;
515    // No keyless attester is accepted here: a keyless-signed revocation of
516    // this key reads as `unknown` (use `VerifiedContext` with
517    // `VerificationPolicy.keyless` to evaluate one).
518    let gathered = gather(
519        client,
520        resolver,
521        &agent_id,
522        policy,
523        &crate::keyless::KeylessPolicy::default(),
524    )
525    .await;
526    Ok(decide(
527        gathered,
528        Some(signing_key_fingerprint),
529        receipt_placement,
530        policy.accept_historical_receipt_key_for_pre_t,
531    ))
532}
533
534/// Classify `revocations` (all treated as caller-supplied) for
535/// `signing_key_fingerprint` — the synchronous core of
536/// [`classify_under_revocation_placed`](crate::classify_under_revocation_placed).
537/// No producer-scope filter: the caller chose these revocations.
538pub(crate) fn decide_known(
539    revocations: &[KeyRevocation],
540    signing_key_fingerprint: &str,
541    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
542    accept_historical_receipt_key: bool,
543) -> RevocationAssessment {
544    let gathered = Gathered {
545        in_scope: revocations
546            .iter()
547            .map(|r| r.sourced(RevocationSource::Known))
548            .collect(),
549        hints: Vec::new(),
550        discovery: None,
551        complete: false,
552        registry_attested_checked: false,
553        observed_at: None,
554        on_failure: DiscoveryFailurePolicy::FailClosed,
555        unevaluated: Vec::new(),
556    };
557    decide(
558        gathered,
559        Some(signing_key_fingerprint),
560        receipt_placement,
561        accept_historical_receipt_key,
562    )
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use acdp_types::primitives::{ContextType, CtxId, Visibility};
569    use serde_json::{json, Value};
570
571    fn spec_dir() -> Option<std::path::PathBuf> {
572        let candidates = [
573            std::env::var("ACDP_SPEC_DIR")
574                .ok()
575                .map(std::path::PathBuf::from),
576            Some(
577                std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
578                    .join("../../../agentcontextdistributionprotocol"),
579            ),
580        ];
581        candidates
582            .into_iter()
583            .flatten()
584            .find(|p| p.join("schemas/conformance").exists())
585    }
586
587    /// One `rev-007` member as acdp would hold it after discovery: built
588    /// as a publish request and parsed by `KeyRevocation`, which applies
589    /// the §4/§10 type rule and derives the trust class. `None` when acdp
590    /// would not treat it as a revocation at all.
591    fn member(m: &Value) -> Option<SourcedRevocation> {
592        let agent = AgentDid::new(m["agent_id"].as_str().unwrap());
593        let mut meta = json!({ "revoked_key_fingerprint": m["revoked_key_fingerprint"] });
594        for k in ["compromised_since", "revoked_key_controller"] {
595            if let Some(v) = m.get(k) {
596                meta[k] = v.clone();
597            }
598        }
599        let req = acdp_producer::Producer::new(
600            acdp_crypto::SigningKey::from_bytes(&[0x42; 32]),
601            agent.clone(),
602            format!("{agent}#key-9"),
603        )
604        .publish_request()
605        .title("rev-007 member")
606        .context_type(serde_json::from_value::<ContextType>(m["type"].clone()).ok()?)
607        .visibility(Visibility::Public)
608        .metadata(meta)
609        .build()
610        .ok()?;
611        let mut r = KeyRevocation::from_publish_request(&req).ok()?;
612        r.ctx_id = Some(CtxId(m["ctx_id"].as_str().unwrap().to_owned()));
613        Some(r.sourced(RevocationSource::Discovered {
614            authority: "registry.example.com".into(),
615        }))
616    }
617
618    /// rev-007 (spec aa9701c): every known-answer vector through `scope`
619    /// and `decide`, the code `find_revocations_verdict` and
620    /// `VerifiedContext` run.
621    #[test]
622    fn rev_007_classification_known_answers() {
623        let Some(spec) = spec_dir() else {
624            assert!(
625                std::env::var("ACDP_REQUIRE_CONFORMANCE").is_err(),
626                "ACDP_REQUIRE_CONFORMANCE is set but the spec checkout was not found"
627            );
628            eprintln!("ACDP spec not found; skipping rev-007");
629            return;
630        };
631        let path = spec.join("schemas/conformance/rev-007-classification-known-answers.json");
632        let fixture: Value =
633            serde_json::from_slice(&std::fs::read(&path).expect("rev-007 fixture")).unwrap();
634        let mut disagreements = Vec::new();
635        for v in fixture["vectors"].as_array().unwrap() {
636            let i = &v["input"];
637            let agent = AgentDid::new(i["context"]["agent_id"].as_str().unwrap());
638            let fp = i["context"]["key_fingerprint"].as_str().unwrap();
639            let all = i["revocations"]
640                .as_array()
641                .unwrap()
642                .iter()
643                .filter_map(member)
644                .collect();
645            let (in_scope, hints) = scope(all, &agent);
646            let unknown = i["unknown_lookups"].as_u64().unwrap() > 0;
647            let discovery = if unknown {
648                Err(AcdpError::RevocationUnknown {
649                    reason: UnknownReason::Transient,
650                    source: Box::new(AcdpError::Http("lookup failed".into())),
651                    detail: "rev-007 unknown lookup".into(),
652                })
653            } else {
654                Ok(DiscoveryOutcome {
655                    producer_signed: in_scope.len(),
656                    registry_attested: Some(0),
657                })
658            };
659            let placement = match &i["receipt"] {
660                r if r.is_null() || r["verified"] == false => None,
661                r => Some((
662                    r["created_at"].as_str().unwrap().parse().unwrap(),
663                    match r["key_status"].as_str().unwrap() {
664                        "current" => ReceiptKeyStatus::Current,
665                        _ => ReceiptKeyStatus::Historical,
666                    },
667                )),
668            };
669            let gathered = Gathered {
670                in_scope,
671                hints,
672                complete: !unknown,
673                registry_attested_checked: !unknown,
674                discovery: Some(discovery),
675                observed_at: None,
676                on_failure: DiscoveryFailurePolicy::FailClosed,
677                unevaluated: Vec::new(),
678            };
679            let got = match decide(gathered, Some(fp), placement, false).verdict {
680                RevocationVerdict::Revoked {
681                    effect,
682                    boundary,
683                    revocation,
684                    ..
685                } => json!({
686                    "key_revocation": match effect {
687                        RevocationEffect::PreCompromise => "pre-compromise",
688                        RevocationEffect::Revoked => "revoked",
689                        _ => "unplaceable",
690                    },
691                    "boundary": fmt_ms(boundary),
692                    "revocation_ctx_id": revocation.ctx_id.map(|c| c.0),
693                }),
694                RevocationVerdict::NotRevoked { .. } => json!({
695                    "key_revocation": "not-revoked", "boundary": null, "revocation_ctx_id": null
696                }),
697                RevocationVerdict::Unknown { .. } => json!({
698                    "key_revocation": "unknown", "boundary": null, "revocation_ctx_id": null
699                }),
700            };
701            if got != v["expected"] {
702                disagreements.push(format!(
703                    "{}: acdp {got}, expected {}",
704                    v["name"], v["expected"]
705                ));
706            }
707        }
708        assert!(disagreements.is_empty(), "{}", disagreements.join("\n"));
709    }
710}