Skip to main content

acdp_client/
verified.rs

1//! VerifiedContext: retrieve + verify in one call.
2
3use super::data_ref::{fetch_and_verify_data_ref, DataRefFetcher};
4use super::registry::{DiscoveryBudget, RegistryClient};
5use acdp_did::WebResolver;
6use acdp_primitives::error::AcdpError;
7use acdp_types::{body::FullContext, primitives::CtxId};
8use acdp_verify::Verifier;
9use std::time::Duration;
10
11/// Consumer-tunable strictness for [`VerifiedContext::fetch_with_policy`],
12/// [`VerifiedContext::fetch_current_with_policy`], and the
13/// `fetch_report*` family ([`VerifiedContext::fetch_report`],
14/// [`VerifiedContext::fetch_report_with_fetcher`],
15/// [`VerifiedContext::fetch_report_diagnose`]). All three surfaces
16/// consult the same policy fields through the same `verify_retrieved`
17/// spine — but they do not always *agree*, because
18/// [`VerifiedContext::fetch_report_diagnose`] differs in more than just
19/// how a failure surfaces:
20///
21/// - [`VerifiedContext::fetch_report`] and
22///   [`VerifiedContext::fetch_report_with_fetcher`] run
23///   `verify_retrieved` directly once their own top-level probes pass,
24///   and surface a phase failure as `Err`.
25/// - [`VerifiedContext::fetch_report_diagnose`] runs its own
26///   independent, strict, assertionMethod-only signature *probe* first
27///   (recorded as `VerificationReport::signature_ok`). That probe has
28///   no historical-key fallback and runs *before* `verify_retrieved` is
29///   ever invoked. If it fails, `diagnose` withholds the
30///   [`VerifiedContext`] handle with `policy_phase_error: None` — the
31///   spine never ran, so there is no phase error to record — even in
32///   cases where `verify_retrieved` itself, as run by `fetch_report`,
33///   would have accepted the key historically under the default
34///   `historical_keys: HistoricalKeyPolicy::AcceptWithReceipt` plus a
35///   verified receipt. Concretely: for a key rotated out of
36///   `assertionMethod` with a valid receipt, `fetch_report` returns
37///   `Ok` with [`KeyAuthorization::HistoricallyAuthorized`], while
38///   `diagnose` returns no handle at all for the same input and policy.
39///   Only once `diagnose`'s own probes all pass does it fall through to
40///   `verify_retrieved` and, from that point on, withhold the handle /
41///   record [`VerificationReport::policy_phase_error`] instead of
42///   returning `Err` — that part of the behavior *is* shared with the
43///   other two.
44///
45/// For ACDP v0.1.0 the verification profile is **always strict**:
46///
47/// - `did:web` is required for every producer identity — enforced
48///   unconditionally by `verify_signature_envelope`
49///   (RFC-ACDP-0001 §5.4), regardless of any policy field.
50/// - Embedded `DataRef` hashes are verified by
51///   [`acdp_validation::validate_body`] whenever `validate_body_schema`
52///   is set.
53///
54/// Only the fields below have real effect in this version; there are no
55/// relaxed-mode `did:web` or embedded-hash knobs.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct VerificationPolicy {
58    /// If true, run [`acdp_validation::validate_body`] (structural
59    /// schema checks plus embedded-`DataRef` hash verification) before
60    /// any cryptographic check. Default `true`. Set `false` only in
61    /// diagnostic paths that want to attempt signature verification
62    /// despite a body known to fail structural checks.
63    ///
64    /// The `fetch_report*` family forces this field off unconditionally
65    /// on the internal policy it derives from the caller's — they run
66    /// `validate_body_structural` (schema only) themselves and record
67    /// per-`DataRef` embedded-hash outcomes in
68    /// [`VerificationReport::data_ref_embedded`] instead of treating a
69    /// mismatch as fatal. This field's value as set by the caller is
70    /// otherwise irrelevant to the report family.
71    pub validate_body_schema: bool,
72
73    /// If true, accept `Status::Other` values (degrade to active per
74    /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
75    /// Default `true`.
76    pub allow_unknown_status: bool,
77
78    /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
79    /// Default [`ReceiptPolicy::VerifyIfPresent`].
80    pub receipts: ReceiptPolicy,
81
82    /// Historical-key handling (ACDP 0.2, WS-B). Default
83    /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
84    pub historical_keys: HistoricalKeyPolicy,
85
86    /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
87    /// RFC-ACDP-0011). Only consulted by
88    /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
89    /// preserves any `lineage_head_receipt` verbatim without verifying
90    /// it. Default [`LineageHeadPolicy::default`]. This is the ONE
91    /// field on this struct with restricted scope — `allow_unknown_status`,
92    /// `receipts`, `historical_keys`, and `revocations` above and below
93    /// are each honored by every entry point that accepts a
94    /// [`VerificationPolicy`], including the `fetch_report*` family.
95    pub lineage_head: LineageHeadPolicy,
96
97    /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
98    /// no known revocations — the phase is inert.
99    pub revocations: RevocationPolicy,
100}
101
102impl Default for VerificationPolicy {
103    fn default() -> Self {
104        Self {
105            validate_body_schema: true,
106            allow_unknown_status: true,
107            receipts: ReceiptPolicy::VerifyIfPresent,
108            historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
109            lineage_head: LineageHeadPolicy::default(),
110            revocations: RevocationPolicy::default(),
111        }
112    }
113}
114
115/// Consumer-held key revocations to enforce during verification
116/// (ACDP 0.3, RFC-ACDP-0014 §7).
117///
118/// [`Self::known`] is **pull-based**: the pipeline does not go looking
119/// for those revocations on its own — the caller supplies the
120/// **verified** revocations it holds (from
121/// [`find_revocations`](crate::revocation::find_revocations),
122/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
123/// an out-of-band channel, or its own indefinite cache — the statement
124/// is permanent, cache accordingly). [`Self::discover`] (RFC-ACDP-0014
125/// §8) is the opt-in complement: when set, `verify_retrieved` itself
126/// runs those same two lookups and unions their result with `known`
127/// (see [`RevocationDiscovery`]). When `known` is empty, `discover` is
128/// `None`, AND no [`crate::RevocationCache`] is attached to the client,
129/// the phase is inert and verification behaves exactly as before
130/// RFC-ACDP-0014.
131///
132/// Issue #257 gives `discover`'s own caching a first-class home:
133/// [`crate::RevocationCache`], attached to the [`crate::RegistryClient`]
134/// passed to `verify_retrieved` via
135/// [`crate::RegistryClient::with_revocation_cache`], persists exactly the
136/// "own indefinite cache" a caller would otherwise have to hand-roll
137/// around `known` — verified revocations discovered on one call are
138/// unioned into every later call against the same client, unconditionally
139/// and indefinitely, independent of [`RevocationDiscovery::freshness`], and
140/// **regardless of whether that later call itself sets `discover`** —
141/// attaching a cache is itself the opt-in (MATERIAL-3, fresh-Opus review
142/// of Phase 2): a call made with `discover: None` still seeds
143/// producer-signed facts from the cache (never registry-attested ones —
144/// see below), which is what extends this anti-rollback protection to
145/// [`VerifiedContext::fetch`]/[`VerifiedContext::fetch_current`] (LIM-2),
146/// the two entry points that can never set `discover` at all. A
147/// registry-attested fact, by contrast, is additionally scoped to the
148/// vantage that minted it (BLOCKER-1, RFC-ACDP-0014 §6): reading it back
149/// through a client talking to a different authority never applies it,
150/// even under [`RevocationDiscovery::include_registry_attested`]. See
151/// that field's doc and `crate::revocation_cache` for the full model,
152/// including the separate, opt-in, TTL-bounded marker that can additionally
153/// skip a repeat lookup.
154///
155/// When the body's signing key matches a supplied revocation, §7
156/// applies: a receipt-attested publish time strictly before the
157/// (earliest, §4) `compromised_since` boundary verifies as
158/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
159/// the boundary, or with no verified receipt to place the context at
160/// all, verification **fails closed** with `key_not_authorized` —
161/// regardless of DID-document state and regardless of the receipt's
162/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
163/// an unverified receipt provides no publish time, so a revoked key's
164/// contexts all fail closed under it.
165///
166/// This applies uniformly to every entry point that *accepts* a
167/// [`VerificationPolicy`] — [`VerifiedContext::fetch_with_policy`],
168/// [`VerifiedContext::fetch_current_with_policy`], and the
169/// `fetch_report*` family (five entry points in total) — since they all
170/// reach this phase through the same internal pipeline. On
171/// [`VerifiedContext::fetch_report_diagnose`] specifically, "fails
172/// closed" means the returned [`VerifiedContext`] handle is withheld
173/// and the cause is recorded in `VerificationReport::policy_phase_error`,
174/// rather than the call returning `Err` — that method never
175/// short-circuits on a policy-phase failure by design.
176///
177/// "Uniformly" has one remaining carve-out, structural rather than a
178/// policy choice: [`VerifiedContext::fetch`] and
179/// [`VerifiedContext::fetch_current`] hardcode
180/// [`VerificationPolicy::default`] and so can never carry a non-empty
181/// `known` or a `discover`; callers wanting either use the
182/// `_with_policy` forms instead. This is recorded as a known limitation
183/// (issue #248 LIM-2) rather than silently true. Phase 2's cache still
184/// extends anti-rollback protection to these two entry points — see
185/// [`Self::discover`]'s doc — but they can never *configure* discovery
186/// themselves.
187///
188/// **Issue #260 closed the sibling limitation, LIM-1.**
189/// [`crate::CrossRegistryResolver::with_revocation_policy`] now injects a
190/// [`RevocationPolicy`] into every node a cross-registry walk verifies,
191/// and [`crate::CrossRegistryResolver::with_revocation_cache`] shares one
192/// [`crate::RevocationCache`] across the walk (walk-scoped by default —
193/// see that method's doc). The resolver derives `receipts` itself, per
194/// node, from that node's advertised capabilities, so it takes a
195/// [`RevocationPolicy`], never a caller-supplied [`VerificationPolicy`] —
196/// see `CrossRegistryResolver::with_revocation_policy`'s doc for why.
197///
198/// Only put revocations here that you have verified (strict body
199/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
200/// decided to act on: producer-signed ones unconditionally;
201/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
202/// by default only for contexts served by or receipted by that same
203/// registry, with corroboration before global application.
204///
205/// [`find_revocations`](crate::revocation::find_revocations) itself
206/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
207/// entries actually published by the queried producer, so §6
208/// registry-attested attestations never arrive through it — obtain
209/// those from
210/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
211/// instead.
212///
213/// `#[non_exhaustive]`: this struct grew a second field
214/// ([`Self::discover`], RFC-ACDP-0014 §8 auto-discovery) after having
215/// shipped with exactly one, and a caller constructing this with a
216/// bare struct literal would otherwise break on every future field the
217/// same way. Construct with [`Self::new`] (equivalent to today's
218/// `RevocationPolicy { known }`) and, when opting into discovery,
219/// [`Self::with_discovery`].
220#[derive(Debug, Clone, PartialEq, Eq, Default)]
221#[non_exhaustive]
222pub struct RevocationPolicy {
223    /// Verified revocations to enforce, matched against the signing
224    /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
225    /// `compromised_since` rule is applied across entries naming the
226    /// same fingerprint, so include *every* revocation of a lineage,
227    /// superseded (and retracted) ones too — a later member can only
228    /// widen the compromise window, never narrow it, and dropping an
229    /// earlier one is exactly how that window gets quietly (and
230    /// wrongly) shrunk. This is no longer an unassisted obligation:
231    /// [`find_revocations`](crate::revocation::find_revocations) and
232    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
233    /// each walk the full lineage of every candidate they find
234    /// (search-visible or not, including all-retracted lineages) and
235    /// already return the complete set for their respective trust
236    /// class; [`find_revocations_in_lineage`](crate::revocation::find_revocations_in_lineage)
237    /// does the same directly from a known `lineage_id`, with no
238    /// producer/trust-class scope filter. Populate `known` from one of
239    /// these rather than hand-assembling a lineage.
240    pub known: Vec<acdp_types::revocation::KeyRevocation>,
241
242    /// RFC-ACDP-0014 §8 auto-discovery configuration. `None` (the
243    /// default) is exactly today's behavior: the caller supplies
244    /// everything via [`Self::known`] and this phase does no network
245    /// I/O of its own. `Some` opts into running
246    /// [`find_revocations`](crate::revocation::find_revocations) and,
247    /// depending on [`RevocationDiscovery::include_registry_attested`],
248    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
249    /// as part of verification, merging their results with
250    /// [`Self::known`].
251    pub discover: Option<RevocationDiscovery>,
252}
253
254impl RevocationPolicy {
255    /// Construct a policy from a caller-supplied revocation set with
256    /// discovery left off (`discover: None`) — the same shape as the
257    /// bare `RevocationPolicy { known }` literal this struct's
258    /// `#[non_exhaustive]` retires.
259    #[must_use]
260    pub fn new(known: Vec<acdp_types::revocation::KeyRevocation>) -> Self {
261        Self {
262            known,
263            discover: None,
264        }
265    }
266
267    /// Opt into RFC-ACDP-0014 §8 auto-discovery on top of any
268    /// caller-supplied [`Self::known`] revocations. See
269    /// [`RevocationDiscovery`] for cost and the required explicit
270    /// trust-class choice.
271    #[must_use]
272    pub fn with_discovery(mut self, discovery: RevocationDiscovery) -> Self {
273        self.discover = Some(discovery);
274        self
275    }
276}
277
278/// RFC-ACDP-0014 §8 revocation auto-discovery configuration.
279///
280/// When set on [`RevocationPolicy::discover`], this instructs
281/// verification to look up revocations itself — via
282/// [`find_revocations`](crate::revocation::find_revocations) and,
283/// when [`Self::include_registry_attested`] is `true`, additionally
284/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
285/// — instead of relying solely on [`RevocationPolicy::known`].
286///
287/// # Cost
288///
289/// Discovery is expensive, and every request it issues is **serial**.
290/// `MAX_SEARCH_PAGES = 10` (`crate::revocation`) bounds search
291/// round-trips *per `(type_form, status)` pair*, and there are **6**
292/// such pairs (2 type forms × 3 statuses) — so up to 60 search
293/// requests, each of which can name up to 100 per-candidate context
294/// retrieves (`GET /contexts/{id}`, capped at 1 MB apiece), for up to
295/// **6,000 + 60 + 100 = 6,160 requests / ~6.1 GB** in the worst case
296/// for *one* of the two discovery functions. The retrieve fan-out is
297/// **not** bounded by `MAX_LINEAGE_WALKS = 100` — that cap is only
298/// checked *after* the retrieves have already gone out. With
299/// [`Self::include_registry_attested`] set, both functions run:
300/// **≈12,321 requests / ~12.2 GB** worst case for the pair (the extra
301/// 1 is the unconditional `client.capabilities()` fetch
302/// `find_registry_attested_revocations` makes).
303///
304/// [`Self::total_timeout`] is an **availability bound, not a bytes or
305/// memory bound**: it stops verification from hanging forever against
306/// a slow or hostile registry, but a hostile registry on a fast link
307/// can still serve gigabytes of legitimate-looking traffic inside the
308/// window — the 1 MB cap applies per request, not in aggregate, and
309/// verified revocations accumulate in a `Vec` for the call's duration.
310/// [`Self::max_requests`] and [`Self::max_bytes`] (issue #258) close
311/// that gap: set either (or both) to bound the two lookups **combined**
312/// — enabling [`Self::include_registry_attested`] does not double the
313/// ceiling — checked **before** each request is issued, so a would-be
314/// request that would exceed the budget is never sent. Exhaustion
315/// raises `AcdpError::RevocationDiscoveryBudgetExceeded` through the
316/// same [`Self::on_failure`] path as `AcdpError::SearchTruncated` — it
317/// is permanent for the same request shape and is never transient.
318/// Both knobs bound **registry** traffic only: DID-document fetches
319/// issued via `WebResolver` are not counted. [`Self::max_bytes`] counts
320/// only **successfully-parsed response bodies** — an error-envelope
321/// read on a non-success response is not charged. [`Self::max_requests`]
322/// has no such exemption: the request slot is reserved *before* the
323/// request is issued, so a 503, a parse failure, or a `PayloadTooLarge`
324/// still consumes it. Leaving both `None` (as both
325/// [`Self::producer_signed_only`] and [`Self::all_trust_classes`] do)
326/// preserves pre-#258 behavior exactly: unbounded requests and bytes,
327/// bounded only by [`Self::total_timeout`].
328///
329/// Issue #257 adds an opt-in cache ([`crate::RevocationCache`], attached
330/// to the [`crate::RegistryClient`] passed in via
331/// [`crate::RegistryClient::with_revocation_cache`]) — but attaching one
332/// does NOT, by itself, turn "re-discovers from scratch" into "sometimes
333/// skips discovery." It is two independent things: verified revocations
334/// ("facts") are always unioned into classification, indefinitely,
335/// regardless of [`Self::freshness`] — a caller verifying many contexts
336/// against the same producer benefits from this immediately, with zero
337/// extra configuration, simply by attaching a cache and reusing the
338/// client. Whether a repeat lookup is skipped entirely (saving requests)
339/// is governed separately by [`Self::freshness`], which defaults to
340/// `Duration::ZERO` — i.e. off. See [`Self::freshness`]'s own doc and
341/// `crate::revocation_cache` for the full model. Without a cache attached
342/// at all, this crate behaves exactly as before #257: every call
343/// re-discovers from scratch, budgeted or not. A caller that does not
344/// want to manage a `RevocationCache` can still discover once itself and
345/// pass the results via [`RevocationPolicy::known`] instead of setting
346/// `discover` on every call — the same hoisting guidance
347/// `crate::revocation`'s `find_registry_attested_revocations` doc already
348/// gives callers of that function directly (see its "Cost note for
349/// callers verifying many contexts").
350///
351/// # Reentrancy
352///
353/// Discovery calls back into verification, and that reentrancy has two
354/// consequences worth stating explicitly rather than leaving implicit:
355///
356/// 1. Each candidate body [`find_revocations`](crate::revocation::find_revocations)
357///    turns up is verified via `Verifier::new(resolver).verify_body` —
358///    **not** `verify_retrieved` — so discovered revocation bodies are
359///    themselves checked *without* revocation checking of their own.
360///    That is defensible under RFC-ACDP-0014 §5 step 1's "currently
361///    authorized key," but it is an assumption this type is making on
362///    the caller's behalf, not an accident.
363/// 2. Verifying a `key-revocation` context now *also* triggers
364///    discovery against the same producer, so the revocation-fetch path
365///    itself becomes fragile under [`DiscoveryFailurePolicy::FailClosed`]:
366///    a producer whose revocation search is briefly unreachable can no
367///    longer be verified as revoked, either.
368///
369/// # No `Default`
370///
371/// This type deliberately has **no [`Default`] impl** — construct it
372/// via [`Self::producer_signed_only`] or [`Self::all_trust_classes`].
373/// RFC-ACDP-0014 §6's "lost-everything" fallback means a producer that
374/// has lost every key it could sign a revocation with can *only* be
375/// revoked registry-attested — so the catastrophic case is exactly the
376/// one a silently-defaulted-off trust class would skip. A quiet
377/// `Default::default()` that leaves `include_registry_attested: false`
378/// would make that skip invisible at every call site; forcing a named
379/// constructor puts the choice at the type level instead, where a
380/// reviewer (and `git grep`) can see it. Callers protecting against
381/// key loss, or otherwise unwilling to assume a producer always
382/// retains signing capacity, MUST use [`Self::all_trust_classes`].
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384#[non_exhaustive]
385pub struct RevocationDiscovery {
386    /// Whether to also run
387    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
388    /// (the §6 registry-attested trust class), in addition to the
389    /// producer-signed search every discovery configuration runs.
390    /// Requires the registry to serve `/.well-known/acdp.json`; under
391    /// [`DiscoveryFailurePolicy::FailClosed`] a registry that serves no
392    /// capabilities document fails verification when this is `true`.
393    pub include_registry_attested: bool,
394    /// What to do when discovery itself fails (a transient transport
395    /// error from either search, or the search-safety-cap error
396    /// `AcdpError::SearchTruncated`). Default [`DiscoveryFailurePolicy::FailClosed`].
397    ///
398    /// **`SearchTruncated` and a transport error (e.g. a 503) are NOT
399    /// equivalent, even though both take this same `on_failure` path.**
400    /// `SearchTruncated` means "this producer has more revocations than
401    /// we will page through" (`MAX_SEARCH_PAGES`) — an
402    /// **attacker-inducible security downgrade**, since a hostile
403    /// producer or registry can pad the result set specifically to
404    /// exhaust the page cap and hide a real revocation from discovery.
405    /// A 503 is an ordinary availability blip. **A third case is
406    /// attacker-inducible the same way `SearchTruncated` is (issue
407    /// #258):** `AcdpError::RevocationDiscoveryBudgetExceeded`, raised
408    /// when [`Self::max_requests`] or [`Self::max_bytes`] is exhausted —
409    /// a hostile registry that learns a caller's budget can pad
410    /// harmless-looking traffic specifically to exhaust it before a
411    /// real revocation is found, just as padding the result set exhausts
412    /// `MAX_SEARCH_PAGES`. Under [`DiscoveryFailurePolicy::ProceedWithKnown`]
413    /// all three are treated the same way (proceed on
414    /// [`RevocationPolicy::known`] alone, record the failure) — choose
415    /// `ProceedWithKnown` knowing it also waives truncation and budget
416    /// exhaustion, not just transient unavailability.
417    pub on_failure: DiscoveryFailurePolicy,
418    /// Wall-clock budget for the whole discovery step (both searches,
419    /// if [`Self::include_registry_attested`] is set). An **availability**
420    /// bound only — see the type-level cost section above. Matches this
421    /// crate's existing `ResolverOptions::total_timeout` precedent
422    /// (`crate::cross_registry`), defaulting to the same 30 s rather
423    /// than exceeding it on the core verify path.
424    ///
425    /// Enforced via [`tokio::time::timeout`], which requires the
426    /// executing Tokio runtime to have its **time driver enabled**
427    /// (`#[tokio::main]` and `#[tokio::test]` enable it by default;
428    /// a hand-built `Builder::new_current_thread()` runtime does
429    /// **not** unless `.enable_time()` or `.enable_all()` is called).
430    /// Calling `verify_retrieved` with `discover: Some(..)` from a
431    /// runtime without the time driver **panics** — it does not
432    /// return `Err` — the same requirement `ResolverOptions::total_timeout`
433    /// (`crate::cross_registry`) already carries on its opt-in walk,
434    /// but here it sits on the core verify path whenever discovery is
435    /// configured, not just on an explicit cross-registry walk.
436    pub total_timeout: Duration,
437    /// Issue #258: cap on the total number of registry requests the
438    /// discovery step may issue, **combined across both lookups** (the
439    /// producer-signed search always, plus the registry-attested search
440    /// when [`Self::include_registry_attested`] is set) — not a ceiling
441    /// per lookup, so turning on the second trust class does not double
442    /// the allowance. `None` (the default from both named constructors)
443    /// is unbounded, matching every version before #258. Checked
444    /// **before** each request is issued (`RegistryClient::capabilities`,
445    /// `::retrieve`, `::lineage`, `::search`); exceeding it raises
446    /// `AcdpError::RevocationDiscoveryBudgetExceeded` through
447    /// [`Self::on_failure`], the same path `AcdpError::SearchTruncated`
448    /// already takes. Counts registry requests only — DID-document
449    /// fetches via `WebResolver` are not counted.
450    pub max_requests: Option<std::num::NonZeroUsize>,
451    /// Issue #258: cap on the cumulative bytes of *successfully-parsed*
452    /// response bodies the discovery step may read, **combined across
453    /// both lookups**, same combination rule as [`Self::max_requests`].
454    /// `None` (the default from both named constructors) is unbounded,
455    /// matching every version before #258. Checked **before** each
456    /// request is issued, using the running total from requests that
457    /// already completed — the size of an in-flight request cannot be
458    /// known (and therefore reserved) in advance, so **up to two**
459    /// requests can push the total past `max_bytes` before the next
460    /// check observes the overrun: the two trust-class lookups run
461    /// concurrently under `tokio::try_join!`, and both can pass a
462    /// not-yet-updated check before either's response is recorded — see
463    /// [`Self::max_requests`]'s doc for the same race on the request
464    /// count, where it is closed by an atomic reservation; there is no
465    /// equivalent reservation for bytes, since a response's size is not
466    /// known until after it is read. Counts a non-success response's
467    /// error-envelope read *not at all* — only bytes read on the
468    /// success path are charged.
469    ///
470    /// `Some(0)` is representable (unlike [`Self::max_requests`], which
471    /// is guarded by `NonZeroUsize`) and is not special-cased: it trips
472    /// the `>=` check on the very first request of either lookup,
473    /// before that request is ever issued, so discovery fails
474    /// immediately with zero registry traffic. This is a deliberate
475    /// consequence of keeping this field a plain `u64` (matching the
476    /// wave plan's chosen types) rather than a reason to reach for
477    /// `NonZeroU64`.
478    pub max_bytes: Option<u64>,
479    /// Issue #257: how long a discovery-freshness marker stays valid on
480    /// a [`crate::RevocationCache`] attached to the [`crate::RegistryClient`]
481    /// passed to `verify_retrieved` (via
482    /// [`crate::RegistryClient::with_revocation_cache`]). A marker records
483    /// "vantage V completed a full, untruncated discovery for this
484    /// producer/trust-class at time T"; while one is within `freshness` of
485    /// T, that lookup is skipped entirely (zero registry requests) rather
486    /// than merely supplemented.
487    ///
488    /// Default `Duration::ZERO` from both named constructors — markers
489    /// never suppress a lookup unless a caller explicitly raises this
490    /// above zero. This is the safe default per RFC-ACDP-0014 §8's own
491    /// warning ("absence of search results is not evidence of absence"): a
492    /// cached *absence* is not licensed the way a cached, verified
493    /// revocation is (§7:114 licenses only the latter, indefinitely). No
494    /// cache attached makes this field inert regardless of its value.
495    ///
496    /// Distinct from — and orthogonal to — caching verified revocations
497    /// themselves ("facts"), which a [`crate::RevocationCache`] does
498    /// **unconditionally** and **indefinitely** whenever one is attached,
499    /// independent of this field: facts are always unioned into
500    /// classification (never gated behind `freshness`), because a
501    /// revocation is monotone (more revocations ⇒ an earlier effective
502    /// boundary ⇒ strictly more fail-closed verdicts), so seeding from
503    /// them can only tighten a verdict, never loosen one. `freshness`
504    /// governs only whether a lookup that would otherwise re-confirm "no
505    /// NEW revocation" is skipped. See `crate::revocation_cache` for the
506    /// full two-object model.
507    ///
508    /// That "unconditionally" is exact for a producer-signed fact (§8:
509    /// self-contained, applies at any vantage) but is scoped for a
510    /// registry-attested one (§6): the cache additionally filters those to
511    /// the vantage that minted them, so attaching one cache to clients for
512    /// two different registries does not let registry A's attestation
513    /// apply to a context served by registry B.
514    ///
515    /// A recommended ceiling, not enforced: RFC-ACDP-0006 §4.2 caps
516    /// `WebResolver`'s own DID-document cache TTL at 3600 s, and that is a
517    /// reasonable order-of-magnitude anchor for this field too — this
518    /// crate already accepts bounded key-material staleness at that
519    /// order. Left unenforced deliberately: a hard cap on a knob whose
520    /// safe default is `ZERO` would add a failure mode without adding
521    /// safety.
522    pub freshness: Duration,
523}
524
525impl RevocationDiscovery {
526    /// Discover producer-signed revocations only
527    /// (`include_registry_attested: false`). Cheapest of the two
528    /// constructors, and the default choice for callers that are not
529    /// specifically defending against a producer that has lost every
530    /// signing key — see the "No `Default`" section above for who must
531    /// NOT stop here.
532    #[must_use]
533    pub fn producer_signed_only() -> Self {
534        Self {
535            include_registry_attested: false,
536            on_failure: DiscoveryFailurePolicy::FailClosed,
537            total_timeout: Duration::from_secs(30),
538            max_requests: None,
539            max_bytes: None,
540            freshness: Duration::ZERO,
541        }
542    }
543
544    /// Discover both trust classes: producer-signed AND registry-attested
545    /// (`include_registry_attested: true`). Required to catch RFC-ACDP-0014
546    /// §6's "lost-everything" fallback, where a producer with no signing
547    /// key left can only be revoked registry-attested. Requires the
548    /// registry to serve a capabilities document.
549    #[must_use]
550    pub fn all_trust_classes() -> Self {
551        Self {
552            include_registry_attested: true,
553            on_failure: DiscoveryFailurePolicy::FailClosed,
554            total_timeout: Duration::from_secs(30),
555            max_requests: None,
556            max_bytes: None,
557            freshness: Duration::ZERO,
558        }
559    }
560}
561
562/// What to do when RFC-ACDP-0014 §8 auto-discovery itself fails (a
563/// transient search/lookup error, or the discovery search functions'
564/// own safety-cap error).
565#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
566#[non_exhaustive]
567pub enum DiscoveryFailurePolicy {
568    /// Treat a discovery failure as a verification failure
569    /// (`AcdpError::RevocationDiscoveryFailed`). Default — matches this
570    /// crate's existing bias (§7's own lineage-walk and
571    /// verification-failure paths already fail closed; see
572    /// `crate::revocation`'s doc for the parallel reasoning) that an
573    /// authorization phase which could not run is not the same thing
574    /// as one that ran and found nothing.
575    #[default]
576    FailClosed,
577    /// Proceed using only [`RevocationPolicy::known`] when discovery
578    /// fails, silently dropping whatever discovery could not complete.
579    /// Use only when availability matters more than catching a
580    /// revocation that discovery would otherwise have found.
581    ProceedWithKnown,
582}
583
584/// What RFC-ACDP-0014 §8 auto-discovery actually did, distinguishing
585/// "this trust class was not queried" from "it was queried and found
586/// nothing" — the same distinction
587/// [`RevocationDiscovery`]'s no-`Default` design protects at the
588/// config level, carried through to the outcome.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590#[non_exhaustive]
591pub struct DiscoveryOutcome {
592    /// Count of producer-signed revocations discovery found.
593    pub producer_signed: usize,
594    /// Count of registry-attested revocations discovery found, or
595    /// `None` if [`RevocationDiscovery::include_registry_attested`]
596    /// was `false` and that trust class was never queried.
597    pub registry_attested: Option<usize>,
598}
599
600/// How to treat the optional `registry_receipt` on retrieval
601/// (RFC-ACDP-0010).
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
603pub enum ReceiptPolicy {
604    /// Skip receipt verification entirely (0.1.0 behavior). The
605    /// receipt value is still preserved verbatim on the context.
606    Ignore,
607    /// Verify the receipt when one is present; absence is not an
608    /// error (the registry may simply be a 0.1.0 registry). Default.
609    #[default]
610    VerifyIfPresent,
611    /// Fail closed unless a receipt is present AND verifies. Use when
612    /// the deployment requires audit-grade provenance — registry
613    /// claims (`ctx_id`, `created_at`, `origin_registry`) are
614    /// assertions, not proofs, without a receipt.
615    ///
616    /// Honored identically by every entry point that accepts a
617    /// [`VerificationPolicy`] — `fetch_with_policy`,
618    /// `fetch_current_with_policy` (via [`LineageHeadPolicy::receipts`]),
619    /// and the `fetch_report*` family. On
620    /// [`VerifiedContext::fetch_report_diagnose`] the failure surfaces as
621    /// a withheld handle plus `VerificationReport::policy_phase_error`,
622    /// not an `Err` — see that method's doc.
623    Require,
624}
625
626/// How to treat the optional `lineage_head_receipt` on
627/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
628///
629/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
630/// two numeric knobs are the RFC's consumer-side parameters:
631///
632/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
633///   receipt whose `as_of` is further in the future **fails
634///   verification** (`invalid_receipt`, fixture `lhr-004`). RFC
635///   RECOMMENDED: 120.
636/// - `max_age_seconds` — §6's freshness policy. A receipt older than
637///   this is still *verified* (it may be perfectly genuine — merely
638///   old); it is reported distinctly via
639///   [`VerifiedContext::head_receipt_stale`], never as a verification
640///   failure. RFC RECOMMENDED default: 300. `None` disables the
641///   staleness verdict.
642#[derive(Debug, Clone, Copy, PartialEq, Eq)]
643pub struct LineageHeadPolicy {
644    /// Presence handling: `Ignore` (skip verification, preserve
645    /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
646    /// closed unless present AND verified — appropriate when the
647    /// registry advertises `acdp-registry-head-receipts`, under which
648    /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
649    pub receipts: ReceiptPolicy,
650    /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
651    pub max_clock_skew_seconds: u32,
652    /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
653    /// staleness verdict (default `Some(300)`).
654    pub max_age_seconds: Option<u32>,
655}
656
657impl Default for LineageHeadPolicy {
658    fn default() -> Self {
659        Self {
660            receipts: ReceiptPolicy::VerifyIfPresent,
661            max_clock_skew_seconds: 120,
662            max_age_seconds: Some(300),
663        }
664    }
665}
666
667/// How to treat a producer key that is present in the DID document's
668/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
669/// key the producer rotated out but retained per the RFC-ACDP-0010
670/// key-retention rule.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
672pub enum HistoricalKeyPolicy {
673    /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
674    /// Every context signed by a rotated-out key fails.
675    Reject,
676    /// Accept a retained key **only** when a verified registry receipt
677    /// attests (via `key_fingerprint`) that this exact key was the
678    /// authorized one at publish time. Without a verified receipt the
679    /// historical path never activates — fail closed. Default.
680    #[default]
681    AcceptWithReceipt,
682}
683
684/// How the producer key that verified the body relates to the
685/// producer's *current* DID document.
686#[derive(Debug, Clone, Copy, PartialEq, Eq)]
687pub enum KeyAuthorization {
688    /// The signing key is currently listed in `assertionMethod`.
689    CurrentlyAuthorized,
690    /// The signing key was rotated out of `assertionMethod` but is
691    /// retained in `verificationMethod`, and a verified registry
692    /// receipt attests it was the authorized key at publish time
693    /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
694    /// current endorsement.
695    HistoricallyAuthorized,
696    /// The signing key is **revoked** (a verified RFC-ACDP-0014
697    /// revocation names its fingerprint), but a verified registry
698    /// receipt attests the context was published strictly *before* the
699    /// compromise boundary `compromised_since` — it was signed while
700    /// the key was still the producer's, and verified under the
701    /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
702    ///
703    /// Deliberately distinguishable from BOTH
704    /// [`Self::CurrentlyAuthorized`] and the no-revocation
705    /// [`Self::HistoricallyAuthorized`]: the revocation and its
706    /// boundary MUST be visible in the verdict — even a key still
707    /// listed in `assertionMethod` MUST NOT be reported as fully
708    /// current once revoked. This holds for every entry point that
709    /// accepts a [`VerificationPolicy`], including the `fetch_report*`
710    /// family: they derive `key_status` from the same `verify_retrieved`
711    /// phase `fetch_with_policy` uses, so a revoked key cannot silently
712    /// surface as [`Self::CurrentlyAuthorized`] on any of them. Contexts
713    /// by the same key at/after the boundary — or with no verifiable
714    /// publish time — never reach a status at all: they fail closed
715    /// with `key_not_authorized` (§7 steps 3–4).
716    HistoricallyAuthorizedPreCompromise,
717}
718
719impl VerificationPolicy {
720    /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
721    ///
722    /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
723    /// recomputation, `did:web` key resolution, signature verification, and
724    /// embedded `data_ref.content_hash` checks. Returns on the first failure.
725    ///
726    /// This is the **only** mode covered by the `acdp-consumer` conformance
727    /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
728    /// available in this crate in v0.1.0 — they would be separately-named
729    /// opt-ins per §9.2, and are not currently implemented.
730    ///
731    /// NOT identical to [`Default::default()`] as of 0.2: the default
732    /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
733    /// while this named profile preserves the exact v0.1.0 semantics —
734    /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
735    /// `assertionMethod` keys accepted
736    /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
737    /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
738    pub fn strict_v0_1_0() -> Self {
739        Self {
740            validate_body_schema: true,
741            allow_unknown_status: true,
742            receipts: ReceiptPolicy::Ignore,
743            historical_keys: HistoricalKeyPolicy::Reject,
744            lineage_head: LineageHeadPolicy {
745                receipts: ReceiptPolicy::Ignore,
746                ..LineageHeadPolicy::default()
747            },
748            // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
749            // unaffected by it (§10): no revocations enforced.
750            revocations: RevocationPolicy::default(),
751        }
752    }
753
754    /// The policy the report family (`fetch_report`,
755    /// `fetch_report_with_fetcher`, `fetch_report_diagnose`) passes to
756    /// [`VerifiedContext::verify_retrieved`].
757    ///
758    /// `validate_body_schema` is forced `false` unconditionally,
759    /// independent of the caller: P1 (schema) is always handled by the
760    /// report path itself — `validate_body_structural` plus per-`DataRef`
761    /// non-fatal recording of embedded-hash outcomes into
762    /// `VerificationReport::data_ref_embedded` — so the spine must always
763    /// skip its own full `validate_body` (structural + fatal embedded-hash
764    /// check) here. Every other field passes through verbatim. Do **not**
765    /// pass the caller's policy directly to `verify_retrieved` from a
766    /// report entry point; doing so reinstates the fatal embedded-hash
767    /// check the report path deliberately downgrades to non-fatal (see
768    /// `tests/tls_conformance.rs`'s
769    /// `fetch_report_records_embedded_hash_failure`).
770    fn derived_for_report(&self) -> Self {
771        Self {
772            validate_body_schema: false,
773            ..self.clone()
774        }
775    }
776}
777
778/// A retrieved context that has been cryptographically verified.
779///
780/// Every value of this type is the output of one of the
781/// `VerifiedContext::fetch*` pipelines, each of which independently
782/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
783/// producer signature before the value is constructed. The fields are
784/// **private** precisely so this "cryptographically verified" invariant
785/// cannot be forged: there is no way to construct a `VerifiedContext`
786/// around an unverified [`FullContext`]. Downstream code can therefore
787/// trust the accessors below without re-deriving anything.
788#[derive(Debug)]
789pub struct VerifiedContext {
790    inner: FullContext,
791    /// Whether the body verified against a currently authorized key or
792    /// a receipt-attested historical one (ACDP 0.2, WS-B).
793    key_status: KeyAuthorization,
794    /// The verified registry receipt, when one was present and the
795    /// policy verified it (RFC-ACDP-0010). `None` under
796    /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
797    verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
798    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
799    /// when one was present and the policy verified it. Only populated
800    /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
801    /// — plain retrieval preserves the raw value verbatim without
802    /// verification. Per §7 this verdict is independent of the body
803    /// verdict and the RFC-ACDP-0010 receipt verdict.
804    verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
805    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
806    /// receipt, reported distinctly from verification: `Some(true)`
807    /// when the (genuine, verified) receipt's `as_of` is older than
808    /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
809    /// within policy; `None` when there is no verified head receipt or
810    /// the max-age knob is disabled.
811    head_receipt_stale: Option<bool>,
812    /// RFC-ACDP-0014 §8 auto-discovery failure, when
813    /// `policy.revocations.discover` was `Some`, discovery failed, and
814    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] let verification
815    /// proceed on [`RevocationPolicy::known`] alone anyway. `None` when
816    /// discovery was off, succeeded, or was never attempted because
817    /// [`DiscoveryFailurePolicy::FailClosed`] turned the failure into
818    /// this call's `Err` instead (so no `VerifiedContext` was ever
819    /// constructed to carry it). Exposed so `ProceedWithKnown` is never
820    /// silent on the plain `fetch*` paths — see
821    /// [`Self::revocation_discovery_failure`].
822    revocation_discovery_failure: Option<AcdpError>,
823}
824
825/// `verify_retrieved`'s return type — private and free to shape (issue
826/// #248 Phase 4 grew a third tuple element for the discovery outcome).
827/// Named only to keep clippy's `type_complexity` lint quiet; every
828/// caller destructures it positionally exactly as before.
829type VerifyRetrievedResult = Result<
830    (
831        KeyAuthorization,
832        Option<acdp_types::receipt::RegistryReceipt>,
833        Option<Result<DiscoveryOutcome, AcdpError>>,
834    ),
835    AcdpError,
836>;
837
838impl VerifiedContext {
839    /// Retrieve a context and verify its signature using the strict
840    /// default [`VerificationPolicy`].
841    pub async fn fetch(
842        client: &RegistryClient,
843        resolver: &WebResolver,
844        ctx_id: &CtxId,
845    ) -> Result<Self, AcdpError> {
846        Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
847    }
848
849    /// Retrieve a context and verify its signature with caller-controlled
850    /// strictness.
851    ///
852    /// 1. Fetches `body + registry_state` from the registry.
853    /// 2. Refuses a served body whose `ctx_id` differs from the one
854    ///    requested (`AcdpError::ContextIdMismatch`) — this implements
855    ///    RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
856    ///    identity"): neither the signature check (step 5) nor the
857    ///    `content_hash` recomputation (step 6) can supply this binding,
858    ///    because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
859    ///    exclusion set and is therefore stripped from ProducerContent
860    ///    before hashing. See RFC-ACDP-0008 §9.1 for the threat this
861    ///    closes: without it, a registry can serve any other
862    ///    validly-signed body by the same producer under the requested
863    ///    context's URL, and both preceding checks still pass. Step 7
864    ///    permits a consumer to surface "an equivalent typed error" in
865    ///    place of the registry-side `cross_registry_resolution_failed`
866    ///    wire code — `ContextIdMismatch` is that typed error. This
867    ///    generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
868    ///    to the receipt-less core-profile path, where it is the only
869    ///    binding available. It does **not** close §9.1 in full: a
870    ///    registry that genuinely republishes the same content under a
871    ///    new `ctx_id` still passes; only serve-time substitution — a
872    ///    different id claimed to be the one requested — is caught.
873    /// 3. Optionally runs `validate_body` — structural schema checks
874    ///    plus embedded-`DataRef` hash verification (policy-controlled).
875    /// 4. Recomputes `content_hash` over ProducerContent.
876    /// 5. Resolves the producer's DID document. `did:web` is required
877    ///    unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
878    /// 6. Verifies the Ed25519 signature (or other supported algorithm).
879    /// 7. Optionally verifies the `registry_receipt` placeholder.
880    /// 8. Optionally rejects unknown statuses.
881    pub async fn fetch_with_policy(
882        client: &RegistryClient,
883        resolver: &WebResolver,
884        ctx_id: &CtxId,
885        policy: &VerificationPolicy,
886    ) -> Result<Self, AcdpError> {
887        let ctx = client.retrieve(ctx_id).await?;
888        let (key_status, verified_receipt, revocation_discovery) =
889            Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
890        let revocation_discovery_failure = match revocation_discovery {
891            Some(Err(e)) => Some(e),
892            _ => None,
893        };
894        Ok(Self {
895            inner: ctx,
896            key_status,
897            verified_receipt,
898            verified_head_receipt: None,
899            head_receipt_stale: None,
900            revocation_discovery_failure,
901        })
902    }
903
904    /// Retrieve the current head of a lineage
905    /// (`GET /lineages/{lineage_id}/current`) and verify it with the
906    /// strict default [`VerificationPolicy`] — including the
907    /// lineage-head receipt when the registry minted one (ACDP 0.3,
908    /// RFC-ACDP-0011).
909    pub async fn fetch_current(
910        client: &RegistryClient,
911        resolver: &WebResolver,
912        lineage_id: &acdp_types::primitives::LineageId,
913    ) -> Result<Self, AcdpError> {
914        Self::fetch_current_with_policy(
915            client,
916            resolver,
917            lineage_id,
918            &VerificationPolicy::default(),
919        )
920        .await
921    }
922
923    /// Retrieve + verify the current head of a lineage with
924    /// caller-controlled strictness.
925    ///
926    /// Runs the same pipeline as [`Self::fetch_with_policy`] against
927    /// the `/current` response (the expected `ctx_id` is the served
928    /// body's own — there is no requested identifier on this endpoint;
929    /// the head receipt's §7 step 5 byte-match is what binds it), then
930    /// applies `policy.lineage_head` to the response's
931    /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
932    ///
933    /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
934    ///   verbatim, unverified.
935    /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
936    ///   (absence is fine: the registry may not advertise
937    ///   `acdp-registry-head-receipts`).
938    /// - [`ReceiptPolicy::Require`] — fail closed with
939    ///   `invalid_receipt` unless present AND verified.
940    ///
941    /// Verification fetches the registry's capabilities document for
942    /// the §7 step 3 `capabilities.registry_did` binding. Staleness
943    /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
944    /// verdict reported via [`Self::head_receipt_stale`], never a
945    /// verification failure (§6).
946    ///
947    /// [`Self::fetch_with_policy`] now additionally refuses a served body
948    /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
949    /// endpoint has no requested identifier to compare against — the
950    /// served head's `ctx_id` is trivially "the one requested" — so on a
951    /// receipt-less registry the served head's identity rests entirely on
952    /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
953    /// where that matters.
954    pub async fn fetch_current_with_policy(
955        client: &RegistryClient,
956        resolver: &WebResolver,
957        lineage_id: &acdp_types::primitives::LineageId,
958        policy: &VerificationPolicy,
959    ) -> Result<Self, AcdpError> {
960        let ctx = client.current(lineage_id).await?;
961        let served_ctx_id = ctx.body.ctx_id.clone();
962        let (key_status, verified_receipt, revocation_discovery) =
963            Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
964        let revocation_discovery_failure = match revocation_discovery {
965            Some(Err(e)) => Some(e),
966            _ => None,
967        };
968
969        // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
970        let (verified_head_receipt, head_receipt_stale) =
971            match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
972                (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
973                (ReceiptPolicy::Require, None) => {
974                    return Err(AcdpError::InvalidReceipt(
975                        "policy requires a lineage-head receipt but the /current response \
976                         carries none (registry without the acdp-registry-head-receipts \
977                         profile?)"
978                            .into(),
979                    ));
980                }
981                (_, Some(value)) => {
982                    let serving_authority = client
983                        .authority()
984                        .unwrap_or_else(|| served_ctx_id.authority().to_string());
985                    // §7 step 3 needs capabilities.registry_did — fetched
986                    // from the same authority the context came from.
987                    let caps = client.capabilities().await?;
988                    let receipt = super::receipt::verify_lineage_head_receipt_value(
989                        value,
990                        lineage_id,
991                        &served_ctx_id,
992                        ctx.body.version,
993                        &ctx.registry_state.status,
994                        true, // /current always serves the attested head
995                        &serving_authority,
996                        &caps.registry_did,
997                        chrono::Duration::seconds(
998                            policy.lineage_head.max_clock_skew_seconds as i64,
999                        ),
1000                        resolver,
1001                    )
1002                    .await?;
1003                    let stale = policy.lineage_head.max_age_seconds.map(|max| {
1004                        receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
1005                    });
1006                    (Some(receipt), stale)
1007                }
1008            };
1009
1010        Ok(Self {
1011            inner: ctx,
1012            key_status,
1013            verified_receipt,
1014            verified_head_receipt,
1015            head_receipt_stale,
1016            revocation_discovery_failure,
1017        })
1018    }
1019
1020    /// The shared retrieve-side verification pipeline: body schema,
1021    /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
1022    /// phase (with the receipt-gated historical-key fallback), and the
1023    /// unknown-status policy check.
1024    #[cfg_attr(
1025        feature = "tracing",
1026        tracing::instrument(
1027            name = "acdp.verify_retrieved",
1028            skip_all,
1029            fields(ctx_id = %expected_ctx_id),
1030            err(Display)
1031        )
1032    )]
1033    async fn verify_retrieved(
1034        client: &RegistryClient,
1035        resolver: &WebResolver,
1036        ctx: &FullContext,
1037        expected_ctx_id: &CtxId,
1038        policy: &VerificationPolicy,
1039    ) -> VerifyRetrievedResult {
1040        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
1041        // the resolved identity"): refuse a served body whose `ctx_id`
1042        // differs from the one requested, before any crypto or network
1043        // work. `ctx_id` is registry-assigned and outside both the
1044        // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
1045        // exclusion set), so this equality check is the only binding
1046        // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
1047        // the threat this closes; it does not close §9.1 in full (a
1048        // genuine republish under a new `ctx_id` still passes — only
1049        // serve-time substitution is caught). Step 7 permits a
1050        // consumer-side "equivalent typed error" in place of the
1051        // registry-side `cross_registry_resolution_failed` wire code —
1052        // `ContextIdMismatch` is that typed error.
1053        if ctx.body.ctx_id != *expected_ctx_id {
1054            return Err(AcdpError::ContextIdMismatch {
1055                requested: expected_ctx_id.as_str().to_string(),
1056                served: ctx.body.ctx_id.as_str().to_string(),
1057            });
1058        }
1059
1060        if policy.validate_body_schema {
1061            acdp_validation::validate_body(&ctx.body)?;
1062        }
1063
1064        // Hash recomputation first: from here on `ctx.body.content_hash`
1065        // IS the independently recomputed value, which the receipt
1066        // cross-check below relies on.
1067        let verifier = Verifier::new(resolver);
1068        verifier.verify_body_hash(&ctx.body)?;
1069
1070        // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
1071        // Verified BEFORE the signature phase because the historical-
1072        // key path is gated on a verified receipt.
1073        let serving_authority = client
1074            .authority()
1075            .unwrap_or_else(|| expected_ctx_id.authority().to_string());
1076        let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
1077            (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
1078            (ReceiptPolicy::Require, None) => {
1079                return Err(AcdpError::InvalidReceipt(
1080                    "policy requires a registry receipt but the response carries none \
1081                     (registry without the acdp-registry-receipts profile, or a \
1082                     pre-receipts context)"
1083                        .into(),
1084                ));
1085            }
1086            (_, Some(value)) => {
1087                let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1088                    &ctx.body.signature.key_id,
1089                    &ctx.body.signature.algorithm,
1090                    resolver,
1091                )
1092                .await?;
1093                Some(
1094                    super::receipt::verify_receipt_value(
1095                        value,
1096                        expected_ctx_id,
1097                        &ctx.body,
1098                        &ctx.body.content_hash,
1099                        &fingerprint,
1100                        &serving_authority,
1101                        resolver,
1102                    )
1103                    .await?,
1104                )
1105            }
1106        };
1107
1108        // ── Revocation discovery phase (RFC-ACDP-0014 §8) ────────────
1109        // Runs `policy.revocations.discover`'s two independent lookups
1110        // — producer-signed always, registry-attested only when
1111        // `include_registry_attested` — CONCURRENTLY under
1112        // `tokio::try_join!`, with the pair wrapped in a single
1113        // `tokio::time::timeout(discover.total_timeout, ..)` (D4). This
1114        // puts a Tokio **time-driver requirement** (`enable_time`) on
1115        // the core verify path, gated behind `discover: Some` — the
1116        // same requirement `CrossRegistryResolver::walk_derived_from`
1117        // already carries on its opt-in walk (`cross_registry.rs`).
1118        //
1119        // Reentrancy: `find_revocations` verifies each candidate body
1120        // via `Verifier::new(resolver).verify_body` — never
1121        // `verify_retrieved` — so discovered revocation bodies are
1122        // themselves checked WITHOUT revocation checking. That is
1123        // defensible under §5 step 1's "currently authorized key," but
1124        // it is an assumption, not an accident: verifying a
1125        // `key-revocation` context now *also* triggers discovery
1126        // against the same producer, so the revocation-fetch path
1127        // itself becomes fragile under `FailClosed` (a producer whose
1128        // revocation search is briefly unreachable can no longer be
1129        // verified as revoked, either).
1130        //
1131        // A `SearchTruncated` failure and a 503 both take the
1132        // `on_failure` path, but they are NOT equivalent: truncation
1133        // means "this producer has more revocations than we will page
1134        // through" — an attacker-inducible security downgrade, since a
1135        // hostile producer/registry can pad the result set specifically
1136        // to exhaust `MAX_SEARCH_PAGES` — whereas a 503 is an ordinary
1137        // availability blip. `RevocationDiscoveryBudgetExceeded` (issue
1138        // #258, `max_requests`/`max_bytes`) is a THIRD case in the same
1139        // attacker-inducible bucket as truncation: a hostile registry
1140        // that learns a caller's budget can pad harmless-looking traffic
1141        // to exhaust it before a real revocation is found.
1142        // `ProceedWithKnown` treats all three the same way (proceed on
1143        // `known` alone, record the failure); naming the asymmetry here
1144        // is so that choice is made with open eyes.
1145        // Issue #257 (D-A / B1): `seeded_facts` is read from whatever
1146        // `RevocationCache` is attached to `client` — BEFORE any of the
1147        // discovery attempt below runs — and threaded through every arm
1148        // below UNCHANGED, including the failure arms that reset
1149        // `discovered` to `Vec::new()`. This is the load-bearing placement:
1150        // facts are a verified, permanent record (RFC-ACDP-0014 §7:114)
1151        // and MUST survive a 503, a `SearchTruncated`, a budget
1152        // exhaustion, or a `total_timeout` trip on THIS call — exactly the
1153        // failure modes an attacker can induce to try to make a client
1154        // "forget" a revocation it already saw. Computing this once, up
1155        // front, and never touching it again inside the match is what
1156        // makes that survival structural rather than a discipline: there
1157        // is no code path below that can zero it out the way `discovered`
1158        // legitimately is on failure.
1159        let (discovered, revocation_discovery, seeded_facts) = match &policy.revocations.discover {
1160            // MATERIAL-3 (fresh-Opus review of Phase 2): attaching a
1161            // `RevocationCache` IS the opt-in for anti-rollback, independent
1162            // of whether `discover` itself is configured — seed producer-
1163            // signed facts here too, not only in the `Some` arm below. This
1164            // is what extends anti-rollback protection to
1165            // `VerifiedContext::fetch`/`fetch_current` (LIM-2), which
1166            // hardcode `VerificationPolicy::default()` and so can NEVER
1167            // set `discover` at all — without this, those two entry points
1168            // would get zero benefit from an attached cache. Seeding is
1169            // monotone (facts can only tighten a verdict, never loosen
1170            // one), so doing it unconditionally is safe. With `discover:
1171            // None` there is no `include_registry_attested` to consult, so
1172            // seed producer-signed facts ONLY: registry-attested requires
1173            // the explicit opt-in `RevocationDiscovery::include_registry_attested`
1174            // carries (D6), and BLOCKER-1's origin filter would need this
1175            // call's serving vantage regardless, so staying conservative
1176            // here costs nothing.
1177            None => {
1178                let agent_id = &ctx.body.agent_id;
1179                let vantage = client.authority();
1180                let seeded_facts = client
1181                    .revocation_cache()
1182                    .map(|(cache, _freshness)| {
1183                        cache.facts_for(agent_id.as_str(), false, vantage.as_deref())
1184                    })
1185                    .unwrap_or_default();
1186                (Vec::new(), None, seeded_facts)
1187            }
1188            Some(discovery) => {
1189                let agent_id = &ctx.body.agent_id;
1190                let include_attested = discovery.include_registry_attested;
1191                // BLOCKER-1: pass this call's own vantage through so
1192                // `facts_for` can filter registry-attested facts to
1193                // `origin == current vantage` (RFC-ACDP-0014 §6) —
1194                // producer-signed facts are unaffected either way (§8).
1195                let vantage = client.authority();
1196                let seeded_facts = client
1197                    .revocation_cache()
1198                    .map(|(cache, _freshness)| {
1199                        cache.facts_for(agent_id.as_str(), include_attested, vantage.as_deref())
1200                    })
1201                    .unwrap_or_default();
1202                // Issue #258 (D-B): a combined request/byte budget is
1203                // enforced inside `RegistryClient`'s request methods, on
1204                // a client clone created HERE and handed to BOTH lookups
1205                // below — never on `client` itself, so a caller sharing
1206                // that original client across concurrent work never has
1207                // unrelated traffic charged to this discovery's budget.
1208                // One `DiscoveryBudget` per call means one combined
1209                // ceiling: enabling `include_registry_attested` cannot
1210                // silently double it, since both `try_join!` arms below
1211                // draw down the same counters. Issue #257: the SAME clone
1212                // additionally carries `client`'s attached
1213                // `RevocationCache` (if any, unchanged by
1214                // `with_discovery_budget`) with `freshness` overridden from
1215                // this call's `discovery.freshness` — never a
1216                // caller-facing knob, only ever set here from the
1217                // extracted `discovery` value (spine-lock safe).
1218                let budget = DiscoveryBudget::new(discovery.max_requests, discovery.max_bytes);
1219                let client = client
1220                    .with_discovery_budget(budget)
1221                    .with_revocation_freshness(discovery.freshness);
1222                let discovery_fut = async {
1223                    tokio::try_join!(
1224                        super::revocation::find_revocations(&client, resolver, agent_id),
1225                        async {
1226                            if include_attested {
1227                                super::revocation::find_registry_attested_revocations(
1228                                    &client, resolver, agent_id,
1229                                )
1230                                .await
1231                            } else {
1232                                Ok(Vec::new())
1233                            }
1234                        },
1235                    )
1236                };
1237                match tokio::time::timeout(discovery.total_timeout, discovery_fut).await {
1238                    Ok(Ok((producer_signed, registry_attested))) => {
1239                        let outcome = DiscoveryOutcome {
1240                            producer_signed: producer_signed.len(),
1241                            registry_attested: if include_attested {
1242                                Some(registry_attested.len())
1243                            } else {
1244                                None
1245                            },
1246                        };
1247                        let mut merged = producer_signed;
1248                        merged.extend(registry_attested);
1249                        (merged, Some(Ok(outcome)), seeded_facts)
1250                    }
1251                    Ok(Err(e)) => {
1252                        let wrapped = AcdpError::RevocationDiscoveryFailed {
1253                            source: Box::new(e),
1254                        };
1255                        match discovery.on_failure {
1256                            DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
1257                            DiscoveryFailurePolicy::ProceedWithKnown => {
1258                                (Vec::new(), Some(Err(wrapped)), seeded_facts)
1259                            }
1260                        }
1261                    }
1262                    Err(_elapsed) => {
1263                        let wrapped = AcdpError::RevocationDiscoveryFailed {
1264                            source: Box::new(AcdpError::CrossRegistryResolutionFailed(format!(
1265                                "revocation auto-discovery exceeded total_timeout={:?}",
1266                                discovery.total_timeout
1267                            ))),
1268                        };
1269                        match discovery.on_failure {
1270                            DiscoveryFailurePolicy::FailClosed => return Err(wrapped),
1271                            DiscoveryFailurePolicy::ProceedWithKnown => {
1272                                (Vec::new(), Some(Err(wrapped)), seeded_facts)
1273                            }
1274                        }
1275                    }
1276                }
1277            }
1278        };
1279
1280        // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
1281        // Runs after the receipt phase because the boundary comparison
1282        // accepts ONLY a receipt-attested publish time (§7 step 1 —
1283        // the bare body created_at is registry-assigned and MUST NOT
1284        // be used). The verified receipt's key_fingerprint was already
1285        // cross-checked against the body's signing key above (§8 step
1286        // 5), so `verified_receipt.created_at` genuinely places THIS
1287        // key's signature in time.
1288        //
1289        // `effective` is the union of `policy.revocations.known`,
1290        // `seeded_facts` (issue #257 — the attached `RevocationCache`'s
1291        // permanent record for this producer, ALWAYS unioned in
1292        // regardless of whether this call's discovery attempt above
1293        // succeeded, failed, or was never configured at all), and
1294        // whatever `discovered` above (empty when `discover` is `None`,
1295        // or when discovery failed under `ProceedWithKnown`) —
1296        // deliberately WITHOUT deduplication: `effective_boundary` is a
1297        // `filter().map().min()` fold, so duplicate entries are inert
1298        // and two sources disagreeing resolves to the earliest
1299        // boundary, the fail-closed direction §4 mandates. Dedup is
1300        // unavailable anyway — `KeyRevocation` is not `Hash`. (The cache
1301        // itself still dedups on insert — see
1302        // `RevocationCache::record_success` — so `seeded_facts` alone
1303        // does not grow unboundedly across repeated calls; this `chain`
1304        // is simply not where that bound lives.)
1305        let effective: Vec<acdp_types::revocation::KeyRevocation> = policy
1306            .revocations
1307            .known
1308            .iter()
1309            .cloned()
1310            .chain(seeded_facts)
1311            .chain(discovered)
1312            .collect();
1313        let revocation_verdict = if effective.is_empty() {
1314            None
1315        } else {
1316            let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1317                &ctx.body.signature.key_id,
1318                &ctx.body.signature.algorithm,
1319                resolver,
1320            )
1321            .await?;
1322            super::revocation::classify_under_revocation(
1323                &effective,
1324                &fingerprint,
1325                verified_receipt.as_ref().map(|r| r.created_at),
1326            )?
1327        };
1328
1329        // ── Signature phase ──────────────────────────────────────────
1330        // Standard path enforces assertionMethod membership. A
1331        // KeyNotAuthorized failure falls back to the historical path
1332        // only under AcceptWithReceipt AND a verified receipt — the
1333        // receipt's key_fingerprint (already cross-checked against this
1334        // exact key above) is what attests publish-time authorization.
1335        let key_status = match revocation_verdict {
1336            // Pre-compromise (§7 step 2): the signature is verified
1337            // under the RFC-ACDP-0010 §10 historical rule — the key may
1338            // legitimately have left assertionMethod (and SHOULD, §9),
1339            // and even a key still in assertionMethod MUST NOT be
1340            // reported as fully current once revoked. did:key material
1341            // cannot rotate, so it takes the plain envelope path.
1342            Some(pre_compromise) => {
1343                if ctx.body.agent_id.as_str().starts_with("did:key:") {
1344                    verifier.verify_body_signature(&ctx.body).await?;
1345                } else {
1346                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
1347                }
1348                pre_compromise
1349            }
1350            None => match verifier.verify_body_signature(&ctx.body).await {
1351                Ok(()) => KeyAuthorization::CurrentlyAuthorized,
1352                Err(AcdpError::KeyNotAuthorized(_))
1353                    if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
1354                        && verified_receipt.is_some() =>
1355                {
1356                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
1357                    KeyAuthorization::HistoricallyAuthorized
1358                }
1359                Err(e) => return Err(e),
1360            },
1361        };
1362
1363        if !policy.allow_unknown_status {
1364            if let Some(other) = ctx.registry_state.status.as_other() {
1365                return Err(AcdpError::SchemaViolation(format!(
1366                    "policy.allow_unknown_status=false; registry returned '{other}'"
1367                )));
1368            }
1369        }
1370
1371        Ok((key_status, verified_receipt, revocation_discovery))
1372    }
1373
1374    /// Retrieve + verify, returning a structured [`VerificationReport`]
1375    /// alongside the verified context. Does NOT attempt external
1376    /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
1377    /// that. Each `data_ref_external` slot in the returned report is
1378    /// `None`.
1379    ///
1380    /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
1381    /// failures are recorded in the report instead of aborting the
1382    /// verification. The top-level checks (schema, body hash,
1383    /// signature) remain hard-fail: if any of them fails, the method
1384    /// returns an `AcdpError` and produces no report.
1385    ///
1386    /// For diagnostic callers that want a populated report even when
1387    /// a top-level check fails (e.g. an audit walker that needs to
1388    /// distinguish "wrong hash" from "wrong signature"), use
1389    /// [`Self::fetch_report_diagnose`] instead.
1390    pub async fn fetch_report(
1391        client: &RegistryClient,
1392        resolver: &WebResolver,
1393        ctx_id: &CtxId,
1394        policy: &VerificationPolicy,
1395    ) -> Result<(Self, VerificationReport), AcdpError> {
1396        Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
1397    }
1398
1399    /// Diagnostic variant of [`Self::fetch_report`] that never
1400    /// short-circuits on a top-level failure — schema, body-hash, and
1401    /// signature outcomes are each recorded individually in the
1402    /// returned [`VerificationReport`]. Returns `Ok((None, report))`
1403    /// when any top-level probe failed (the report shows which one);
1404    /// `Ok((Some(verified), report))` only when every check passed
1405    /// (FEAT-05) — and "every check" now genuinely means every
1406    /// authorization phase (receipt, revocation, signature/
1407    /// historical-key, unknown-status), not just the top-level probes:
1408    /// once the probes pass, this method additionally runs the same
1409    /// `verify_retrieved` phase `fetch_with_policy` does, and withholds
1410    /// the handle — recording the cause in
1411    /// [`VerificationReport::policy_phase_error`] — if that phase fails
1412    /// too. Either way the method still returns `Ok`; it never converts
1413    /// a policy-phase failure into an `Err`.
1414    ///
1415    /// Use cases:
1416    /// - Audit walkers that need to classify failures by stage.
1417    /// - Admin tooling that wants to distinguish "hash mismatch"
1418    ///   (probable tampering / encoding drift) from "signature
1419    ///   verification failed" (key compromise / DID resolution
1420    ///   problem).
1421    ///
1422    /// Network errors from the initial retrieval still propagate as
1423    /// `Err` — there's no body to inspect when the registry is
1424    /// unreachable. But network/DID-resolution errors that occur
1425    /// *inside* the `verify_retrieved` phase (e.g. resolving the
1426    /// fingerprint for a receipt cross-check, or the historical-key
1427    /// fallback) are caught there and land in
1428    /// [`VerificationReport::policy_phase_error`] instead of `Err`,
1429    /// same as any other phase failure — this method never
1430    /// short-circuits once retrieval has succeeded. That means a
1431    /// transient network flake at that stage can read as a policy
1432    /// rejection (`Ok((None, report))`) rather than an `Err`. A caller
1433    /// that needs to tell a flake from a genuine rejection should
1434    /// inspect `policy_phase_error`'s [`AcdpError::is_transient`].
1435    pub async fn fetch_report_diagnose(
1436        client: &RegistryClient,
1437        resolver: &WebResolver,
1438        ctx_id: &CtxId,
1439        policy: &VerificationPolicy,
1440    ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
1441        let ctx = client.retrieve(ctx_id).await?;
1442        let mut report = VerificationReport {
1443            body_hash_ok: false,
1444            signature_ok: false,
1445            schema_ok: false,
1446            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
1447            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
1448            ctx_id_ok: ctx.body.ctx_id == *ctx_id,
1449            key_status: None,
1450            policy_phase_error: None,
1451            revocation_discovery: None,
1452        };
1453
1454        // Schema (structural) — record pass/fail.
1455        if policy.validate_body_schema {
1456            match acdp_validation::validate_body_structural(&ctx.body) {
1457                Ok(()) => report.schema_ok = true,
1458                Err(_) => { /* keep schema_ok=false; continue collecting */ }
1459            }
1460        } else {
1461            report.schema_ok = true;
1462        }
1463
1464        // Per-DataRef embedded hashes — same as fetch_report_inner.
1465        for dr in &ctx.body.data_refs {
1466            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
1467                let outcome = acdp_validation::verify_embedded_hash(dr)
1468                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
1469                report.data_ref_embedded.push(outcome);
1470            } else {
1471                report.data_ref_embedded.push(Ok(0));
1472            }
1473        }
1474
1475        // Hash + signature recorded independently (FEAT-05).
1476        let verifier = Verifier::new(resolver);
1477        report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
1478        report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
1479
1480        // External fetches were not attempted (this method has no
1481        // fetcher param — diagnostic callers can wire their own).
1482        for _ in &ctx.body.data_refs {
1483            report.data_ref_external.push(None);
1484        }
1485
1486        // Decide whether to surface the verified handle. The probes above
1487        // are diagnostic — their whole value is continuing past failure —
1488        // but the handle is a trust assertion (`VerifiedContext`'s
1489        // invariant: "the accessors below can be trusted without
1490        // re-deriving anything"), so it is only ever issued once the real
1491        // authorization phases (receipt, revocation, signature/historical,
1492        // unknown-status) have actually run and passed through
1493        // `verify_retrieved` — never on the probes alone.
1494        let all_top_level_pass =
1495            report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
1496        let verified = if all_top_level_pass {
1497            // The call MUST be hoisted out of the `match` scrutinee: in a
1498            // match, scrutinee temporaries live to the end of the match,
1499            // so the awaited future would still be holding `&ctx` inside
1500            // the arms and `Self { inner: ctx, .. }` below would fail
1501            // borrowck (E0505).
1502            let outcome = Self::verify_retrieved(
1503                client,
1504                resolver,
1505                &ctx,
1506                ctx_id,
1507                &policy.derived_for_report(),
1508            )
1509            .await; // borrow of `ctx` ends here
1510            match outcome {
1511                Ok((key_status, verified_receipt, revocation_discovery)) => {
1512                    report.key_status = Some(key_status);
1513                    let revocation_discovery_failure = match &revocation_discovery {
1514                        Some(Err(e)) => Some(e.clone()),
1515                        _ => None,
1516                    };
1517                    report.revocation_discovery = revocation_discovery;
1518                    Some(Self {
1519                        inner: ctx,
1520                        key_status,
1521                        verified_receipt,
1522                        verified_head_receipt: None,
1523                        head_receipt_stale: None,
1524                        revocation_discovery_failure,
1525                    })
1526                }
1527                Err(e) => {
1528                    // Reports; never short-circuits — `fetch_report_diagnose`
1529                    // still returns `Ok` in every case it does today.
1530                    report.policy_phase_error = Some(e);
1531                    None
1532                }
1533            }
1534        } else {
1535            None
1536        };
1537        Ok((verified, report))
1538    }
1539
1540    /// Retrieve + verify like [`Self::fetch_report`], and additionally
1541    /// fetch every `DataRef` whose `location` resolves through `fetcher`.
1542    /// Each external fetch outcome is recorded in `report.data_ref_external`.
1543    pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
1544        client: &RegistryClient,
1545        resolver: &WebResolver,
1546        ctx_id: &CtxId,
1547        policy: &VerificationPolicy,
1548        fetcher: &F,
1549    ) -> Result<(Self, VerificationReport), AcdpError> {
1550        Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
1551    }
1552
1553    async fn fetch_report_inner<F: DataRefFetcher>(
1554        client: &RegistryClient,
1555        resolver: &WebResolver,
1556        ctx_id: &CtxId,
1557        policy: &VerificationPolicy,
1558        fetcher: Option<&F>,
1559    ) -> Result<(Self, VerificationReport), AcdpError> {
1560        let ctx = client.retrieve(ctx_id).await?;
1561
1562        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
1563        // the resolved identity"). Same check as `verify_retrieved`,
1564        // applied here (in addition to `verify_retrieved`'s own re-check
1565        // below) so this fails before schema validation too — the early
1566        // copy preserves fail-before-schema ordering that
1567        // `tests/receipts.rs` depends on.
1568        if ctx.body.ctx_id != *ctx_id {
1569            return Err(AcdpError::ContextIdMismatch {
1570                requested: ctx_id.as_str().to_string(),
1571                served: ctx.body.ctx_id.as_str().to_string(),
1572            });
1573        }
1574
1575        let mut report = VerificationReport {
1576            body_hash_ok: false,
1577            signature_ok: false,
1578            schema_ok: false,
1579            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
1580            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
1581            ctx_id_ok: true,
1582            key_status: None,
1583            policy_phase_error: None,
1584            revocation_discovery: None,
1585        };
1586
1587        // Structural-only schema validation — embedded-hash checks are
1588        // intentionally skipped here so per-DataRef hash failures land
1589        // in the report (below) instead of short-circuiting the whole
1590        // verification. That's the diagnostic shape `fetch_report`
1591        // promises in its docstring.
1592        if policy.validate_body_schema {
1593            acdp_validation::validate_body_structural(&ctx.body)?;
1594        }
1595        report.schema_ok = true;
1596
1597        // Per-DataRef embedded-hash outcomes — recorded individually.
1598        for dr in &ctx.body.data_refs {
1599            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
1600                let outcome = acdp_validation::verify_embedded_hash(dr)
1601                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
1602                report.data_ref_embedded.push(outcome);
1603            } else {
1604                report.data_ref_embedded.push(Ok(0));
1605            }
1606        }
1607
1608        // Delegate the remaining phases — content_hash recomputation,
1609        // RFC-ACDP-0010 receipt, RFC-ACDP-0014 revocation, signature (with
1610        // the historical-key fallback), and the unknown-status check — to
1611        // `verify_retrieved`, the sole reader of those policy fields. The
1612        // derived policy forces `validate_body_schema` off (P1 was already
1613        // handled, structurally-only, above) and passes everything else
1614        // through verbatim — see `VerificationPolicy::derived_for_report`.
1615        let (key_status, verified_receipt, revocation_discovery) =
1616            Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
1617                .await?;
1618        report.body_hash_ok = true;
1619        report.signature_ok = true;
1620        report.key_status = Some(key_status);
1621        let revocation_discovery_failure = match &revocation_discovery {
1622            Some(Err(e)) => Some(e.clone()),
1623            _ => None,
1624        };
1625        report.revocation_discovery = revocation_discovery;
1626
1627        // External fetches — record per-ref outcomes when a fetcher is
1628        // supplied; otherwise leave each slot as `None` so callers can
1629        // distinguish "skipped" from "failed".
1630        for dr in &ctx.body.data_refs {
1631            let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
1632                (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
1633                _ => None,
1634            };
1635            report.data_ref_external.push(slot);
1636        }
1637
1638        Ok((
1639            Self {
1640                inner: ctx,
1641                key_status,
1642                verified_receipt,
1643                verified_head_receipt: None,
1644                head_receipt_stale: None,
1645                revocation_discovery_failure,
1646            },
1647            report,
1648        ))
1649    }
1650
1651    pub fn body(&self) -> &acdp_types::body::Body {
1652        &self.inner.body
1653    }
1654
1655    pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
1656        &self.inner.registry_state
1657    }
1658
1659    /// The verified [`FullContext`] (body + registry state + any
1660    /// receipts) in its retrieval shape. Every field was reached only
1661    /// after this context's hash + signature were verified.
1662    pub fn full_context(&self) -> &FullContext {
1663        &self.inner
1664    }
1665
1666    /// Whether the body verified against a currently authorized key, a
1667    /// receipt-attested historical one, or a receipt-attested
1668    /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7). This is
1669    /// the real verdict regardless of which `fetch*`/`fetch_report*`
1670    /// entry point produced this `VerifiedContext` — every construction
1671    /// path runs the same `verify_retrieved` phase to derive it.
1672    pub fn key_status(&self) -> KeyAuthorization {
1673        self.key_status
1674    }
1675
1676    /// The verified registry receipt (RFC-ACDP-0010), when one was
1677    /// present and the policy verified it. `None` under
1678    /// [`ReceiptPolicy::Ignore`] or when the registry minted none — this
1679    /// is exhaustive; there is no additional "or you used a report path"
1680    /// carve-out, since `fetch_report`/`fetch_report_with_fetcher`/
1681    /// `fetch_report_diagnose` verify the receipt exactly like
1682    /// `fetch_with_policy` does. For the raw on-wire value see
1683    /// [`Self::receipt`].
1684    pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
1685        self.verified_receipt.as_ref()
1686    }
1687
1688    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
1689    /// populated only by [`Self::fetch_current`] /
1690    /// [`Self::fetch_current_with_policy`] when one was present and the
1691    /// policy verified it. For the raw on-wire value see
1692    /// [`Self::lineage_head_receipt`].
1693    pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
1694        self.verified_head_receipt.as_ref()
1695    }
1696
1697    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
1698    /// receipt: `Some(true)` when the (genuine, verified) receipt's
1699    /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
1700    /// `Some(false)` when within policy; `None` when there is no
1701    /// verified head receipt or the max-age knob is disabled.
1702    pub fn head_receipt_stale(&self) -> Option<bool> {
1703        self.head_receipt_stale
1704    }
1705
1706    /// RFC-ACDP-0014 §8 auto-discovery failure that
1707    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed to let
1708    /// this `VerifiedContext` exist at all. `None` when discovery was
1709    /// off ([`RevocationPolicy::discover`] is `None`), succeeded, or
1710    /// was never attempted — a [`DiscoveryFailurePolicy::FailClosed`]
1711    /// failure turns into this call's `Err` instead, so there is no
1712    /// `VerifiedContext` to carry it in that case. See
1713    /// [`VerificationReport::revocation_discovery`] for the twin
1714    /// surface on the report family, populated from the same event on
1715    /// the `fetch_report*` paths.
1716    pub fn revocation_discovery_failure(&self) -> Option<&AcdpError> {
1717        self.revocation_discovery_failure.as_ref()
1718    }
1719
1720    /// Raw registry receipt value as served on the wire
1721    /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
1722    /// form see [`Self::verified_receipt`].
1723    pub fn receipt(&self) -> Option<&serde_json::Value> {
1724        self.inner.registry_receipt.as_ref()
1725    }
1726
1727    /// Raw lineage-head receipt value as served on the wire
1728    /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
1729    /// form see [`Self::verified_head_receipt`].
1730    pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
1731        self.inner.lineage_head_receipt.as_ref()
1732    }
1733
1734    /// Verify the registry receipt, when one is present
1735    /// (RFC-ACDP-0010).
1736    ///
1737    /// Standalone variant for contexts obtained via the report paths;
1738    /// `fetch_with_policy` already does this under
1739    /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
1740    /// authority is taken from the context's own `ctx_id` — this method
1741    /// performs no requested-id binding of its own (it has no requested
1742    /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
1743    /// so deriving the serving authority this way is sound only for a
1744    /// `VerifiedContext` obtained through a pipeline that already bound
1745    /// the served `ctx_id` to the one requested. Every construction path
1746    /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
1747    /// check it directly; `fetch_current_with_policy` does too,
1748    /// tautologically, since `/current` has no requested id to diverge
1749    /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
1750    /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
1751    /// folds it into its `all_top_level_pass` gate, so it only ever
1752    /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
1753    /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
1754    /// — every `VerifiedContext` was bound to its requested `ctx_id` —
1755    /// holds unconditionally.
1756    ///
1757    /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
1758    /// with the verified receipt otherwise.
1759    ///
1760    /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
1761    /// `body.content_hash` being the independently recomputed value.
1762    /// That is guaranteed by the type invariant — every
1763    /// `VerifiedContext` is built only after its constructing pipeline
1764    /// verified the body hash (`Verifier::verify_body_hash` /
1765    /// `verify_body_signed`), and the fields are private so no caller
1766    /// can substitute an unverified body — so no re-derivation is
1767    /// needed here.
1768    pub async fn verify_receipt(
1769        &self,
1770        resolver: &WebResolver,
1771    ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
1772        let Some(value) = &self.inner.registry_receipt else {
1773            return Ok(None);
1774        };
1775        let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1776            &self.inner.body.signature.key_id,
1777            &self.inner.body.signature.algorithm,
1778            resolver,
1779        )
1780        .await?;
1781        let receipt = super::receipt::verify_receipt_value(
1782            value,
1783            &self.inner.body.ctx_id,
1784            &self.inner.body,
1785            &self.inner.body.content_hash,
1786            &fingerprint,
1787            self.inner.body.ctx_id.authority(),
1788            resolver,
1789        )
1790        .await?;
1791        Ok(Some(receipt))
1792    }
1793}
1794
1795/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
1796///
1797/// Top-level booleans report the per-stage outcome of the verification
1798/// pipeline. Per-`DataRef` slots track outcomes for each entry in
1799/// `body.data_refs`, in declaration order:
1800///
1801/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
1802///   payload's `content_hash` matched; `Err` when it didn't (or the
1803///   embedded was malformed). Refs without an embedded payload or
1804///   without a declared `content_hash` produce `Ok(0)`.
1805/// - `data_ref_external[i]` — `None` when no external fetch was
1806///   attempted (either no `location` or no `fetcher` was provided);
1807///   `Some(Ok(bytes_len))` when the fetch + hash succeeded;
1808///   `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
1809///   timeout, …).
1810///
1811/// `AcdpError` implements `Clone` (see its doc), which is what lets
1812/// [`Self::revocation_discovery`]'s failure also be independently owned
1813/// by [`VerifiedContext::revocation_discovery_failure`] from the same
1814/// `fetch_report*` call; `AcdpError` still has no `PartialEq`, so
1815/// asserting on any `AcdpError`-carrying field here wants `matches!`.
1816///
1817/// `#[non_exhaustive]`: this struct has already gained a field once as a
1818/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
1819/// context-identity binding), and it is output-only — constructed solely
1820/// inside this crate (`verified.rs`) — so downstream loses nothing by
1821/// being unable to construct it directly. Same rationale as `SsrfReason`
1822/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
1823/// ranges"): future fields stop being breaking changes for callers that
1824/// only read this report.
1825#[derive(Debug)]
1826#[non_exhaustive]
1827pub struct VerificationReport {
1828    /// `content_hash` recomputed from the body matches the declared one.
1829    pub body_hash_ok: bool,
1830    /// The producer signature verified against the resolved DID key.
1831    pub signature_ok: bool,
1832    /// `validate_body` passed (or was disabled by policy).
1833    pub schema_ok: bool,
1834    /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
1835    pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
1836    /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
1837    /// `None` indicates "not attempted" (no fetcher provided or no
1838    /// `location` to fetch from).
1839    pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
1840    /// The served body's `ctx_id` equals the one requested
1841    /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
1842    /// identity"). `false` means the registry served a different,
1843    /// validly-signed body under the requested id (context
1844    /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
1845    /// the full rationale. This flag gates whether
1846    /// [`VerifiedContext::fetch_report_diagnose`] hands back a
1847    /// `Some(VerifiedContext)` — appended last so any positional
1848    /// construction fails loudly rather than silently binding the wrong
1849    /// field.
1850    pub ctx_id_ok: bool,
1851    /// The real P3-P6 verdict from `verify_retrieved`'s authorization
1852    /// phases (receipt, revocation, signature/historical, unknown-status),
1853    /// when they ran and all passed. `None` means either "not reached"
1854    /// (a top-level probe — schema, body hash, signature, ctx_id — failed
1855    /// first, so `verify_retrieved` was never invoked) or "the phase ran
1856    /// and failed" (see [`Self::policy_phase_error`] for which one).
1857    pub key_status: Option<KeyAuthorization>,
1858    /// Which of `verify_retrieved`'s policy-governed phases (receipt,
1859    /// revocation, signature/historical-key, unknown-status) failed, when
1860    /// one did. `None` when every phase passed, or when `verify_retrieved`
1861    /// was never invoked because a top-level probe failed first.
1862    /// `AcdpError` derives `Clone` (see its doc — added for this field's
1863    /// and [`VerifiedContext::revocation_discovery_failure`]'s sake), but
1864    /// asserting on it still wants `matches!` over `==`/`assert_eq!`:
1865    /// `AcdpError` has no `PartialEq`.
1866    pub policy_phase_error: Option<AcdpError>,
1867    /// What RFC-ACDP-0014 §8 auto-discovery did, when
1868    /// `policy.revocations.discover` was `Some` and `verify_retrieved`
1869    /// was reached (a top-level probe failure or a
1870    /// [`DiscoveryFailurePolicy::FailClosed`] discovery failure both
1871    /// leave this `None` — the latter surfaces via
1872    /// [`Self::policy_phase_error`] instead, since `verify_retrieved`
1873    /// returned `Err` before there was any outcome to record). `Some(Ok(_))`
1874    /// on success; `Some(Err(_))` when
1875    /// [`DiscoveryFailurePolicy::ProceedWithKnown`] swallowed a
1876    /// discovery failure and verification proceeded on
1877    /// [`RevocationPolicy::known`] alone — see
1878    /// [`VerifiedContext::revocation_discovery_failure`] for the twin
1879    /// surface on the verified handle itself, populated from the same
1880    /// event. The counts inside [`DiscoveryOutcome`] are discovery
1881    /// output only, never `known` — appended last, after
1882    /// `policy_phase_error`, for the same "fail loudly on stale
1883    /// positional construction" reason that field was.
1884    pub revocation_discovery: Option<Result<DiscoveryOutcome, AcdpError>>,
1885}
1886
1887/// Sentinel `DataRefFetcher` used as the type parameter for
1888/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
1889/// actually called — the option is matched out before that — but
1890/// providing a real impl lets the generic monomorphize cleanly without
1891/// requiring `fetch_report`'s callers to name a type.
1892struct NoFetcher;
1893
1894impl DataRefFetcher for NoFetcher {
1895    async fn fetch(
1896        &self,
1897        _location: &acdp_types::data_ref::Location,
1898    ) -> Result<Vec<u8>, AcdpError> {
1899        Err(AcdpError::NotImplemented(
1900            "NoFetcher should never be called — this is a fetch_report sentinel".into(),
1901        ))
1902    }
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::{
1908        DiscoveryFailurePolicy, HistoricalKeyPolicy, ReceiptPolicy, RevocationDiscovery,
1909        RevocationPolicy, VerificationPolicy,
1910    };
1911    use acdp_primitives::error::AcdpError;
1912    use std::time::Duration;
1913
1914    /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
1915    /// semantics: receipts inert, assertionMethod-only keys. It is
1916    /// deliberately NOT the 0.2 default (which is receipt-aware).
1917    #[test]
1918    fn strict_v0_1_0_preserves_v0_1_0_semantics() {
1919        let strict = VerificationPolicy::strict_v0_1_0();
1920        assert!(strict.validate_body_schema);
1921        assert!(strict.allow_unknown_status);
1922        assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
1923        assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
1924        assert!(
1925            strict.revocations.known.is_empty(),
1926            "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
1927        );
1928        assert_ne!(
1929            strict,
1930            VerificationPolicy::default(),
1931            "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
1932        );
1933    }
1934
1935    /// Phase 2 acceptance criterion 6 — the spine lock.
1936    ///
1937    /// `verify_retrieved` must be the SOLE reader of the four
1938    /// authorization-policy fields (`receipts`, `revocations`,
1939    /// `historical_keys`, `allow_unknown_status`) anywhere in this file.
1940    /// Every public entry point (the four `fetch*` forms plus the three
1941    /// report forms) reaches every authorization phase through that one
1942    /// function, so a future RFC phase added anywhere else — instead of
1943    /// inside `verify_retrieved` — trips this test instead of silently
1944    /// reintroducing the exact divergence this phase fixed.
1945    ///
1946    /// Implemented as a plain `str` scan (no `regex` — it is not a
1947    /// dependency of `acdp-client`) over this file's own source, read via
1948    /// `include_str!`. `verify_retrieved`'s body span is located by
1949    /// brace-counting from its own opening brace (its signature has no
1950    /// braces of its own — only angle brackets in the return type — so
1951    /// the first `{` after the `fn` keyword IS the body's opening brace),
1952    /// not by hard-coded line numbers, so the check survives any diff.
1953    /// Lines whose trimmed start is `//` (covers `///` too), and matches
1954    /// that fall inside a string literal (detected by an odd count of
1955    /// unescaped `"` before the match on its line — this file's one
1956    /// in-string occurrence, the `allow_unknown_status=false` error
1957    /// message, already lives inside `verify_retrieved` regardless), are
1958    /// excluded.
1959    ///
1960    /// The four search patterns are built by runtime concatenation
1961    /// (`policy.` + each field name) rather than written as contiguous
1962    /// `"policy.receipts"`-style literals, so this test's own source —
1963    /// included verbatim via `include_str!` — does not self-match its
1964    /// own patterns.
1965    #[test]
1966    fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
1967        const SRC: &str = include_str!("verified.rs");
1968
1969        let policy_prefix = "policy.";
1970        let fields = [
1971            "receipts",
1972            "revocations",
1973            "historical_keys",
1974            "allow_unknown_status",
1975        ];
1976        let patterns: Vec<String> = fields
1977            .iter()
1978            .map(|f| format!("{policy_prefix}{f}"))
1979            .collect();
1980
1981        // Locate `verify_retrieved`'s body span.
1982        let fn_start = SRC
1983            .find("async fn verify_retrieved(")
1984            .expect("verify_retrieved must exist in verified.rs");
1985        let body_open = fn_start
1986            + SRC[fn_start..]
1987                .find('{')
1988                .expect("verify_retrieved must have a body");
1989        let mut depth = 0i32;
1990        let mut body_close = None;
1991        for (i, ch) in SRC[body_open..].char_indices() {
1992            match ch {
1993                '{' => depth += 1,
1994                '}' => {
1995                    depth -= 1;
1996                    if depth == 0 {
1997                        body_close = Some(body_open + i);
1998                        break;
1999                    }
2000                }
2001                _ => {}
2002            }
2003        }
2004        let body_close =
2005            body_close.expect("verify_retrieved's matching closing brace must be found");
2006        assert!(
2007            body_close > body_open,
2008            "sanity: verify_retrieved's body must be non-empty"
2009        );
2010
2011        // Scan the whole file, tracking byte offsets so each match's
2012        // position can be tested against the body span.
2013        let mut offset = 0usize;
2014        let mut checked_any = false;
2015        for line in SRC.split_inclusive('\n') {
2016            let trimmed = line.trim_start();
2017            let is_comment_line = trimmed.starts_with("//");
2018            if !is_comment_line {
2019                for pattern in &patterns {
2020                    let mut search_from = 0usize;
2021                    while let Some(rel) = line[search_from..].find(pattern.as_str()) {
2022                        let match_col = search_from + rel;
2023                        let match_start = offset + match_col;
2024                        let before = &line[..match_col];
2025                        let in_string_literal = before.matches('"').count() % 2 == 1;
2026                        if !in_string_literal {
2027                            checked_any = true;
2028                            assert!(
2029                                match_start >= body_open && match_start < body_close,
2030                                "found `{pattern}` outside verify_retrieved's body \
2031                                 (byte offset {match_start}, line: {line:?}) — every \
2032                                 authorization-policy-field read must live inside \
2033                                 verify_retrieved, the sole reader"
2034                            );
2035                        }
2036                        search_from = match_col + pattern.len();
2037                    }
2038                }
2039            }
2040            offset += line.len();
2041        }
2042
2043        // Second pass — whitespace-normalized, to catch a read rustfmt
2044        // has wrapped across lines (e.g. a `policy` / `.revocations` /
2045        // `.known` chain on three separate lines), which the
2046        // line-by-line pass above cannot see since the pattern never
2047        // sits contiguously on any single line. Build a copy of `SRC`
2048        // with whitespace immediately touching a `.` removed — folding
2049        // any such wrapped chain back to its unwrapped spelling — while
2050        // recording, for every byte kept, the byte offset it came from
2051        // in the original `SRC`. Every match here is re-validated
2052        // against its ORIGINAL line for the same comment / string
2053        // exclusions the first pass applies, so this pass only adds
2054        // coverage; it does not relax anything the first pass enforces.
2055        let mut normalized = String::with_capacity(SRC.len());
2056        let mut orig_offsets: Vec<usize> = Vec::with_capacity(SRC.len());
2057        let mut after_dot = false;
2058        for (i, ch) in SRC.char_indices() {
2059            if ch == '.' {
2060                while let Some(last) = normalized.chars().last() {
2061                    if !last.is_whitespace() {
2062                        break;
2063                    }
2064                    normalized.pop();
2065                    let new_len = orig_offsets.len() - last.len_utf8();
2066                    orig_offsets.truncate(new_len);
2067                }
2068                normalized.push(ch);
2069                orig_offsets.push(i);
2070                after_dot = true;
2071                continue;
2072            }
2073            if after_dot && ch.is_whitespace() {
2074                continue; // swallow whitespace immediately after a dot
2075            }
2076            after_dot = false;
2077            normalized.push(ch);
2078            for _ in 0..ch.len_utf8() {
2079                orig_offsets.push(i);
2080            }
2081        }
2082        debug_assert_eq!(normalized.len(), orig_offsets.len());
2083
2084        let is_comment_line_at = |pos: usize| -> bool {
2085            let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
2086            let line_end = SRC[pos..].find('\n').map_or(SRC.len(), |i| pos + i);
2087            SRC[line_start..line_end].trim_start().starts_with("//")
2088        };
2089        let in_string_literal_at = |pos: usize| -> bool {
2090            let line_start = SRC[..pos].rfind('\n').map_or(0, |i| i + 1);
2091            SRC[line_start..pos].matches('"').count() % 2 == 1
2092        };
2093
2094        for pattern in &patterns {
2095            let mut search_from = 0usize;
2096            while let Some(rel) = normalized[search_from..].find(pattern.as_str()) {
2097                let match_col = search_from + rel;
2098                let orig_start = orig_offsets[match_col];
2099                if !is_comment_line_at(orig_start) && !in_string_literal_at(orig_start) {
2100                    checked_any = true;
2101                    assert!(
2102                        orig_start >= body_open && orig_start < body_close,
2103                        "found `{pattern}` (whitespace-normalized) outside \
2104                         verify_retrieved's body (original byte offset {orig_start}) \
2105                         — every authorization-policy-field read must live inside \
2106                         verify_retrieved, the sole reader, even when rustfmt has \
2107                         wrapped the field-access chain across multiple lines"
2108                    );
2109                }
2110                search_from = match_col + pattern.len();
2111            }
2112        }
2113
2114        assert!(
2115            checked_any,
2116            "sanity: the scan must find at least one non-comment, non-string-literal \
2117             match for at least one pattern (verify_retrieved itself reads these \
2118             fields) — zero hits would mean the patterns are miscomputed, not that \
2119             the invariant holds"
2120        );
2121    }
2122
2123    /// issue #248 Phase 2, acceptance criterion 1 — `RevocationPolicy::default()`
2124    /// stays behaviorally identical to the pre-Phase-2 shape: no known
2125    /// revocations, discovery off.
2126    #[test]
2127    fn revocation_policy_default_is_unchanged() {
2128        let policy = RevocationPolicy::default();
2129        assert!(policy.known.is_empty());
2130        assert!(policy.discover.is_none());
2131    }
2132
2133    /// issue #248 Phase 2, acceptance criterion 1 (continued) — `::new`
2134    /// produces the same shape as the old bare-literal `RevocationPolicy { known }`
2135    /// this struct's `#[non_exhaustive]` retires.
2136    #[test]
2137    fn revocation_policy_new_leaves_discovery_off() {
2138        let policy = RevocationPolicy::new(vec![]);
2139        assert!(policy.known.is_empty());
2140        assert!(policy.discover.is_none());
2141    }
2142
2143    /// issue #248 Phase 2, acceptance criterion 3.
2144    #[test]
2145    fn discovery_failure_policy_defaults_to_fail_closed() {
2146        assert_eq!(
2147            DiscoveryFailurePolicy::default(),
2148            DiscoveryFailurePolicy::FailClosed
2149        );
2150    }
2151
2152    /// issue #248 Phase 2, acceptance criterion 3.
2153    #[test]
2154    fn revocation_discovery_constructors_set_the_right_trust_classes() {
2155        let producer_only = RevocationDiscovery::producer_signed_only();
2156        assert!(!producer_only.include_registry_attested);
2157        assert_eq!(producer_only.on_failure, DiscoveryFailurePolicy::FailClosed);
2158        assert_eq!(producer_only.total_timeout, Duration::from_secs(30));
2159        assert_eq!(
2160            producer_only.freshness,
2161            Duration::ZERO,
2162            "issue #257: freshness defaults to ZERO (off) from both named constructors"
2163        );
2164
2165        let all = RevocationDiscovery::all_trust_classes();
2166        assert!(all.include_registry_attested);
2167        assert_eq!(all.on_failure, DiscoveryFailurePolicy::FailClosed);
2168        assert_eq!(all.total_timeout, Duration::from_secs(30));
2169        assert_eq!(all.freshness, Duration::ZERO);
2170    }
2171
2172    /// issue #248 Phase 2, acceptance criterion 3 (continued) — D6:
2173    /// `RevocationDiscovery` has no `Default` impl. This is a
2174    /// compile-time property, not something a runtime assertion can
2175    /// check; the doc comment on `RevocationDiscovery` records why.
2176    /// This test exists to make that guarantee discoverable from the
2177    /// test suite: if a future change adds `Default`, this comment is
2178    /// the tripwire a reviewer reads, since nothing here would fail.
2179    /// (An attempted `RevocationDiscovery::default()` call would be a
2180    /// compile error today — that IS the enforcement.)
2181    #[test]
2182    fn revocation_discovery_has_no_default_by_design() {
2183        // Deliberately empty: the guarantee is enforced by the type
2184        // system (no `Default` impl exists), not by this test body.
2185    }
2186
2187    /// issue #248 Phase 2, acceptance criterion 4 — `RevocationDiscoveryFailed`
2188    /// delegates `is_transient` to its `source`, both ways.
2189    #[test]
2190    fn revocation_discovery_failed_delegates_is_transient_to_source() {
2191        let transient = AcdpError::RevocationDiscoveryFailed {
2192            source: Box::new(AcdpError::KeyResolutionUnreachable(
2193                "did:web host unreachable".into(),
2194            )),
2195        };
2196        assert!(
2197            transient.is_transient(),
2198            "a transient source must make the wrapper transient too"
2199        );
2200
2201        let permanent = AcdpError::RevocationDiscoveryFailed {
2202            source: Box::new(AcdpError::InvalidSignature("bad signature".into())),
2203        };
2204        assert!(
2205            !permanent.is_transient(),
2206            "a permanent source must make the wrapper permanent too"
2207        );
2208    }
2209}