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