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