Skip to main content

acdp_client/
revocation.rs

1//! Consumer-side key-revocation semantics (ACDP 0.3, RFC-ACDP-0014).
2//!
3//! Three layers:
4//!
5//! - [`classify_under_revocation`] — the pure §7 boundary rule: given
6//!   verified revocations and a (receipt-attested) publish time,
7//!   decide *historically authorized (pre-compromise, receipt-attested)*
8//!   vs fail-closed. This is what
9//!   [`VerificationPolicy::revocations`](crate::VerificationPolicy)
10//!   drives inside the fetch pipeline.
11//! - [`verify_revocation_body`] — the §5 verification pipeline for a
12//!   revocation context itself: strict RFC-ACDP-0001 §5.11 body
13//!   verification, §4 shape parse, and the §5 step 2 not-self-signed
14//!   check against the resolved signing key's fingerprint.
15//! - [`find_revocations`] — the §8 discovery SHOULD: search a registry
16//!   for a producer's revocation contexts and return the ones that
17//!   verify.
18
19use acdp_crypto::fingerprint::{fingerprint_for_key_id, fingerprint_for_key_id_since};
20use acdp_did::WebResolver;
21use acdp_primitives::error::AcdpError;
22use acdp_types::body::{Body, FullContext};
23use acdp_types::keyless::is_keyless_key_id;
24use acdp_types::primitives::{AgentDid, CtxId, LineageId};
25use acdp_types::receipt::ReceiptKeyStatus;
26use acdp_types::revocation::{KeyRevocation, RevocationTrustClass};
27
28use crate::revocation_verdict::{RevocationEffect, RevocationVerdict};
29use acdp_types::search::SearchParamsBuilder;
30use acdp_verify::Verifier;
31use chrono::{DateTime, Utc};
32
33use super::registry::RegistryClient;
34use super::verified::KeyAuthorization;
35
36/// Pagination safety cap for [`find_revocations`] — a hostile registry
37/// must not be able to hold the helper in an endless cursor loop.
38const MAX_SEARCH_PAGES: usize = 10;
39
40/// Lineage-walk safety cap for [`find_revocations`] and
41/// [`find_registry_attested_revocations`] — a hostile registry must not
42/// be able to name an unbounded number of distinct `lineage_id`s across
43/// a `MAX_SEARCH_PAGES`-bounded search (3 statuses × 2 type-forms × 10
44/// pages × limit 100 = up to ~6000 candidates) and force one
45/// `GET /lineages/{id}` fetch each (each capped at 1 MB by the client —
46/// ~6 GB of forced fetches with no cap at all). 100 is generous for any
47/// legitimate producer's revocation history and bounded against a
48/// hostile one; exceeding it is treated exactly like exhausting
49/// `MAX_SEARCH_PAGES` — a hard [`AcdpError::SearchTruncated`], never a
50/// silently partial set (issue #226 Phase 4).
51const MAX_LINEAGE_WALKS: usize = 100;
52
53/// Apply the RFC-ACDP-0014 §7 compromise-boundary rule.
54///
55/// Inputs:
56///
57/// - `revocations` — **verified** revocations the consumer has decided
58///   to act on (see [`RevocationPolicy`](crate::RevocationPolicy) for
59///   the §6 trust-class guidance). The §4 earliest-`compromised_since`
60///   rule is applied across every entry naming the fingerprint.
61/// - `signing_key_fingerprint` — the RFC-ACDP-0010 §6 fingerprint of
62///   the key that signed the context under verification.
63/// - `receipt_attested_created_at` — `created_at` from a registry
64///   receipt **verified per RFC-ACDP-0010 §8** (whose step 5 confirms
65///   the receipt attests this same fingerprint), or `None` when there
66///   is no verified receipt. The bare body `created_at` MUST NOT be
67///   passed here — it is registry-assigned, unsigned by the producer,
68///   and attacker-backdatable (§7 step 1).
69///
70/// Verdicts:
71///
72/// - `Ok(None)` — no supplied revocation names this key; the ordinary
73///   verification rules apply unchanged.
74/// - `Ok(Some(`[`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]`))`
75///   — publish time strictly before the boundary (§7 step 2). The
76///   caller must still verify the signature itself, under the
77///   RFC-ACDP-0010 §10 historical rule.
78/// - `Err(`[`AcdpError::KeyRevoked`]`)` — fail closed (wire code
79///   `key_not_authorized`; [`AcdpError::is_key_not_authorized`]): publish
80///   time at/after the boundary (§7 step 3), or no verifiable publish
81///   time at all (§7 step 4). Per RFC-ACDP-0014 §10 this is a
82///   verification verdict, not a wire condition — there is no new wire
83///   error code; the key is simply not authorized to speak for the
84///   producer in (or without placement relative to) the compromise
85///   window.
86///
87/// `receipt_attested_created_at` is taken as a placement from a
88/// *current* receipt key; use [`classify_under_revocation_placed`] to
89/// apply the 0.5.0 receipt-key-status rule.
90pub fn classify_under_revocation(
91    revocations: &[KeyRevocation],
92    signing_key_fingerprint: &str,
93    receipt_attested_created_at: Option<DateTime<Utc>>,
94) -> Result<Option<KeyAuthorization>, AcdpError> {
95    classify_under_revocation_placed(
96        revocations,
97        signing_key_fingerprint,
98        receipt_attested_created_at.map(|t| (t, ReceiptKeyStatus::Current)),
99        false,
100    )
101}
102
103/// [`classify_under_revocation`] with the receipt-key status of the
104/// placing receipt (RFC-ACDP-0014 §7 step 1, 0.5.0). Under the strict
105/// profile (`accept_historical_receipt_key == false`) a receipt verified
106/// under a [`ReceiptKeyStatus::Historical`] key cannot place the context:
107/// it is unplaceable (§7 step 4) and fails closed. `true` is the named
108/// non-strict mode
109/// ([`RevocationPolicy::accept_historical_receipt_key_for_pre_t`](crate::RevocationPolicy::accept_historical_receipt_key_for_pre_t)).
110pub fn classify_under_revocation_placed(
111    revocations: &[KeyRevocation],
112    signing_key_fingerprint: &str,
113    receipt_placement: Option<(DateTime<Utc>, ReceiptKeyStatus)>,
114    accept_historical_receipt_key: bool,
115) -> Result<Option<KeyAuthorization>, AcdpError> {
116    let assessment = crate::revocation_verdict::decide_known(
117        revocations,
118        signing_key_fingerprint,
119        receipt_placement,
120        accept_historical_receipt_key,
121    );
122    match assessment.verdict {
123        RevocationVerdict::Revoked {
124            effect: RevocationEffect::PreCompromise,
125            ..
126        } => Ok(Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)),
127        RevocationVerdict::NotRevoked { .. } => Ok(None),
128        _ => assessment.enforce().map(|_| None),
129    }
130}
131
132/// Verify a `key-revocation` context body per RFC-ACDP-0014 §5 and
133/// return its typed, trust-classified form.
134///
135/// Pipeline:
136///
137/// 1. Strict RFC-ACDP-0001 §5.11 body verification (schema, hash
138///    recomputation, DID resolution, `assertionMethod` authorization,
139///    signature) — §5 step 1's "signed by a currently authorized key".
140/// 2. §4 shape parse + §5/§6 trust-class derivation
141///    ([`KeyRevocation::from_body`]).
142/// 3. §5 step 2 — the resolved signing key's fingerprint MUST NOT
143///    equal `revoked_key_fingerprint`
144///    ([`KeyRevocation::check_not_self_signed`]).
145///
146/// Note the §5 step 1 nuance this strict form does not cover: a
147/// revocation whose own signing key was later rotated out *cleanly*
148/// remains acceptable via the RFC-ACDP-0010 §10 receipt-attested
149/// historical rule — fetch it through
150/// [`VerifiedContext::fetch_with_policy`](crate::VerifiedContext::fetch_with_policy)
151/// (default policy) and parse the body afterwards for that case.
152///
153/// The returned trust class MUST be honored per §6: act on
154/// producer-signed revocations unconditionally; treat registry-attested
155/// ones as the weaker class (confirm `publisher` is in fact the DID of
156/// the registry involved via
157/// [`KeyRevocation::cross_check_registry_binding`], and corroborate
158/// before global use).
159pub async fn verify_revocation_body(
160    body: &Body,
161    resolver: &WebResolver,
162) -> Result<KeyRevocation, AcdpError> {
163    Verifier::new(resolver).verify_body(body).await?;
164    let revocation = KeyRevocation::from_body(body)?;
165    let signer_fingerprint =
166        fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
167    revocation.check_not_self_signed(&signer_fingerprint)?;
168    Ok(revocation)
169}
170
171/// [`verify_revocation_body`] for a retrieved [`FullContext`]: the
172/// `content_hash` is recomputed over the raw received body
173/// (RFC-ACDP-0001 §5.7), so a revocation whose producer spelled a
174/// timestamp differently from this crate's typed rendering still
175/// verifies. Prefer this whenever the whole context is at hand.
176pub async fn verify_revocation_context(
177    ctx: &FullContext,
178    resolver: &WebResolver,
179) -> Result<KeyRevocation, AcdpError> {
180    let body = &ctx.body;
181    Verifier::new(resolver).verify_context(ctx).await?;
182    let revocation = KeyRevocation::from_body(body)?;
183    let signer_fingerprint =
184        fingerprint_for_key_id(&body.signature.key_id, &body.signature.algorithm, resolver).await?;
185    revocation.check_not_self_signed(&signer_fingerprint)?;
186    Ok(revocation)
187}
188
189/// Verify one revocation **candidate** found by auto-discovery
190/// (RFC-ACDP-0014 §8, 0.5.0 "Discovery outcome classes").
191///
192/// Differs from [`verify_revocation_context`] in two ways the discovery
193/// outcome classes require:
194///
195/// 1. **DID-cache freshness.** Every DID lookup is scoped to `since`
196///    (the discovery run's start): a key absent from — or not yet in
197///    `assertionMethod` of — a cached document older than `since` is
198///    re-fetched once before any verdict (RFC-ACDP-0001 §5.11
199///    "Caching"; rev-005 D). One forced fetch per DID serves every
200///    candidate in the run.
201/// 2. **The mandatory historical arm.** A candidate whose signing key is
202///    in the producer's `verificationMethod` but no longer in
203///    `assertionMethod` — the producer rotated after publishing the
204///    revocation — is evaluated under the RFC-ACDP-0010 §10 historical
205///    rule: its `registry_receipt` (re-retrieved by `ctx_id` when the
206///    candidate came from a lineage walk without one) is verified and,
207///    if it verifies, the signature is checked against the retained key
208///    (rev-005 A). No receipt, or one that does not verify, makes the
209///    candidate **unverifiable** —
210///    [`AcdpError::RevocationCandidateUnverifiable`], which discovery
211///    propagates (rev-005 B) — never a silent drop.
212///
213/// Return classes, as the discovery loops consume them:
214///
215/// - `Ok(rev)` — a verified revocation.
216/// - `Err(e)` with `e.is_transient()` or
217///   [`AcdpError::RevocationCandidateUnverifiable`] — could not decide;
218///   discovery fails (outcome *unknown*).
219/// - any other `Err` — **permanently invalid** (hash failure, bad
220///   signature, §4 shape, self-signed, key absent from a fresh
221///   `verificationMethod`, foreign `key_id`): the candidate is dropped.
222pub async fn verify_revocation_candidate(
223    ctx: &FullContext,
224    client: &RegistryClient,
225    resolver: &WebResolver,
226    since: std::time::Instant,
227) -> Result<KeyRevocation, AcdpError> {
228    let body = &ctx.body;
229    if is_keyless_key_id(&body.signature.key_id) {
230        return Err(keyless_unverifiable(body));
231    }
232    let verifier = Verifier::new(resolver).fresh_since(since);
233    match verifier.verify_context(ctx).await {
234        Ok(()) => {}
235        Err(AcdpError::KeyNotAuthorized(why)) => {
236            verify_candidate_historically(ctx, client, resolver, since, why).await?;
237        }
238        Err(e) => return Err(e),
239    }
240    let revocation = KeyRevocation::from_body(body)?;
241    let signer_fingerprint = fingerprint_for_key_id_since(
242        &body.signature.key_id,
243        &body.signature.algorithm,
244        resolver,
245        since,
246    )
247    .await?;
248    revocation.check_not_self_signed(&signer_fingerprint)?;
249    Ok(revocation)
250}
251
252/// The RFC-ACDP-0014 §8 historical arm for a candidate whose strict
253/// verification ended in `key_not_authorized` (`why`). Schema and hash
254/// already passed (strict verification checks them first).
255async fn verify_candidate_historically(
256    ctx: &FullContext,
257    client: &RegistryClient,
258    resolver: &WebResolver,
259    since: std::time::Instant,
260    why: String,
261) -> Result<(), AcdpError> {
262    let body = &ctx.body;
263    let key_id = body.signature.key_id.as_str();
264    // Only a did:web key of the producer itself can be a rotated-out
265    // historical key; a foreign key_id or an unsupported DID method is
266    // permanently not this producer's signature.
267    let same_producer = key_id
268        .split_once('#')
269        .is_some_and(|(did, _)| did == body.agent_id.as_str() && did.starts_with("did:web:"));
270    if !same_producer {
271        return Err(AcdpError::KeyNotAuthorized(why));
272    }
273
274    // The receipt: carried on the candidate, or — for a lineage-walk
275    // member served without one — re-retrieved by ctx_id (charged to the
276    // discovery budget like every other discovery request).
277    let refetched;
278    let receipt_value = match &ctx.registry_receipt {
279        Some(v) => v,
280        None => {
281            // RFC-ACDP-0014 §8 P1: the FULL-retrieval endpoint (the
282            // body-only one never carries a receipt). A transient failure
283            // or a budget refusal keeps its identity (both → unknown);
284            // any other failure leaves the candidate unverifiable.
285            refetched = match client.retrieve(&body.ctx_id).await {
286                Ok(c) => c,
287                Err(e) if is_discovery_fatal(&e) => return Err(e),
288                Err(e) => {
289                    return Err(AcdpError::RevocationCandidateUnverifiable(format!(
290                        "revocation candidate {} could not be re-retrieved for its \
291                         registry receipt: {e}",
292                        body.ctx_id.as_str()
293                    )))
294                }
295            };
296            if refetched.body.ctx_id != body.ctx_id
297                || refetched.body.content_hash != body.content_hash
298            {
299                return Err(AcdpError::RevocationCandidateUnverifiable(format!(
300                    "revocation candidate {} re-retrieved for its registry receipt came                      back as a different body — cannot place its rotated-out signing key",
301                    body.ctx_id.as_str()
302                )));
303            }
304            match &refetched.registry_receipt {
305                Some(v) => v,
306                None => {
307                    return Err(AcdpError::RevocationCandidateUnverifiable(format!(
308                        "revocation candidate {} is signed by {key_id}, which is no longer                          in assertionMethod, and carries no registry_receipt — the                          RFC-ACDP-0010 §10 historical rule cannot place it ({why})",
309                        body.ctx_id.as_str()
310                    )))
311                }
312            }
313        }
314    };
315
316    let serving_authority = client
317        .authority()
318        .unwrap_or_else(|| body.ctx_id.authority().to_string());
319    let producer_fp =
320        fingerprint_for_key_id_since(key_id, &body.signature.algorithm, resolver, since).await?;
321    match super::receipt::verify_receipt_value_since(
322        receipt_value,
323        &body.ctx_id,
324        body,
325        &body.content_hash,
326        &producer_fp,
327        &serving_authority,
328        resolver,
329        since,
330    )
331    .await
332    {
333        Ok(_) => {}
334        Err(e) if e.is_transient() => return Err(e),
335        Err(e) => {
336            return Err(AcdpError::RevocationCandidateUnverifiable(format!(
337                "revocation candidate {} is signed by rotated-out key {key_id} and its                  registry_receipt does not verify: {e}",
338                body.ctx_id.as_str()
339            )))
340        }
341    }
342    acdp_verify::verify_body_signature_historical_since(body, resolver, since).await
343}
344
345/// A revocation signed by a keyless key (RFC-ACDP-0021 §9.3) cannot be
346/// verified through RFC-ACDP-0001 §5.11. Discovery sets such candidates
347/// aside for the keyless procedure; standalone callers get this error,
348/// which makes the outcome *unknown*, never *absent*.
349fn keyless_unverifiable(body: &Body) -> AcdpError {
350    AcdpError::RevocationCandidateUnverifiable(format!(
351        "revocation candidate {} is signed by the keyless key {}; it verifies only through \
352         its key attestation (RFC-ACDP-0021 §8.1), not through the DID document",
353        body.ctx_id.as_str(),
354        body.signature.key_id
355    ))
356}
357
358/// Fail closed on keyless candidates a standalone discovery call could
359/// not evaluate (see [`keyless_unverifiable`]).
360fn refuse_deferred(deferred: &[FullContext]) -> Result<(), AcdpError> {
361    match deferred.first() {
362        Some(ctx) => Err(keyless_unverifiable(&ctx.body)),
363        None => Ok(()),
364    }
365}
366
367/// A candidate-verification error that makes the discovery outcome
368/// *unknown* rather than dropping the candidate: transient lookups and
369/// unverifiable candidates, and a discovery-budget refusal met while
370/// verifying a candidate (RFC-ACDP-0014 §8, P1/P2).
371fn is_discovery_fatal(e: &AcdpError) -> bool {
372    e.is_transient()
373        || matches!(
374            e,
375            AcdpError::RevocationCandidateUnverifiable(_)
376                | AcdpError::RevocationDiscoveryBudgetExceeded(_)
377        )
378}
379
380/// Walk every member of a revocation lineage and return the ones that
381/// verify per §5 — **regardless of `registry_state.status`** and with
382/// **no trust-class or publisher scope filter**.
383///
384/// **Why walk a lineage at all, when [`find_revocations`] already
385/// re-queries `status=superseded`:** a merely-superseded member is
386/// already visible to a `status=superseded` search pass — the walk buys
387/// nothing there. What the walk buys is robustness to any lineage whose
388/// members carry a *mixed* set of statuses a search pass does not (or
389/// cannot) enumerate: `expired` members, a future [`acdp_types::primitives::Status::Other`]
390/// value (`Status` is an open enum — RFC-ACDP-0004 §4), or a member a
391/// search pass has been made structurally blind to. RFC-ACDP-0013
392/// §8.1 makes `GET /lineages/{id}` an **obligation** to include the
393/// full lineage including retracted versions — so one lineage member
394/// that IS visible to search exposes every other member through this
395/// endpoint, independent of what search itself would ever return for
396/// them. A registry excluding a retracted context from `status=superseded`
397/// search results (RFC-ACDP-0013 §8.2 — normative) is exactly the seed
398/// case this closes; the complementary case — where **every** member of
399/// a lineage is invisible to search (e.g. the lineage head itself was
400/// retracted) — has no live search match to discover the lineage id
401/// from in the first place, and is closed separately by an explicit
402/// `status=retracted` search pass, not by this walk.
403///
404/// Fetches `client.lineage(lineage_id)` (RFC-ACDP-0004 §5.1 defines no
405/// pagination for this endpoint, so a pathological lineage fails loudly
406/// at the client's `MAX_CONTEXT_BYTES` cap rather than truncating) and
407/// verifies every returned member via [`verify_revocation_body`],
408/// pairing each verified member with its `ctx_id` — read from
409/// `ctx.body.ctx_id`, since the lineage linkage travels via the
410/// [`acdp_types::body::FullContext`]/[`Body`] shape, never via a new
411/// [`KeyRevocation`] field (`KeyRevocation` carries no `ctx_id` and
412/// derives no `Hash`, so its public return shape alone cannot feed a
413/// `ctx_id`-keyed dedupe set).
414///
415/// A member is only skipped when its §5 verification failure is
416/// **permanent** (`AcdpError::is_transient() == false` — a broken
417/// signature, a hash mismatch, a schema violation, a DID that resolves
418/// but denies the key) — attacker-injected garbage in a lineage must
419/// not poison the set — logged via `tracing::warn!` when the `tracing`
420/// feature is enabled. Note the asymmetry with the two fail-closed
421/// cases below: a cryptographic-verification failure *inside* an
422/// otherwise well-formed, correctly-membered lineage is actually a
423/// **stronger** signal of registry (or upstream producer) misbehavior
424/// than an empty or mismatched lineage response is — a well-behaved
425/// registry simply does not have a signature-broken member to serve in
426/// the first place — yet it is the empty/mismatched cases that fail
427/// loudly here, not this one. That is a deliberate, asymmetric choice,
428/// not an oversight: treating every verification failure as fatal
429/// would let one injected garbage member suppress every other genuine
430/// revocation in the lineage (the same "one bad apple poisons
431/// discovery" DoS this whole function exists to avoid), whereas
432/// dropping a **permanently** un-verifiable member and continuing
433/// costs nothing an attacker can turn into a false *authorization* —
434/// such a member was never going to contribute a valid
435/// `compromised_since` to [`effective_boundary`](acdp_types::revocation::effective_boundary)'s fold, dropped or
436/// not. It is currently invisible without the `tracing` feature; a
437/// future caller auditing registry health should not assume "no
438/// revocations found" means "no suspicious members were seen."
439///
440/// **A transient failure (`is_transient() == true` — the DID host is
441/// unreachable, rate-limited, or otherwise could not be asked, as
442/// opposed to having answered and denied) is a different case and is
443/// NOT dropped: it propagates as `Err` instead (issue #248 Phase 1,
444/// "D5").** An earlier revision of this doc claimed a dropped member
445/// is "simply absent from the result, never wrongly present" — true
446/// only of the permanent case above, and false in general: a dropped
447/// member that *would* have named an earlier `compromised_since` than
448/// any survivor moves [`effective_boundary`](acdp_types::revocation::effective_boundary)'s `.min()` fold **later**
449/// (`acdp_types::revocation::effective_boundary`), which silently
450/// authorizes activity that should have been inside the compromise
451/// window — a genuine false authorization, not a mere omission. A
452/// transient failure gives no information about what that member would
453/// have said, so silently continuing past it could produce exactly
454/// that outcome. Propagating instead tells the caller "this set is
455/// incomplete, do not trust it," which — per D5 — adds no new denial-
456/// of-service lever: this function already aborts unconditionally on
457/// `client.lineage` failing outright, so a hostile or merely-unlucky
458/// network path already had an abort lever before this change; this
459/// closes the one case that used to bypass it by masquerading as a
460/// clean, complete result.
461///
462/// **Fails closed, rather than silently continuing, in two cases:**
463///
464/// 1. The lineage has no members at all. The reference registry returns
465///    `Ok(vec![])` for an unrecognized `lineage_id`
466///    (`crates/acdp-server/src/registry/store.rs`), so an empty response
467///    for a `lineage_id` a live search match just named is never an
468///    honest "nothing here."
469/// 2. `expect_member` is `Some` and is not among the **pre-verification**
470///    members the registry served (see the parameter doc below) — the
471///    registry answered the lineage-walk request, but not with the
472///    lineage the search match actually pointed at.
473///
474/// Both cases return [`AcdpError::IncompleteLineage`]. A genuine 404 or
475/// transport error from `client.lineage` propagates unchanged (a
476/// different variant, not this one) — all the way out to
477/// [`find_revocations`] and [`find_registry_attested_revocations`],
478/// neither of which catches an `Err` from this function.
479///
480/// **This does not contradict RFC-ACDP-0004 §5.4** ("if the lineage
481/// exists but the requester is authorized to see zero versions, the
482/// response MUST be an empty array `[]`, not `not_found`" — corroborated
483/// by fixture `vis-008`): that rule is about visibility scoping hiding
484/// versions from a requester who is not authorized to see them, and it
485/// is real for ordinary lineages. It cannot legitimately fire *here*,
486/// specifically, because RFC-ACDP-0014 §4 requires every revocation
487/// context to be published `visibility: public` — there is no
488/// authorized-to-see-zero-versions state a public-only lineage can be
489/// in. An empty response to a revocation-lineage walk therefore means
490/// the registry is inconsistent (eventually-consistent lag) or hostile,
491/// not that §5.4 visibility scoping legitimately applied — reword from
492/// an earlier draft that called this "the realistic hostile shape,"
493/// which conflated a hostile registry with a benign eventual-consistency
494/// race; both are covered by the same fail-closed response here, but
495/// only because both are equally unexplainable for a lineage that MUST
496/// be all-public. Callers relying on this function for a
497/// **non**-revocation, mixed-visibility lineage would need a different
498/// rule.
499///
500/// **Round-trip cost.** Every distinct `lineage_id` a search match names
501/// (deduped by the callers below) costs one `client.lineage` fetch
502/// (RFC-ACDP-0004 §5.1 defines no pagination for this endpoint, so a
503/// pathological lineage fails loudly at `MAX_CONTEXT_BYTES` rather than
504/// truncating) plus one DID resolution per member returned — including
505/// members belonging to producers other than the one a caller queried
506/// for, since a lineage walk has no producer filter. Worst case this
507/// roughly doubles the per-call work a hostile registry can impose,
508/// against the already-existing per-search-match `retrieve` cost — not a
509/// new exposure *class*, since [`acdp_did::WebResolver`]'s
510/// [`acdp_safe_http::SsrfPolicy`] still bounds which hosts any of those
511/// DID fetches can reach, but real added work per call.
512///
513/// This is the neutral building block: no trust-class or publisher
514/// filter is applied here. [`find_revocations_in_lineage`] is a thin
515/// wrapper for a caller that wants every verified member regardless of
516/// scope; [`find_revocations`] and [`find_registry_attested_revocations`]
517/// each apply their own (different) scope filter to the members this
518/// returns, and each propagates a failure from this function via `?`,
519/// aborting the whole discovery call rather than scoping the failure to
520/// one lineage — see their docs.
521///
522/// `expect_member`, when `Some`, is the `ctx_id` a live search match
523/// named for `lineage_id` — checked against the lineage's
524/// **pre-verification** member list, not the post-verification
525/// survivors. The two differ whenever a member fails §5 verification,
526/// and the choice matters: checking survivors instead would let a
527/// hostile registry serve a corrupted (signature-broken) copy of the
528/// named member specifically to manufacture this same failure, making
529/// "fails §5" and "isn't the lineage that was searched" indistinguishable.
530/// The question this check answers is "did the registry serve the
531/// lineage it claimed to," which is answerable from the raw member list
532/// alone, before any per-member verification is attempted.
533async fn walk_revocation_lineage(
534    client: &RegistryClient,
535    resolver: &WebResolver,
536    lineage_id: &LineageId,
537    expect_member: Option<&CtxId>,
538    since: std::time::Instant,
539) -> Result<Walked, AcdpError> {
540    let members = client.lineage(lineage_id).await?;
541    if members.is_empty() {
542        return Err(AcdpError::IncompleteLineage {
543            lineage_id: lineage_id.as_str().to_string(),
544            ctx_id: expect_member.map(|c| c.as_str().to_string()),
545        });
546    }
547    // GAP-A (issue #226 Phase 3): confirm the registry actually served
548    // the lineage a live search match named, checked against the
549    // PRE-verification member list — see the parameter doc above for
550    // why pre- rather than post-verification.
551    if let Some(expected) = expect_member {
552        if !members.iter().any(|ctx| &ctx.body.ctx_id == expected) {
553            return Err(AcdpError::IncompleteLineage {
554                lineage_id: lineage_id.as_str().to_string(),
555                ctx_id: Some(expected.as_str().to_string()),
556            });
557        }
558    }
559    let mut out = Vec::with_capacity(members.len());
560    let mut deferred = Vec::new();
561    for ctx in members {
562        if is_keyless_key_id(&ctx.body.signature.key_id) {
563            deferred.push(ctx);
564            continue;
565        }
566        match verify_revocation_candidate(&ctx, client, resolver, since).await {
567            Ok(rev) => out.push((ctx.body.ctx_id.clone(), rev)),
568            // D5 (issue #248 Phase 1): a transient failure means "could
569            // not check this member," not "this member is bad" — propagate
570            // it rather than silently treating it as if it had never
571            // existed. An unverifiable member (RFC-ACDP-0014 §8 historical
572            // arm without a usable receipt) is likewise "unknown", never
573            // "absent". See the rustdoc block above for why the permanent
574            // case still drops and warns.
575            Err(e) if is_discovery_fatal(&e) => return Err(e),
576            Err(_e) => {
577                #[cfg(feature = "tracing")]
578                tracing::warn!(
579                    lineage_id = %lineage_id.as_str(),
580                    ctx_id = %ctx.body.ctx_id.as_str(),
581                    error = %_e,
582                    "walk_revocation_lineage: dropped lineage member failing §5 verification"
583                );
584            }
585        }
586    }
587    Ok(Walked {
588        verified: out,
589        deferred,
590    })
591}
592
593/// A lineage walk's members: those that verified per §5, and those
594/// signed by a keyless key, set aside unverified (RFC-ACDP-0021 §9.3).
595struct Walked {
596    verified: Vec<(CtxId, KeyRevocation)>,
597    deferred: Vec<FullContext>,
598}
599
600/// Discover every verified member of a revocation lineage
601/// (RFC-ACDP-0014 §4, RFC-ACDP-0013 §8.1), regardless of
602/// `registry_state.status` and with no trust-class or publisher scope
603/// filter applied.
604///
605/// A thin wrapper over the crate-private lineage walk
606/// (`walk_revocation_lineage`) — see the walk-vs-search rationale
607/// below (in short: `GET /lineages/{id}` is normatively obliged to
608/// include every member, including ones a `status=`-scoped search pass
609/// cannot or does not enumerate, so one search-visible member exposes
610/// the whole lineage through this call). [`find_revocations`] and
611/// [`find_registry_attested_revocations`] both call this internally
612/// (via the same private walk) to widen their own search-driven
613/// discovery; call this function directly when a caller already has a
614/// `lineage_id` in hand (e.g. from a [`acdp_types::search::SearchResult`]
615/// or a previously-verified [`Body`]) and wants every verified member
616/// without those functions' scope filters.
617pub async fn find_revocations_in_lineage(
618    client: &RegistryClient,
619    resolver: &WebResolver,
620    lineage_id: &LineageId,
621) -> Result<Vec<KeyRevocation>, AcdpError> {
622    let walked = walk_revocation_lineage(
623        client,
624        resolver,
625        lineage_id,
626        None,
627        std::time::Instant::now(),
628    )
629    .await?;
630    refuse_deferred(&walked.deferred)?;
631    Ok(walked.verified.into_iter().map(|(_, rev)| rev).collect())
632}
633
634/// The data-only differences between [`find_revocations`] and
635/// [`find_registry_attested_revocations`] that [`discover_revocations`]
636/// needs but cannot compute itself (issue #264: "five parameters, not
637/// three" — this struct is three of the five; `keep` and `on_drop` below
638/// are the other two).
639struct DiscoveryParams<'a> {
640    /// Value passed to `SearchParamsBuilder::agent_id` for every search
641    /// pass — the producer's own DID for [`find_revocations`], the
642    /// registry's own DID (`capabilities.registry_did`) for the attested
643    /// form (registry-attested revocations are published under the
644    /// *registry's* `agent_id`, never the producer's).
645    search_agent_id: &'a str,
646    /// Diagnostic prefix shared by both `AcdpError::SearchTruncated`
647    /// messages this engine can raise — `"find_revocations"` or
648    /// `"find_registry_attested_revocations"`.
649    fn_name: &'a str,
650    /// Identity label used in the `MAX_LINEAGE_WALKS` message —
651    /// `"agent_id"` or `"controller"`. Genuinely different from a mere
652    /// value substitution: the label itself differs, not only what fills
653    /// it (issue #264 point 5).
654    identity_label: &'a str,
655    /// Identity value formatted after `identity_label` in the same
656    /// message — `agent_id.as_str()` or `controller.as_str()`.
657    identity_value: &'a str,
658}
659
660/// The discovery engine shared by [`find_revocations`] and
661/// [`find_registry_attested_revocations`]: the type-form × status ×
662/// page search loop, the `MAX_LINEAGE_WALKS` bound, and the lineage-walk
663/// loop that follows it (issue #264). Everything upstream of the search
664/// loop — DID parsing, the revocation-cache marker check/record, and
665/// (for the attested form) the one-time `client.capabilities()` fetch —
666/// stays in the two public callers: the marker check must run before
667/// `capabilities()` in the attested form, and `record_success` must fire
668/// only once this engine returns `Ok`, so neither belongs inside a
669/// shared loop body.
670///
671/// `params.search_agent_id` scopes every `SearchParamsBuilder` pass.
672/// `keep` re-checks the verified body against the caller's own scope
673/// invariants (publisher + trust class for the producer-signed form;
674/// controller + registry-binding for the attested form) — a candidate
675/// `keep` rejects is dropped, never surfaced as an error, exactly as
676/// before. `on_drop` is called for every candidate `keep` rejects, with
677/// a [`DropSite`] naming which loop dropped it (the two forms differ
678/// in `tracing::warn!` payload shape — `trust_class`/computed `filter`
679/// vs `publisher`/`controller` — which a single
680/// `&(dyn Fn(..) -> bool + Sync)` predicate cannot carry).
681///
682/// Every other behavior — the transient-propagate/permanent-drop split
683/// (issue #248 Phase 1), `MAX_SEARCH_PAGES` being fresh per
684/// `(type_form, status)` pair, the `MAX_LINEAGE_WALKS` check running
685/// after the retrieves for that pass have already gone out, and the
686/// `SearchTruncated` fail-closed contract — is unchanged from the two
687/// functions' original bodies; see their docs for the full rationale.
688/// Which loop inside [`discover_revocations`] dropped a candidate.
689///
690/// Deliberately an enum rather than a `bool`: the value is chosen ~70
691/// lines away from the `tracing::warn!` it selects, and swapping it
692/// silently mislabels a search-loop drop as a lineage-walk drop (and
693/// vice versa) while every test still passes — verified by mutation
694/// during review of issue #264. A named variant makes the call site
695/// self-describing so the mistake is visible in the diff rather than
696/// only in production log wording.
697#[derive(Debug, Clone, Copy, PartialEq, Eq)]
698enum DropSite {
699    /// The `(type_form, status)` search/retrieve loop.
700    Search,
701    /// The `MAX_LINEAGE_WALKS`-bounded lineage-walk loop.
702    LineageWalk,
703}
704
705async fn discover_revocations(
706    client: &RegistryClient,
707    resolver: &WebResolver,
708    params: DiscoveryParams<'_>,
709    keep: &(dyn Fn(&KeyRevocation) -> bool + Sync),
710    on_drop: &(dyn Fn(&KeyRevocation, &CtxId, DropSite) + Sync),
711) -> Result<(Vec<KeyRevocation>, Vec<FullContext>), AcdpError> {
712    let DiscoveryParams {
713        search_agent_id,
714        fn_name,
715        identity_label,
716        identity_value,
717    } = params;
718    // One DID-freshness scope for the whole run: a document fetched at
719    // or after this instant is fresh, so a forced re-fetch triggered by
720    // one candidate serves every later candidate (RFC-ACDP-0001 §5.11).
721    let since = std::time::Instant::now();
722
723    let mut revocations = Vec::new();
724    // Keyless-signed candidates, set aside for the keyless procedure.
725    let mut deferred = Vec::new();
726    let mut seen = std::collections::HashSet::new();
727    // Discovery-order list of distinct lineage ids (walked below) plus
728    // a plain `HashSet<String>` for the dedupe check — `LineageId`
729    // derives `Hash` but not `Ord`, so a `BTreeSet` is not an option
730    // without a wider change than GAP-B needs. Iterating a `Vec` here
731    // (rather than a `HashSet<LineageId>`) keeps output order — and
732    // which lineage's walk failure surfaces first — deterministic
733    // across runs, matching the pre-existing search-order determinism.
734    let mut lineage_order: Vec<LineageId> = Vec::new();
735    let mut lineage_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
736    // First search match naming each lineage_id — threaded into the
737    // walk below as the expected member (GAP-A). First match wins: once
738    // a lineage_id has been seen, later matches naming the same lineage
739    // do not overwrite the recorded ctx_id.
740    let mut lineage_expect: std::collections::HashMap<String, CtxId> =
741        std::collections::HashMap::new();
742
743    for type_form in ["key-revocation", "acdp:key-revocation"] {
744        // Revocations are permanent but supersedable; the registry
745        // defaults search to status=active, so ask for the other
746        // statuses explicitly too. `retracted` closes the seed hole
747        // Phase 3's lineage walk cannot reach on its own: if every
748        // member of a lineage is retracted, no `active`/`superseded`
749        // pass returns anything to walk from in the first place
750        // (RFC-ACDP-0013 §8.2, issue #226 Phase 4).
751        for status in ["active", "superseded", "retracted"] {
752            let mut search_params = SearchParamsBuilder::new()
753                .context_type(type_form)
754                .agent_id(search_agent_id)
755                .status(status)
756                .limit(100)
757                .build();
758            // Set only when the loop exhausts `MAX_SEARCH_PAGES` while a
759            // cursor still remains — i.e. genuine truncation, not
760            // completion. See the boundary note on `AcdpError::SearchTruncated`.
761            let mut truncated = false;
762            for _page in 0..MAX_SEARCH_PAGES {
763                let resp = client.search(&search_params).await?;
764                for m in &resp.matches {
765                    // Free — no extra round trip: every match names its
766                    // own lineage, walked below regardless of whether
767                    // this particular ctx_id turns out to be new.
768                    let lid_key = m.lineage_id.as_str().to_string();
769                    if lineage_seen.insert(lid_key.clone()) {
770                        lineage_order.push(m.lineage_id.clone());
771                        lineage_expect.insert(lid_key, m.ctx_id.clone());
772                    }
773                    if !seen.insert(m.ctx_id.as_str().to_string()) {
774                        continue;
775                    }
776                    let ctx = client.retrieve(&m.ctx_id).await?;
777                    if is_keyless_key_id(&ctx.body.signature.key_id) {
778                        deferred.push(ctx);
779                        continue;
780                    }
781                    match verify_revocation_candidate(&ctx, client, resolver, since).await {
782                        Ok(rev) => {
783                            if keep(&rev) {
784                                revocations.push(rev);
785                            } else {
786                                on_drop(&rev, &m.ctx_id, DropSite::Search);
787                            }
788                        }
789                        // D5 (issue #248 Phase 1): transient means "could
790                        // not look," not "clean" — propagate rather than
791                        // let the candidate vanish into an empty Vec that
792                        // `classify_under_revocation` reads as "proceed."
793                        // Same for an unverifiable candidate (§8 historical
794                        // arm, rev-005 B).
795                        Err(e) if is_discovery_fatal(&e) => return Err(e),
796                        Err(_e) => {
797                            #[cfg(feature = "tracing")]
798                            tracing::warn!(
799                                ctx_id = %m.ctx_id,
800                                error = %_e,
801                                "{fn_name}: dropped candidate failing §5 verification"
802                            );
803                        }
804                    }
805                }
806                match resp.next_cursor {
807                    Some(cursor) => {
808                        search_params.cursor = Some(cursor);
809                        truncated = true;
810                    }
811                    None => {
812                        truncated = false;
813                        break;
814                    }
815                }
816            }
817            if truncated {
818                return Err(AcdpError::SearchTruncated(format!(
819                    "{fn_name}: exhausted MAX_SEARCH_PAGES={MAX_SEARCH_PAGES} pages \
820                     for (type_form={type_form}, status={status}) with results still \
821                     remaining on the registry (next_cursor was still Some) — refusing to \
822                     return a silently partial revocation set"
823                )));
824            }
825        }
826    }
827
828    // Bound the lineage walk the same way the search pages are bounded:
829    // a hostile registry must not be able to name more distinct
830    // lineage ids than this client is willing to fetch one-by-one via
831    // `GET /lineages/{id}` (issue #226 Phase 4).
832    if lineage_order.len() > MAX_LINEAGE_WALKS {
833        return Err(AcdpError::SearchTruncated(format!(
834            "{fn_name}: {} candidate lineage ids for {identity_label}={identity_value} exceed \
835             MAX_LINEAGE_WALKS={MAX_LINEAGE_WALKS} — refusing to fetch a partial set",
836            lineage_order.len(),
837        )));
838    }
839
840    // The lineage walk: recovers members a search pass cannot or does
841    // not enumerate. Each candidate lineage is fetched at most once
842    // (deduped above), in discovery order (GAP-B). A walk failure for
843    // one lineage — empty response, or a served member set missing the
844    // search-named `ctx_id` (GAP-A) — aborts this whole call via `?`:
845    // a partial `Vec` here is indistinguishable from a complete one to
846    // the caller, and feeds `effective_boundary`'s `.min()` over
847    // `compromised_since` (RFC-ACDP-0014 §4), so silently dropping a
848    // lineage could only ever move the effective compromise boundary
849    // later (or, if the whole set is dropped, make enforcement inert) —
850    // never safer to omit than to fail closed on. A member failing §5
851    // verification transiently (issue #248 Phase 1, "D5") also aborts
852    // the walk this way, rather than being skipped like a permanent
853    // failure.
854    for lineage_id in &lineage_order {
855        let expect = lineage_expect.get(lineage_id.as_str());
856        let walked = walk_revocation_lineage(client, resolver, lineage_id, expect, since).await?;
857        for ctx in walked.deferred {
858            if seen.insert(ctx.body.ctx_id.as_str().to_string()) {
859                deferred.push(ctx);
860            }
861        }
862        for (ctx_id, rev) in walked.verified {
863            if !seen.insert(ctx_id.as_str().to_string()) {
864                continue;
865            }
866            if keep(&rev) {
867                revocations.push(rev);
868            } else {
869                on_drop(&rev, &ctx_id, DropSite::LineageWalk);
870            }
871        }
872    }
873
874    Ok((revocations, deferred))
875}
876
877/// Discover a producer's key revocations on a registry
878/// (RFC-ACDP-0014 §8): search `type=key-revocation` (and the §10
879/// interim `acdp:key-revocation`) with `agent_id=<producer>`, retrieve
880/// each match, and return the ones that verify per §5
881/// ([`verify_revocation_body`]) **and** that satisfy two additional
882/// invariants enforced client-side, neither of which the registry can
883/// be trusted to have applied:
884///
885/// 1. `KeyRevocation::publisher == agent_id` (exact byte match — see
886///    below) — the query's own scope. `resp.matches` is registry-
887///    supplied and is re-checked here rather than trusted: a hostile
888///    registry could otherwise return a context belonging to a
889///    different producer entirely, and it would verify (§5 covers
890///    signature validity, not query relevance). This is a genuine
891///    cryptographic binding, not merely a registry-trusted one:
892///    `Body.agent_id` is a producer-controlled field, not part of the
893///    RFC-ACDP-0001 §5.7 exclusion set, so it sits inside
894///    ProducerContent — covered by `content_hash` and the producer's
895///    signature. Because [`verify_revocation_body`] runs the full
896///    §5.11 pipeline first, `rev.publisher` read here has already been
897///    verified against that signature, so a hostile registry cannot
898///    forge it — stronger than the Phase 1 `ctx_id` check, which binds
899///    only a registry-assigned field.
900/// 2. `trust_class == RevocationTrustClass::ProducerSigned` — with (1)
901///    enforced, a `RegistryAttested` result here would mean a producer
902///    published a revocation *claiming to speak as the registry*
903///    (`agent_id == publisher == <the queried producer>` while
904///    `revoked_key_controller` differs). RFC-ACDP-0014 §4 makes
905///    `revoked_key_controller` REQUIRED on registry-attested
906///    revocations and, when present, requires it equal `body.agent_id`
907///    on producer-signed ones — so this shape (`agent_id` fixed as the
908///    queried producer, `revoked_key_controller` differing) is illegal
909///    only in the producer-signed case; it is the *mandatory* shape
910///    when `body.agent_id` is genuinely a registry. RFC-ACDP-0014 §13
911///    documents this exact cross-producer forgery and endorses
912///    consumer-local, operational mitigations generally — this
913///    implementation's choice is client-local filtering; §13's own
914///    first-named suggestion is surfacing which DID issued each
915///    acted-upon revocation, which the `tracing` diagnostics below
916///    partially adopt.
917///
918/// Both are required together: (1) alone still admits a producer's own
919/// spec-illegal self-published "registry attestation" (`agent_id` and
920/// `publisher` are the same field, so that check alone can't tell honest
921/// producer-signed apart from self-claimed registry-attested); (2) alone
922/// still admits *another* producer's genuine revocation served by a
923/// hostile or buggy registry that ignored the `agent_id` search filter.
924///
925/// Candidates dropped by either check, or that fail §5 verification
926/// outright with a **permanent** error (`AcdpError::is_transient() ==
927/// false`) — including self-signed "revocations", which are at most a
928/// hint (§5 step 2) — are omitted from the return value: returning a
929/// typed error for one bad or off-scope body would let anyone poison
930/// the whole discovery result for every caller — a cheap DoS on the
931/// very helper meant to defend against DoS. Dropped candidates are not
932/// silent, though: with the `tracing` feature enabled, each one is
933/// surfaced via a `tracing::warn!` recording the publisher, trust
934/// class, and `ctx_id` of the dropped candidate and which filter
935/// dropped it — RFC-ACDP-0014 §13's first-named mitigation,
936/// "surfacing which DID issued each acted-upon revocation." A caller
937/// wanting them in-band, or building without `tracing`, has the
938/// composable primitives directly:
939/// [`RegistryClient::search`](crate::RegistryClient::search),
940/// [`verify_revocation_body`], and [`KeyRevocation::from_body`].
941///
942/// Superseded revocations are queried too: the §4 earliest-boundary
943/// rule needs the whole lineage. **Retracted revocations are queried as
944/// a third status pass** (RFC-ACDP-0013 §8.2 makes `status=retracted`
945/// available for exactly this): if every member of a revocation lineage
946/// has been retracted, no `active`/`superseded` search pass returns
947/// anything to seed the lineage walk from at all — the `retracted` pass
948/// is the only way to discover the `lineage_id` in that case (issue
949/// #226 Phase 4). **A retracted revocation is not merely a seed for
950/// finding the rest of the lineage**: once verified it is pushed into
951/// the returned `Vec` exactly like any other member, so it still
952/// permanently constrains [`effective_boundary`](acdp_types::revocation::effective_boundary)'s earliest-`T` fold.
953/// This is deliberate, fail-closed behavior, not an oversight — a
954/// registry cannot erase a revocation's effect merely by retracting it
955/// — and it matches the private lineage walk underneath, which never
956/// filtered on `registry_state.status` in the first place.
957///
958/// **Error contract.** In addition to the errors documented per-step
959/// above, this function returns
960/// [`AcdpError::SearchTruncated`] in two cases, both hard fail-closed —
961/// never a silently partial `Vec`: (1) any one `(type_form, status)`
962/// search pass exhausts `MAX_SEARCH_PAGES` while a `next_cursor` still
963/// remains, or (2) the total number of distinct candidate
964/// `lineage_id`s discovered across every search pass exceeds
965/// `MAX_LINEAGE_WALKS` before the lineage walk begins. Exactly
966/// `MAX_SEARCH_PAGES` full pages whose last page's `next_cursor` is
967/// `None` is a *complete* result, not truncated, and returns `Ok`
968/// normally.
969///
970/// **`agent_id` is matched by exact bytes, not normalized.**
971/// [`AgentDid`] derives `PartialEq` as plain string equality, and
972/// [`AgentDid::parse`] allows uppercase in the method-specific id —
973/// only the DID *method* is constrained to lowercase (a mismatched
974/// case there is rejected with `SchemaViolation`, not normalized) — so
975/// `did:web:Agents.example.com` and `did:web:agents.example.com` are
976/// both schema-valid and unequal here.
977/// Passing an `agent_id` that differs only in case from the one a body
978/// was actually published under means every candidate is dropped by
979/// filter (1) and this function returns `Ok(vec![])` — a silent "no
980/// revocations found", indistinguishable from the honest empty case.
981/// Pass the exact DID bytes the producer publishes under (e.g. from a
982/// verified body's `agent_id`, not a hand-typed or config-sourced
983/// variant). `agent_id` is schema-parsed at entry so a malformed DID
984/// fails loudly instead of silently returning empty.
985///
986/// **The honest caveat (§8), unchanged by the above:** search is served
987/// by the registry, and a malicious registry can hide a revocation
988/// exactly as it can hide any context — an empty result is *not*
989/// evidence of absence, and a registry colluding with a key thief can
990/// serve the stolen key's contexts while suppressing this signal.
991/// Within the protocol the systemic mitigation is the RFC-ACDP-0009
992/// §2.11 append-only transparency log (RFC-ACDP-0012); until it is
993/// deployed, query more than one vantage where the stakes warrant it,
994/// and remember that revocations are self-contained signed contexts —
995/// out-of-band delivery verifies identically and is the one channel a
996/// registry cannot suppress.
997///
998/// **No independent binding of the returned `ctx_id`.** Each result is
999/// retrieved by the `ctx_id` the search response named; nothing here
1000/// re-derives or independently confirms that id. The publisher filter
1001/// above catches a registry substituting a *different producer's*
1002/// revocation, but a registry that returns `agent_id`'s own *wrong*
1003/// revocation body for a listed `ctx_id` is not detected by this
1004/// function. That is benign today only because the §4 earliest-`T`
1005/// rule makes any genuine revocation of `agent_id` conservative to
1006/// apply regardless of which one is returned — a future reader must
1007/// not assume the id-to-body binding is actually checked.
1008///
1009/// Registry-attested revocations (§6) are published under the
1010/// *registry's* DID, not the producer's, so this producer-scoped query
1011/// never returns them (filter 2 above, in addition to the search scope
1012/// itself) — call [`find_registry_attested_revocations`] for those.
1013///
1014/// **Beyond the search passes above, every distinct `lineage_id` named
1015/// by a search match is also walked** via the private lineage-walking
1016/// helper behind [`find_revocations_in_lineage`] — see that function's
1017/// doc for the full rationale. This recovers a lineage member a
1018/// `status=`-scoped search pass cannot or does not enumerate (mixed
1019/// `expired`/`Other` statuses, or a member a search pass has been made
1020/// structurally blind to) as long as at least one member of the same
1021/// lineage is still visible to search. Walked members are deduped
1022/// against search-found ones by `ctx_id` and pass through the same
1023/// publisher/trust-class filter as above.
1024///
1025/// **A failure walking any one lineage aborts the whole call.** If
1026/// `walk_revocation_lineage` errors for a given `lineage_id` — empty
1027/// response, or (per issue #226 Phase 3) a registry-served member set
1028/// that does not include the `ctx_id` a search match actually named
1029/// for it — that error propagates via `?` and this function returns
1030/// `Err` rather than a partial `Vec`. A caller cannot distinguish a
1031/// `Vec` that omits a compromise window from one that is complete, and
1032/// that `Vec` feeds straight into [`effective_boundary`](acdp_types::revocation::effective_boundary)'s `.min()`
1033/// over `compromised_since` (RFC-ACDP-0014 §4): silently dropping a
1034/// member with an earlier boundary would move the effective boundary
1035/// later, and dropping an entire lineage would make revocation
1036/// enforcement inert for it — exactly the "quietly shrink a compromise
1037/// window" outcome §4 forbids, and exactly what a hostile registry
1038/// gets for free by failing one lineage walk if this were scoped
1039/// instead of fatal. This is not a new abort lever either: the same
1040/// call already aborts unconditionally on `client.search` and
1041/// `client.retrieve` a few lines above, so failing closed here adds no
1042/// availability exposure this function does not already have.
1043///
1044/// **Extended by issue #248 Phase 1 ("D5") to the per-candidate
1045/// verification calls this function makes directly**, not only to
1046/// `walk_revocation_lineage`'s own errors above: a *transient*
1047/// [`verify_revocation_body`] failure (the candidate's DID host is
1048/// unreachable, rate-limited, or otherwise could not be asked) now
1049/// propagates via the same reasoning — an unresolved candidate could
1050/// have named an earlier `compromised_since` than anything already
1051/// found, so silently continuing is the same "quietly shrink a
1052/// compromise window" outcome this paragraph already rejects for a
1053/// failed lineage walk. Only a *permanent* verification failure (bad
1054/// signature, hash mismatch, schema violation, or a DID that resolves
1055/// but denies the key) is still dropped with a `tracing::warn!` — see
1056/// `walk_revocation_lineage`'s doc above for the full DoS argument for
1057/// why that narrower case remains safe to drop.
1058pub async fn find_revocations(
1059    client: &RegistryClient,
1060    resolver: &WebResolver,
1061    agent_id: &AgentDid,
1062) -> Result<Vec<KeyRevocation>, AcdpError> {
1063    let lookup = find_revocations_lookup(client, resolver, agent_id).await?;
1064    refuse_deferred(&lookup.deferred)?;
1065    Ok(lookup.revocations)
1066}
1067
1068/// One discovery lookup's result: the verified revocations found and, when
1069/// a fresh cache marker answered the lookup instead of the network, the
1070/// wall-clock time that cached complete absence was observed.
1071#[derive(Debug)]
1072pub(crate) struct Lookup {
1073    pub(crate) revocations: Vec<KeyRevocation>,
1074    /// Candidates signed by a keyless key, not yet verified (RFC-ACDP-0021
1075    /// §9.3): the caller evaluates them under its keyless policy.
1076    pub(crate) deferred: Vec<FullContext>,
1077    pub(crate) cached_since: Option<std::time::SystemTime>,
1078}
1079
1080impl Lookup {
1081    fn live(revocations: Vec<KeyRevocation>, deferred: Vec<FullContext>) -> Self {
1082        Self {
1083            revocations,
1084            deferred,
1085            cached_since: None,
1086        }
1087    }
1088
1089    fn cached(observed: std::time::SystemTime) -> Self {
1090        Self {
1091            revocations: Vec::new(),
1092            deferred: Vec::new(),
1093            cached_since: Some(observed),
1094        }
1095    }
1096}
1097
1098/// [`find_revocations`] reporting whether a cached absence answered it.
1099pub(crate) async fn find_revocations_lookup(
1100    client: &RegistryClient,
1101    resolver: &WebResolver,
1102    agent_id: &AgentDid,
1103) -> Result<Lookup, AcdpError> {
1104    // Schema-validate the caller's DID before it is promoted from an
1105    // opaque search-filter string into an equality operand (filter 1
1106    // below) — see the exact-byte-match caveat in the doc above. This
1107    // does not normalize case; it only rejects a malformed DID loudly
1108    // instead of silently yielding an empty result.
1109    let agent_id = AgentDid::parse(agent_id.as_str())?;
1110
1111    // Issue #257: a per-vantage freshness marker, when a cache is attached
1112    // AND still fresh, skips this entire lookup — zero requests issued.
1113    // Keyed by `(vantage, agent_id, ProducerSigned)`, never by anything
1114    // registry-specific: this function's scope is the producer itself.
1115    // `fresh_marker` is a lock-free fast path when no cache is attached or
1116    // `freshness == Duration::ZERO` (the default from both
1117    // `RevocationDiscovery` named constructors), so this costs nothing in
1118    // the common case. Facts are handled separately, downstream, at
1119    // `verify_retrieved`'s `effective` merge — never folded in here (see
1120    // the wave plan's B1: this function's own failure paths below set
1121    // nothing but `Err`, and if facts rode in this return value they would
1122    // vanish on exactly the failure an attacker can induce).
1123    let vantage = client.authority();
1124    // N7 (fresh-Opus review of Phase 2): both the marker check and the
1125    // fact record below are gated on `Some(vantage)`, so a client whose
1126    // base URL has no resolvable host (`authority()` returns `None`)
1127    // silently makes caching a no-op for this call — unreachable in
1128    // practice (`RegistryClient` is always built from a parsed URL), but
1129    // worth signaling rather than leaving unsignalled.
1130    #[cfg(feature = "tracing")]
1131    if vantage.is_none() && client.revocation_cache().is_some() {
1132        tracing::warn!(
1133            "find_revocations: a RevocationCache is attached but client.authority() is None \
1134             — caching is silently inert for this call"
1135        );
1136    }
1137    if let (Some((cache, freshness)), Some(vantage)) =
1138        (client.revocation_cache(), vantage.as_deref())
1139    {
1140        if let Some(observed) = cache.fresh_marker(
1141            vantage,
1142            agent_id.as_str(),
1143            RevocationTrustClass::ProducerSigned,
1144            freshness,
1145        ) {
1146            return Ok(Lookup::cached(observed));
1147        }
1148    }
1149
1150    let keep = |rev: &KeyRevocation| {
1151        // Re-check query scope and trust class on the verified body — do
1152        // not trust `resp.matches`, and do not accept a producer claiming
1153        // to be a registry (RFC-ACDP-0014 §4, §13).
1154        rev.publisher == agent_id && rev.trust_class == RevocationTrustClass::ProducerSigned
1155    };
1156    let on_drop = |_rev: &KeyRevocation, _ctx_id: &CtxId, _site: DropSite| {
1157        #[cfg(feature = "tracing")]
1158        if _site == DropSite::LineageWalk {
1159            tracing::warn!(
1160                publisher = %_rev.publisher,
1161                trust_class = ?_rev.trust_class,
1162                ctx_id = %_ctx_id,
1163                filter = if _rev.publisher != agent_id {
1164                    "publisher_scope"
1165                } else {
1166                    "trust_class"
1167                },
1168                "find_revocations: dropped lineage-walk candidate outside query scope/trust class"
1169            );
1170        } else {
1171            tracing::warn!(
1172                publisher = %_rev.publisher,
1173                trust_class = ?_rev.trust_class,
1174                ctx_id = %_ctx_id,
1175                filter = if _rev.publisher != agent_id {
1176                    "publisher_scope"
1177                } else {
1178                    "trust_class"
1179                },
1180                "find_revocations: dropped candidate outside query scope/trust class"
1181            );
1182        }
1183    };
1184
1185    let (revocations, deferred) = discover_revocations(
1186        client,
1187        resolver,
1188        DiscoveryParams {
1189            search_agent_id: agent_id.as_str(),
1190            fn_name: "find_revocations",
1191            identity_label: "agent_id",
1192            identity_value: agent_id.as_str(),
1193        },
1194        &keep,
1195        &on_drop,
1196    )
1197    .await?;
1198
1199    // Issue #257: this point is reached only on a fully successful,
1200    // untruncated discovery (every early-return above is an `Err`), so
1201    // recording here is exactly "mint a marker only on full success" by
1202    // construction. `record_success` dedups `revocations` into the cached
1203    // fact set via `KeyRevocation`'s `Eq` and caps growth per entry — see
1204    // `crate::revocation_cache`.
1205    //
1206    // A lookup that set keyless candidates aside is not a complete answer
1207    // on its own: no marker, so a later lookup re-fetches them rather than
1208    // reading an absence from the cache.
1209    if let (Some((cache, _freshness)), Some(vantage), true) = (
1210        client.revocation_cache(),
1211        vantage.as_deref(),
1212        deferred.is_empty(),
1213    ) {
1214        cache.record_success(
1215            vantage,
1216            agent_id.as_str(),
1217            RevocationTrustClass::ProducerSigned,
1218            &revocations,
1219        );
1220    }
1221    Ok(Lookup::live(revocations, deferred))
1222}
1223
1224/// Discover a producer's **registry-attested** (§6) key revocations —
1225/// the RFC-ACDP-0014 §8 second query that [`find_revocations`]'s own
1226/// doc points callers at, since a producer-scoped search structurally
1227/// cannot return them (they are published under the *registry's*
1228/// `agent_id`, not the producer's).
1229///
1230/// Fetches the registry's own capabilities document (**exactly once**,
1231/// before the search loop — [`RegistryClient::capabilities`] issues a
1232/// fresh network round-trip on every call, so hoisting it above the
1233/// type-form × status loop bounds total cost to one capabilities fetch,
1234/// plus up to `6 * MAX_SEARCH_PAGES` search round-trips (three statuses
1235/// — `active`, `superseded`, `retracted` — × two type-forms = six
1236/// distinct `(type_form, status)` pairs, each independently bounded by
1237/// its own `MAX_SEARCH_PAGES` page cap), plus up to
1238/// `MAX_LINEAGE_WALKS` lineage fetches from the walk below, each capped
1239/// at 1 MB — rather than one capabilities fetch per candidate), then searches
1240/// `agent_id=<capabilities.registry_did>` for `key-revocation` (and the
1241/// §10 interim `acdp:key-revocation`) contexts, retrieves each match,
1242/// and keeps the ones that:
1243///
1244/// 1. Verify per §5 ([`verify_revocation_body`]) — schema, hash
1245///    recomputation, DID resolution, signature, and the §5 step 2
1246///    not-self-signed check;
1247/// 2. Name `controller` in `revoked_key_controller` (exact
1248///    [`AgentDid`] equality — see [`find_revocations`]'s exact-byte-match
1249///    caveat, which applies here identically); and
1250/// 3. Pass [`KeyRevocation::cross_check_registry_binding`] against the
1251///    authority this client actually talks to and the capabilities
1252///    document just fetched — RFC-ACDP-0014 §6 step 2 (`publisher`
1253///    must equal `capabilities.registry_did`) plus the RFC-ACDP-0011
1254///    §7 step 3 / RFC-ACDP-0012 §9.3 step 3 house binding (`publisher`
1255///    must equal `did:web:<serving_authority>`), confirming that
1256///    `publisher` really is the specific registry this client is
1257///    talking to, not merely *some* identity that appears in the
1258///    search response claiming registry standing over `controller`'s
1259///    key.
1260///
1261///    This function is what closes the *discovery* gap
1262///    [`find_revocations`]'s trust-class filter opened: that filter
1263///    excludes registry-attested revocations from its results
1264///    entirely (by design — a producer-scoped search cannot
1265///    distinguish a genuine one from a forgery), so without a
1266///    dedicated registry-scoped query a caller would never see a
1267///    genuine one at all. Filter (3) here then narrows *this*
1268///    function's own results — and narrows a different thing than it
1269///    might look like: it does **not** stop a producer forging a
1270///    registry attestation of its own key by setting `agent_id` to the
1271///    registry's DID. That forgery is already impossible one step
1272///    earlier — [`verify_revocation_body`] runs `Verifier::verify_body`,
1273///    which resolves `body.agent_id`'s DID document and verifies the
1274///    signature against it, so a body claiming `agent_id = <registry
1275///    DID>` cannot exist unless the registry's own key actually signed
1276///    it. What filter (3) actually rejects is a **genuinely signed
1277///    body published under some third DID** — another registry, or a
1278///    producer emitting the §4-illegal `agent_id=Q,
1279///    revoked_key_controller=P` shape — that a hostile or compromised
1280///    registry lists in the `agent_id=<registry_did>` search response
1281///    it serves to this client. That is the exact analog of
1282///    [`find_revocations`]'s filter 1, and it is real and load-bearing:
1283///    without it, this function would trust `publisher` merely because
1284///    *some* validly-signed body appeared among the search results,
1285///    rather than confirming it is signed by the one registry this
1286///    client actually talks to.
1287///
1288/// As with [`find_revocations`], candidates dropped by (2) or (3), or
1289/// that fail §5 outright with a **permanent** error
1290/// (`AcdpError::is_transient() == false`), are omitted rather than
1291/// surfaced as errors — a single bad or off-scope body must not poison
1292/// the whole discovery result. With the `tracing` feature enabled,
1293/// each drop is logged via `tracing::warn!` naming the publisher,
1294/// controller, and `ctx_id`.
1295///
1296/// **Propagates, rather than swallows, a `capabilities()` error.**
1297/// `CapabilitiesDocument.registry_did` is a required, non-`Option`
1298/// `String` with no `#[serde(default)]`
1299/// (`crates/acdp-types/src/capabilities.rs`), and
1300/// [`RegistryClient::capabilities`] runs
1301/// `acdp_validation::validate_capabilities` — which parses it with
1302/// [`acdp_types::primitives::AgentDid::parse_web`] — before returning.
1303/// A registry that omits `registry_did` fails deserialization; one
1304/// sending `""` or a non-`did:web` value fails `parse_web`. Either way
1305/// `capabilities()` already returns `Err`, so there is no "missing
1306/// `registry_did`" state for this function to special-case — it simply
1307/// propagates whatever `capabilities()` returns via `?`, rather than
1308/// mapping a failure into a silent empty vec.
1309///
1310/// **The same §8 honest caveat as [`find_revocations`] applies**: search
1311/// is registry-served, so an empty result is not evidence of absence.
1312///
1313/// **Retracted revocations are queried as a third status pass**, and
1314/// **the same [`AcdpError::SearchTruncated`] error contract applies**,
1315/// as [`find_revocations`] — see that function's doc for the exact
1316/// truncation and completeness boundary (issue #226 Phase 4). As with
1317/// [`find_revocations`], a retracted revocation is not seed-only: once
1318/// verified it is pushed into the returned `Vec` like any other member
1319/// and still permanently constrains [`effective_boundary`](acdp_types::revocation::effective_boundary)'s
1320/// earliest-`T` fold — a registry cannot erase its effect by retracting
1321/// it.
1322///
1323/// **Beyond the search passes above, every distinct `lineage_id` named
1324/// by a search match is also walked** via the private lineage-walking
1325/// helper behind [`find_revocations_in_lineage`] — see that function's
1326/// doc for the full rationale. Walked members are deduped against
1327/// search-found ones by `ctx_id` and pass through the same
1328/// controller/registry-binding filter as above.
1329///
1330/// **Cost note for callers verifying many contexts.** The single
1331/// capabilities fetch above is hoisted *within* one call, but
1332/// [`RegistryClient::capabilities`] issues a fresh network round-trip
1333/// on every call to *this* function too — nothing here caches it across
1334/// calls. A caller verifying many contexts against the same registry in
1335/// a loop should hoist its own call to this function (or to
1336/// `capabilities()` directly) above that loop rather than calling it
1337/// once per context. An overload taking a pre-fetched capabilities
1338/// document can be added additively later if that turns out to matter
1339/// in practice.
1340///
1341/// **A failure walking any one lineage aborts the whole call** — see
1342/// [`find_revocations`]'s doc for the identical rationale (issue #226
1343/// Phase 3): a partial `Vec` is indistinguishable from a complete one
1344/// to the caller, and feeds the same `effective_boundary` `.min()` this
1345/// crate uses to enforce RFC-ACDP-0014 §4, so a dropped lineage must
1346/// fail the call rather than silently narrow the result. The same doc's
1347/// issue #248 Phase 1 ("D5") extension applies here identically: a
1348/// *transient* per-candidate [`verify_revocation_body`] failure in the
1349/// search loop below also propagates as `Err` rather than being
1350/// silently dropped — only a *permanent* one is dropped with a
1351/// `tracing::warn!`.
1352pub async fn find_registry_attested_revocations(
1353    client: &RegistryClient,
1354    resolver: &WebResolver,
1355    controller: &AgentDid,
1356) -> Result<Vec<KeyRevocation>, AcdpError> {
1357    find_registry_attested_lookup(client, resolver, controller)
1358        .await
1359        .map(|l| l.revocations)
1360}
1361
1362/// [`find_registry_attested_revocations`] reporting whether a cached
1363/// absence answered it.
1364pub(crate) async fn find_registry_attested_lookup(
1365    client: &RegistryClient,
1366    resolver: &WebResolver,
1367    controller: &AgentDid,
1368) -> Result<Lookup, AcdpError> {
1369    let controller = AgentDid::parse(controller.as_str())?;
1370
1371    // Issue #257: the marker check MUST precede `client.capabilities()`
1372    // below — that fetch is unconditional and otherwise costs one request
1373    // on every "suppressed" call. Keyed by `(vantage, controller,
1374    // RegistryAttested)` — `controller` is the producer/controller DID
1375    // this call was asked about, deliberately NEVER the registry's own DID
1376    // (`capabilities.registry_did`, not yet even fetched at this point):
1377    // keying by the search identity instead would let one marker suppress
1378    // discovery for every producer at this registry, not just this one.
1379    // See `find_revocations` above for the shared rationale (fast path
1380    // when unattached or `freshness == Duration::ZERO`; facts are handled
1381    // downstream in `verify_retrieved`, never folded into this return
1382    // value).
1383    let vantage = client.authority();
1384    // N7 (fresh-Opus review of Phase 2): see `find_revocations`'s identical
1385    // note — both the marker check and the fact record below are gated on
1386    // `Some(vantage)`, so a client with no resolvable authority silently
1387    // makes caching a no-op for this call.
1388    #[cfg(feature = "tracing")]
1389    if vantage.is_none() && client.revocation_cache().is_some() {
1390        tracing::warn!(
1391            "find_registry_attested_revocations: a RevocationCache is attached but \
1392             client.authority() is None — caching is silently inert for this call"
1393        );
1394    }
1395    if let (Some((cache, freshness)), Some(vantage)) =
1396        (client.revocation_cache(), vantage.as_deref())
1397    {
1398        if let Some(observed) = cache.fresh_marker(
1399            vantage,
1400            controller.as_str(),
1401            RevocationTrustClass::RegistryAttested,
1402            freshness,
1403        ) {
1404            return Ok(Lookup::cached(observed));
1405        }
1406    }
1407
1408    // Fetched exactly once, outside the type-form × status loop below —
1409    // see the doc above for why hoisting this is required, not
1410    // stylistic.
1411    let caps = client.capabilities().await?;
1412    let registry_agent_id = AgentDid::parse(caps.registry_did.as_str())?;
1413    let serving_authority = client
1414        .authority()
1415        .ok_or_else(|| AcdpError::SchemaViolation("registry client base URL has no host".into()))?;
1416
1417    let keep = |rev: &KeyRevocation| {
1418        rev.revoked_key_controller == controller
1419            && rev
1420                .cross_check_registry_binding(&serving_authority, &caps.registry_did)
1421                .is_ok()
1422    };
1423    let on_drop = |_rev: &KeyRevocation, _ctx_id: &CtxId, _site: DropSite| {
1424        #[cfg(feature = "tracing")]
1425        if _site == DropSite::LineageWalk {
1426            tracing::warn!(
1427                publisher = %_rev.publisher,
1428                controller = %_rev.revoked_key_controller,
1429                ctx_id = %_ctx_id,
1430                "find_registry_attested_revocations: dropped lineage-walk \
1431                 candidate outside controller scope or failing \
1432                 registry-binding check"
1433            );
1434        } else {
1435            tracing::warn!(
1436                publisher = %_rev.publisher,
1437                controller = %_rev.revoked_key_controller,
1438                ctx_id = %_ctx_id,
1439                "find_registry_attested_revocations: dropped candidate \
1440                 outside controller scope or failing registry-binding check"
1441            );
1442        }
1443    };
1444
1445    // A keyless key is a producer's key, never a registry's: keyless
1446    // candidates here are outside the registry-attested class.
1447    let (revocations, _keyless) = discover_revocations(
1448        client,
1449        resolver,
1450        DiscoveryParams {
1451            search_agent_id: registry_agent_id.as_str(),
1452            fn_name: "find_registry_attested_revocations",
1453            identity_label: "controller",
1454            identity_value: controller.as_str(),
1455        },
1456        &keep,
1457        &on_drop,
1458    )
1459    .await?;
1460
1461    // Issue #257: reached only on full, untruncated success — see
1462    // `find_revocations`'s identical note above. Keyed by `controller`,
1463    // matching the marker check at the top of this function.
1464    if let (Some((cache, _freshness)), Some(vantage)) =
1465        (client.revocation_cache(), vantage.as_deref())
1466    {
1467        cache.record_success(
1468            vantage,
1469            controller.as_str(),
1470            RevocationTrustClass::RegistryAttested,
1471            &revocations,
1472        );
1473    }
1474    Ok(Lookup::live(revocations, Vec::new()))
1475}
1476
1477#[cfg(test)]
1478mod tests {
1479    use super::*;
1480    use acdp_types::revocation::RevocationTrustClass;
1481    use chrono::TimeZone;
1482
1483    fn rev(fp: &str, t: DateTime<Utc>) -> KeyRevocation {
1484        KeyRevocation {
1485            revoked_key_fingerprint: fp.into(),
1486            compromised_since: t,
1487            reason: None,
1488            revoked_key_id: None,
1489            revoked_key_controller: AgentDid::new("did:web:agents.example.com:p"),
1490            publisher: AgentDid::new("did:web:agents.example.com:p"),
1491            trust_class: RevocationTrustClass::ProducerSigned,
1492            ctx_id: None,
1493        }
1494    }
1495
1496    fn at(s: &str) -> DateTime<Utc> {
1497        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
1498    }
1499
1500    const F: &str = "sha256:139e3940e64b5491722088d9a0d741628fc826e09475d341a780acde3c4b8070";
1501
1502    /// §7 step 2: strictly-before-T verifies with the distinguishable
1503    /// pre-compromise status; §7 step 3: equal-to-T already fails.
1504    #[test]
1505    fn boundary_is_strict() {
1506        let t = at("2026-05-01T00:00:00.000Z");
1507        let revs = [rev(F, t)];
1508        assert_eq!(
1509            classify_under_revocation(&revs, F, Some(at("2026-04-30T23:59:59.999Z"))).unwrap(),
1510            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1511        );
1512        assert!(matches!(
1513            classify_under_revocation(&revs, F, Some(t)),
1514            Err(AcdpError::KeyRevoked(_))
1515        ));
1516    }
1517
1518    /// §7 step 4: no receipt-attested time → fail closed.
1519    #[test]
1520    fn no_receipt_fails_closed() {
1521        let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
1522        assert!(matches!(
1523            classify_under_revocation(&revs, F, None),
1524            Err(AcdpError::KeyRevoked(_))
1525        ));
1526    }
1527
1528    /// A revocation of some OTHER key changes nothing.
1529    #[test]
1530    fn unrelated_fingerprint_is_inert() {
1531        let revs = [rev(F, at("2026-05-01T00:00:00.000Z"))];
1532        let other = "sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f";
1533        assert_eq!(classify_under_revocation(&revs, other, None).unwrap(), None);
1534        assert_eq!(
1535            classify_under_revocation(&[], F, None).unwrap(),
1536            None,
1537            "no known revocations ⇒ inert"
1538        );
1539    }
1540
1541    /// §4 monotonicity: the earliest T across a revocation lineage is
1542    /// effective — a later supersession cannot quietly shrink the
1543    /// window.
1544    #[test]
1545    fn earliest_boundary_wins() {
1546        let early = at("2026-04-01T00:00:00.000Z");
1547        let late = at("2026-05-01T00:00:00.000Z");
1548        let revs = [rev(F, late), rev(F, early)];
1549        // Between the two boundaries: inside the (earliest-T) window.
1550        assert!(matches!(
1551            classify_under_revocation(&revs, F, Some(at("2026-04-15T00:00:00.000Z"))),
1552            Err(AcdpError::KeyRevoked(_))
1553        ));
1554        // Before both: pre-compromise.
1555        assert_eq!(
1556            classify_under_revocation(&revs, F, Some(at("2026-03-01T00:00:00.000Z"))).unwrap(),
1557            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1558        );
1559    }
1560
1561    #[test]
1562    fn pre_compromise_uses_millis() {
1563        // Sub-second boundaries compare at millisecond precision — the
1564        // canonical wire precision (RFC-ACDP-0001 §5.3).
1565        let t = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap()
1566            + chrono::Duration::milliseconds(500);
1567        let revs = [rev(F, t)];
1568        let just_before = t - chrono::Duration::milliseconds(1);
1569        assert_eq!(
1570            classify_under_revocation(&revs, F, Some(just_before)).unwrap(),
1571            Some(KeyAuthorization::HistoricallyAuthorizedPreCompromise)
1572        );
1573    }
1574}