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;
9
10/// Consumer-tunable strictness for [`VerifiedContext::fetch_with_policy`],
11/// [`VerifiedContext::fetch_current_with_policy`], and the
12/// `fetch_report*` family ([`VerifiedContext::fetch_report`],
13/// [`VerifiedContext::fetch_report_with_fetcher`],
14/// [`VerifiedContext::fetch_report_diagnose`]). All three surfaces
15/// consult the same policy fields through the same `verify_retrieved`
16/// spine — but they do not always *agree*, because
17/// [`VerifiedContext::fetch_report_diagnose`] differs in more than just
18/// how a failure surfaces:
19///
20/// - [`VerifiedContext::fetch_report`] and
21///   [`VerifiedContext::fetch_report_with_fetcher`] run
22///   `verify_retrieved` directly once their own top-level probes pass,
23///   and surface a phase failure as `Err`.
24/// - [`VerifiedContext::fetch_report_diagnose`] runs its own
25///   independent, strict, assertionMethod-only signature *probe* first
26///   (recorded as `VerificationReport::signature_ok`). That probe has
27///   no historical-key fallback and runs *before* `verify_retrieved` is
28///   ever invoked. If it fails, `diagnose` withholds the
29///   [`VerifiedContext`] handle with `policy_phase_error: None` — the
30///   spine never ran, so there is no phase error to record — even in
31///   cases where `verify_retrieved` itself, as run by `fetch_report`,
32///   would have accepted the key historically under the default
33///   `historical_keys: HistoricalKeyPolicy::AcceptWithReceipt` plus a
34///   verified receipt. Concretely: for a key rotated out of
35///   `assertionMethod` with a valid receipt, `fetch_report` returns
36///   `Ok` with [`KeyAuthorization::HistoricallyAuthorized`], while
37///   `diagnose` returns no handle at all for the same input and policy.
38///   Only once `diagnose`'s own probes all pass does it fall through to
39///   `verify_retrieved` and, from that point on, withhold the handle /
40///   record [`VerificationReport::policy_phase_error`] instead of
41///   returning `Err` — that part of the behavior *is* shared with the
42///   other two.
43///
44/// For ACDP v0.1.0 the verification profile is **always strict**:
45///
46/// - `did:web` is required for every producer identity — enforced
47///   unconditionally by `verify_signature_envelope`
48///   (RFC-ACDP-0001 §5.4), regardless of any policy field.
49/// - Embedded `DataRef` hashes are verified by
50///   [`acdp_validation::validate_body`] whenever `validate_body_schema`
51///   is set.
52///
53/// Only the fields below have real effect in this version; there are no
54/// relaxed-mode `did:web` or embedded-hash knobs.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct VerificationPolicy {
57    /// If true, run [`acdp_validation::validate_body`] (structural
58    /// schema checks plus embedded-`DataRef` hash verification) before
59    /// any cryptographic check. Default `true`. Set `false` only in
60    /// diagnostic paths that want to attempt signature verification
61    /// despite a body known to fail structural checks.
62    ///
63    /// The `fetch_report*` family forces this field off unconditionally
64    /// on the internal policy it derives from the caller's — they run
65    /// `validate_body_structural` (schema only) themselves and record
66    /// per-`DataRef` embedded-hash outcomes in
67    /// [`VerificationReport::data_ref_embedded`] instead of treating a
68    /// mismatch as fatal. This field's value as set by the caller is
69    /// otherwise irrelevant to the report family.
70    pub validate_body_schema: bool,
71
72    /// If true, accept `Status::Other` values (degrade to active per
73    /// RFC-ACDP-0004 §4.1). When false, reject unknown statuses.
74    /// Default `true`.
75    pub allow_unknown_status: bool,
76
77    /// Registry-receipt handling (ACDP 0.2, RFC-ACDP-0010).
78    /// Default [`ReceiptPolicy::VerifyIfPresent`].
79    pub receipts: ReceiptPolicy,
80
81    /// Historical-key handling (ACDP 0.2, WS-B). Default
82    /// [`HistoricalKeyPolicy::AcceptWithReceipt`].
83    pub historical_keys: HistoricalKeyPolicy,
84
85    /// Lineage-head receipt handling on `/current` fetches (ACDP 0.3,
86    /// RFC-ACDP-0011). Only consulted by
87    /// [`VerifiedContext::fetch_current_with_policy`]; plain retrieval
88    /// preserves any `lineage_head_receipt` verbatim without verifying
89    /// it. Default [`LineageHeadPolicy::default`]. This is the ONE
90    /// field on this struct with restricted scope — `allow_unknown_status`,
91    /// `receipts`, `historical_keys`, and `revocations` above and below
92    /// are each honored by every entry point that accepts a
93    /// [`VerificationPolicy`], including the `fetch_report*` family.
94    pub lineage_head: LineageHeadPolicy,
95
96    /// Key-revocation handling (ACDP 0.3, RFC-ACDP-0014 §7). Default:
97    /// no known revocations — the phase is inert.
98    pub revocations: RevocationPolicy,
99}
100
101impl Default for VerificationPolicy {
102    fn default() -> Self {
103        Self {
104            validate_body_schema: true,
105            allow_unknown_status: true,
106            receipts: ReceiptPolicy::VerifyIfPresent,
107            historical_keys: HistoricalKeyPolicy::AcceptWithReceipt,
108            lineage_head: LineageHeadPolicy::default(),
109            revocations: RevocationPolicy::default(),
110        }
111    }
112}
113
114/// Consumer-held key revocations to enforce during verification
115/// (ACDP 0.3, RFC-ACDP-0014 §7).
116///
117/// The revocation signal is **pull-based**: the pipeline does not go
118/// looking for revocations on its own — the caller supplies the
119/// **verified** revocations it holds (from
120/// [`find_revocations`](crate::revocation::find_revocations),
121/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations),
122/// an out-of-band channel, or its own indefinite cache — the statement
123/// is permanent, cache accordingly). When `known` is empty the phase
124/// is inert and verification behaves exactly as before RFC-ACDP-0014.
125///
126/// When the body's signing key matches a supplied revocation, §7
127/// applies: a receipt-attested publish time strictly before the
128/// (earliest, §4) `compromised_since` boundary verifies as
129/// [`KeyAuthorization::HistoricallyAuthorizedPreCompromise`]; at/after
130/// the boundary, or with no verified receipt to place the context at
131/// all, verification **fails closed** with `key_not_authorized` —
132/// regardless of DID-document state and regardless of the receipt's
133/// own validity. Note the interaction with [`ReceiptPolicy::Ignore`]:
134/// an unverified receipt provides no publish time, so a revoked key's
135/// contexts all fail closed under it.
136///
137/// This applies uniformly to every entry point that accepts a
138/// [`VerificationPolicy`] — [`VerifiedContext::fetch_with_policy`],
139/// [`VerifiedContext::fetch_current_with_policy`], and the
140/// `fetch_report*` family — since they all reach this phase through the
141/// same internal pipeline. On [`VerifiedContext::fetch_report_diagnose`]
142/// specifically, "fails closed" means the returned [`VerifiedContext`]
143/// handle is withheld and the cause is recorded in
144/// `VerificationReport::policy_phase_error`, rather than the call
145/// returning `Err` — that method never short-circuits on a policy-phase
146/// failure by design.
147///
148/// Only put revocations here that you have verified (strict body
149/// pipeline + the §5 not-self-signed rule) and, per §6, that you have
150/// decided to act on: producer-signed ones unconditionally;
151/// registry-attested ones ([`RevocationTrustClass::RegistryAttested`](acdp_types::revocation::RevocationTrustClass))
152/// by default only for contexts served by or receipted by that same
153/// registry, with corroboration before global application.
154///
155/// [`find_revocations`](crate::revocation::find_revocations) itself
156/// pre-filters its output to [`RevocationTrustClass::ProducerSigned`](acdp_types::revocation::RevocationTrustClass)
157/// entries actually published by the queried producer, so §6
158/// registry-attested attestations never arrive through it — obtain
159/// those from
160/// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
161/// instead.
162#[derive(Debug, Clone, PartialEq, Eq, Default)]
163pub struct RevocationPolicy {
164    /// Verified revocations to enforce, matched against the signing
165    /// key's RFC-ACDP-0010 §6 fingerprint. The §4 earliest-
166    /// `compromised_since` rule is applied across entries naming the
167    /// same fingerprint, so include *every* revocation of a lineage,
168    /// superseded (and retracted) ones too — a later member can only
169    /// widen the compromise window, never narrow it, and dropping an
170    /// earlier one is exactly how that window gets quietly (and
171    /// wrongly) shrunk. This is no longer an unassisted obligation:
172    /// [`find_revocations`](crate::revocation::find_revocations) and
173    /// [`find_registry_attested_revocations`](crate::revocation::find_registry_attested_revocations)
174    /// each walk the full lineage of every candidate they find
175    /// (search-visible or not, including all-retracted lineages) and
176    /// already return the complete set for their respective trust
177    /// class; [`find_revocations_in_lineage`](crate::revocation::find_revocations_in_lineage)
178    /// does the same directly from a known `lineage_id`, with no
179    /// producer/trust-class scope filter. Populate `known` from one of
180    /// these rather than hand-assembling a lineage.
181    pub known: Vec<acdp_types::revocation::KeyRevocation>,
182}
183
184/// How to treat the optional `registry_receipt` on retrieval
185/// (RFC-ACDP-0010).
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
187pub enum ReceiptPolicy {
188    /// Skip receipt verification entirely (0.1.0 behavior). The
189    /// receipt value is still preserved verbatim on the context.
190    Ignore,
191    /// Verify the receipt when one is present; absence is not an
192    /// error (the registry may simply be a 0.1.0 registry). Default.
193    #[default]
194    VerifyIfPresent,
195    /// Fail closed unless a receipt is present AND verifies. Use when
196    /// the deployment requires audit-grade provenance — registry
197    /// claims (`ctx_id`, `created_at`, `origin_registry`) are
198    /// assertions, not proofs, without a receipt.
199    ///
200    /// Honored identically by every entry point that accepts a
201    /// [`VerificationPolicy`] — `fetch_with_policy`,
202    /// `fetch_current_with_policy` (via [`LineageHeadPolicy::receipts`]),
203    /// and the `fetch_report*` family. On
204    /// [`VerifiedContext::fetch_report_diagnose`] the failure surfaces as
205    /// a withheld handle plus `VerificationReport::policy_phase_error`,
206    /// not an `Err` — see that method's doc.
207    Require,
208}
209
210/// How to treat the optional `lineage_head_receipt` on
211/// `GET /lineages/{id}/current` responses (ACDP 0.3, RFC-ACDP-0011).
212///
213/// The presence handling reuses the [`ReceiptPolicy`] vocabulary; the
214/// two numeric knobs are the RFC's consumer-side parameters:
215///
216/// - `max_clock_skew_seconds` — §7 step 6's forward-skew allowance. A
217///   receipt whose `as_of` is further in the future **fails
218///   verification** (`invalid_receipt`, fixture `lhr-004`). RFC
219///   RECOMMENDED: 120.
220/// - `max_age_seconds` — §6's freshness policy. A receipt older than
221///   this is still *verified* (it may be perfectly genuine — merely
222///   old); it is reported distinctly via
223///   [`VerifiedContext::head_receipt_stale`], never as a verification
224///   failure. RFC RECOMMENDED default: 300. `None` disables the
225///   staleness verdict.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct LineageHeadPolicy {
228    /// Presence handling: `Ignore` (skip verification, preserve
229    /// verbatim), `VerifyIfPresent` (default), or `Require` (fail
230    /// closed unless present AND verified — appropriate when the
231    /// registry advertises `acdp-registry-head-receipts`, under which
232    /// a head receipt on `/current` is REQUIRED, RFC-ACDP-0011 §6).
233    pub receipts: ReceiptPolicy,
234    /// RFC-ACDP-0011 §7 step 6 clock-skew allowance (default 120 s).
235    pub max_clock_skew_seconds: u32,
236    /// RFC-ACDP-0011 §6 maximum acceptable receipt age for the
237    /// staleness verdict (default `Some(300)`).
238    pub max_age_seconds: Option<u32>,
239}
240
241impl Default for LineageHeadPolicy {
242    fn default() -> Self {
243        Self {
244            receipts: ReceiptPolicy::VerifyIfPresent,
245            max_clock_skew_seconds: 120,
246            max_age_seconds: Some(300),
247        }
248    }
249}
250
251/// How to treat a producer key that is present in the DID document's
252/// `verificationMethod` but no longer in `assertionMethod` — i.e. a
253/// key the producer rotated out but retained per the RFC-ACDP-0010
254/// key-retention rule.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
256pub enum HistoricalKeyPolicy {
257    /// Strict 0.1.0 behavior: only `assertionMethod` keys verify.
258    /// Every context signed by a rotated-out key fails.
259    Reject,
260    /// Accept a retained key **only** when a verified registry receipt
261    /// attests (via `key_fingerprint`) that this exact key was the
262    /// authorized one at publish time. Without a verified receipt the
263    /// historical path never activates — fail closed. Default.
264    #[default]
265    AcceptWithReceipt,
266}
267
268/// How the producer key that verified the body relates to the
269/// producer's *current* DID document.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum KeyAuthorization {
272    /// The signing key is currently listed in `assertionMethod`.
273    CurrentlyAuthorized,
274    /// The signing key was rotated out of `assertionMethod` but is
275    /// retained in `verificationMethod`, and a verified registry
276    /// receipt attests it was the authorized key at publish time
277    /// (RFC-ACDP-0010). Weigh accordingly: valid history, not a
278    /// current endorsement.
279    HistoricallyAuthorized,
280    /// The signing key is **revoked** (a verified RFC-ACDP-0014
281    /// revocation names its fingerprint), but a verified registry
282    /// receipt attests the context was published strictly *before* the
283    /// compromise boundary `compromised_since` — it was signed while
284    /// the key was still the producer's, and verified under the
285    /// RFC-ACDP-0010 §10 historical rule (RFC-ACDP-0014 §7 step 2).
286    ///
287    /// Deliberately distinguishable from BOTH
288    /// [`Self::CurrentlyAuthorized`] and the no-revocation
289    /// [`Self::HistoricallyAuthorized`]: the revocation and its
290    /// boundary MUST be visible in the verdict — even a key still
291    /// listed in `assertionMethod` MUST NOT be reported as fully
292    /// current once revoked. This holds for every entry point that
293    /// accepts a [`VerificationPolicy`], including the `fetch_report*`
294    /// family: they derive `key_status` from the same `verify_retrieved`
295    /// phase `fetch_with_policy` uses, so a revoked key cannot silently
296    /// surface as [`Self::CurrentlyAuthorized`] on any of them. Contexts
297    /// by the same key at/after the boundary — or with no verifiable
298    /// publish time — never reach a status at all: they fail closed
299    /// with `key_not_authorized` (§7 steps 3–4).
300    HistoricallyAuthorizedPreCompromise,
301}
302
303impl VerificationPolicy {
304    /// The v0.1.0 strict verification profile (RFC-ACDP-0001 §5.11, §9.2).
305    ///
306    /// Runs the full §5.11 pipeline: body schema validation, `content_hash`
307    /// recomputation, `did:web` key resolution, signature verification, and
308    /// embedded `data_ref.content_hash` checks. Returns on the first failure.
309    ///
310    /// This is the **only** mode covered by the `acdp-consumer` conformance
311    /// profile. Relaxed modes (`Diagnostic`, `UnsafeForTests`) are NOT
312    /// available in this crate in v0.1.0 — they would be separately-named
313    /// opt-ins per §9.2, and are not currently implemented.
314    ///
315    /// NOT identical to [`Default::default()`] as of 0.2: the default
316    /// policy is receipt-aware (`VerifyIfPresent` + `AcceptWithReceipt`),
317    /// while this named profile preserves the exact v0.1.0 semantics —
318    /// receipts inert ([`ReceiptPolicy::Ignore`]) and only
319    /// `assertionMethod` keys accepted
320    /// ([`HistoricalKeyPolicy::Reject`]). Callers pinned to this
321    /// constructor keep v0.1.0 behavior across the 0.2 upgrade.
322    pub fn strict_v0_1_0() -> Self {
323        Self {
324            validate_body_schema: true,
325            allow_unknown_status: true,
326            receipts: ReceiptPolicy::Ignore,
327            historical_keys: HistoricalKeyPolicy::Reject,
328            lineage_head: LineageHeadPolicy {
329                receipts: ReceiptPolicy::Ignore,
330                ..LineageHeadPolicy::default()
331            },
332            // A 0.1.0-pinned consumer predates RFC-ACDP-0014 and is
333            // unaffected by it (§10): no revocations enforced.
334            revocations: RevocationPolicy::default(),
335        }
336    }
337
338    /// The policy the report family (`fetch_report`,
339    /// `fetch_report_with_fetcher`, `fetch_report_diagnose`) passes to
340    /// [`VerifiedContext::verify_retrieved`].
341    ///
342    /// `validate_body_schema` is forced `false` unconditionally,
343    /// independent of the caller: P1 (schema) is always handled by the
344    /// report path itself — `validate_body_structural` plus per-`DataRef`
345    /// non-fatal recording of embedded-hash outcomes into
346    /// `VerificationReport::data_ref_embedded` — so the spine must always
347    /// skip its own full `validate_body` (structural + fatal embedded-hash
348    /// check) here. Every other field passes through verbatim. Do **not**
349    /// pass the caller's policy directly to `verify_retrieved` from a
350    /// report entry point; doing so reinstates the fatal embedded-hash
351    /// check the report path deliberately downgrades to non-fatal (see
352    /// `tests/tls_conformance.rs`'s
353    /// `fetch_report_records_embedded_hash_failure`).
354    fn derived_for_report(&self) -> Self {
355        Self {
356            validate_body_schema: false,
357            ..self.clone()
358        }
359    }
360}
361
362/// A retrieved context that has been cryptographically verified.
363///
364/// Every value of this type is the output of one of the
365/// `VerifiedContext::fetch*` pipelines, each of which independently
366/// recomputes `content_hash` (RFC-ACDP-0001 §5.11) and verifies the
367/// producer signature before the value is constructed. The fields are
368/// **private** precisely so this "cryptographically verified" invariant
369/// cannot be forged: there is no way to construct a `VerifiedContext`
370/// around an unverified [`FullContext`]. Downstream code can therefore
371/// trust the accessors below without re-deriving anything.
372#[derive(Debug)]
373pub struct VerifiedContext {
374    inner: FullContext,
375    /// Whether the body verified against a currently authorized key or
376    /// a receipt-attested historical one (ACDP 0.2, WS-B).
377    key_status: KeyAuthorization,
378    /// The verified registry receipt, when one was present and the
379    /// policy verified it (RFC-ACDP-0010). `None` under
380    /// [`ReceiptPolicy::Ignore`] or when the registry minted none.
381    verified_receipt: Option<acdp_types::receipt::RegistryReceipt>,
382    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
383    /// when one was present and the policy verified it. Only populated
384    /// by [`Self::fetch_current`] / [`Self::fetch_current_with_policy`]
385    /// — plain retrieval preserves the raw value verbatim without
386    /// verification. Per §7 this verdict is independent of the body
387    /// verdict and the RFC-ACDP-0010 receipt verdict.
388    verified_head_receipt: Option<acdp_types::receipt::LineageHeadReceipt>,
389    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
390    /// receipt, reported distinctly from verification: `Some(true)`
391    /// when the (genuine, verified) receipt's `as_of` is older than
392    /// [`LineageHeadPolicy::max_age_seconds`]; `Some(false)` when
393    /// within policy; `None` when there is no verified head receipt or
394    /// the max-age knob is disabled.
395    head_receipt_stale: Option<bool>,
396}
397
398impl VerifiedContext {
399    /// Retrieve a context and verify its signature using the strict
400    /// default [`VerificationPolicy`].
401    pub async fn fetch(
402        client: &RegistryClient,
403        resolver: &WebResolver,
404        ctx_id: &CtxId,
405    ) -> Result<Self, AcdpError> {
406        Self::fetch_with_policy(client, resolver, ctx_id, &VerificationPolicy::default()).await
407    }
408
409    /// Retrieve a context and verify its signature with caller-controlled
410    /// strictness.
411    ///
412    /// 1. Fetches `body + registry_state` from the registry.
413    /// 2. Refuses a served body whose `ctx_id` differs from the one
414    ///    requested (`AcdpError::ContextIdMismatch`) — this implements
415    ///    RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind the resolved
416    ///    identity"): neither the signature check (step 5) nor the
417    ///    `content_hash` recomputation (step 6) can supply this binding,
418    ///    because `ctx_id` sits in the RFC-ACDP-0001 §5.7 registry-assigned
419    ///    exclusion set and is therefore stripped from ProducerContent
420    ///    before hashing. See RFC-ACDP-0008 §9.1 for the threat this
421    ///    closes: without it, a registry can serve any other
422    ///    validly-signed body by the same producer under the requested
423    ///    context's URL, and both preceding checks still pass. Step 7
424    ///    permits a consumer to surface "an equivalent typed error" in
425    ///    place of the registry-side `cross_registry_resolution_failed`
426    ///    wire code — `ContextIdMismatch` is that typed error. This
427    ///    generalizes the receipt-path analogue at RFC-ACDP-0010 §8 step 3
428    ///    to the receipt-less core-profile path, where it is the only
429    ///    binding available. It does **not** close §9.1 in full: a
430    ///    registry that genuinely republishes the same content under a
431    ///    new `ctx_id` still passes; only serve-time substitution — a
432    ///    different id claimed to be the one requested — is caught.
433    /// 3. Optionally runs `validate_body` — structural schema checks
434    ///    plus embedded-`DataRef` hash verification (policy-controlled).
435    /// 4. Recomputes `content_hash` over ProducerContent.
436    /// 5. Resolves the producer's DID document. `did:web` is required
437    ///    unconditionally for v0.1.0 (RFC-ACDP-0001 §5.4).
438    /// 6. Verifies the Ed25519 signature (or other supported algorithm).
439    /// 7. Optionally verifies the `registry_receipt` placeholder.
440    /// 8. Optionally rejects unknown statuses.
441    pub async fn fetch_with_policy(
442        client: &RegistryClient,
443        resolver: &WebResolver,
444        ctx_id: &CtxId,
445        policy: &VerificationPolicy,
446    ) -> Result<Self, AcdpError> {
447        let ctx = client.retrieve(ctx_id).await?;
448        let (key_status, verified_receipt) =
449            Self::verify_retrieved(client, resolver, &ctx, ctx_id, policy).await?;
450        Ok(Self {
451            inner: ctx,
452            key_status,
453            verified_receipt,
454            verified_head_receipt: None,
455            head_receipt_stale: None,
456        })
457    }
458
459    /// Retrieve the current head of a lineage
460    /// (`GET /lineages/{lineage_id}/current`) and verify it with the
461    /// strict default [`VerificationPolicy`] — including the
462    /// lineage-head receipt when the registry minted one (ACDP 0.3,
463    /// RFC-ACDP-0011).
464    pub async fn fetch_current(
465        client: &RegistryClient,
466        resolver: &WebResolver,
467        lineage_id: &acdp_types::primitives::LineageId,
468    ) -> Result<Self, AcdpError> {
469        Self::fetch_current_with_policy(
470            client,
471            resolver,
472            lineage_id,
473            &VerificationPolicy::default(),
474        )
475        .await
476    }
477
478    /// Retrieve + verify the current head of a lineage with
479    /// caller-controlled strictness.
480    ///
481    /// Runs the same pipeline as [`Self::fetch_with_policy`] against
482    /// the `/current` response (the expected `ctx_id` is the served
483    /// body's own — there is no requested identifier on this endpoint;
484    /// the head receipt's §7 step 5 byte-match is what binds it), then
485    /// applies `policy.lineage_head` to the response's
486    /// `lineage_head_receipt` per RFC-ACDP-0011 §7:
487    ///
488    /// - [`ReceiptPolicy::Ignore`] — the raw value is preserved
489    ///   verbatim, unverified.
490    /// - [`ReceiptPolicy::VerifyIfPresent`] — verified when present
491    ///   (absence is fine: the registry may not advertise
492    ///   `acdp-registry-head-receipts`).
493    /// - [`ReceiptPolicy::Require`] — fail closed with
494    ///   `invalid_receipt` unless present AND verified.
495    ///
496    /// Verification fetches the registry's capabilities document for
497    /// the §7 step 3 `capabilities.registry_did` binding. Staleness
498    /// beyond `policy.lineage_head.max_age_seconds` is a *freshness*
499    /// verdict reported via [`Self::head_receipt_stale`], never a
500    /// verification failure (§6).
501    ///
502    /// [`Self::fetch_with_policy`] now additionally refuses a served body
503    /// whose `ctx_id` is not the one requested (RFC-ACDP-0008 §9.1). This
504    /// endpoint has no requested identifier to compare against — the
505    /// served head's `ctx_id` is trivially "the one requested" — so on a
506    /// receipt-less registry the served head's identity rests entirely on
507    /// registry honesty (RFC-ACDP-0008 §9.1). Use [`ReceiptPolicy::Require`]
508    /// where that matters.
509    pub async fn fetch_current_with_policy(
510        client: &RegistryClient,
511        resolver: &WebResolver,
512        lineage_id: &acdp_types::primitives::LineageId,
513        policy: &VerificationPolicy,
514    ) -> Result<Self, AcdpError> {
515        let ctx = client.current(lineage_id).await?;
516        let served_ctx_id = ctx.body.ctx_id.clone();
517        let (key_status, verified_receipt) =
518            Self::verify_retrieved(client, resolver, &ctx, &served_ctx_id, policy).await?;
519
520        // ── Lineage-head receipt phase (RFC-ACDP-0011) ──────────────
521        let (verified_head_receipt, head_receipt_stale) =
522            match (policy.lineage_head.receipts, &ctx.lineage_head_receipt) {
523                (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => (None, None),
524                (ReceiptPolicy::Require, None) => {
525                    return Err(AcdpError::InvalidReceipt(
526                        "policy requires a lineage-head receipt but the /current response \
527                         carries none (registry without the acdp-registry-head-receipts \
528                         profile?)"
529                            .into(),
530                    ));
531                }
532                (_, Some(value)) => {
533                    let serving_authority = client
534                        .authority()
535                        .unwrap_or_else(|| served_ctx_id.authority().to_string());
536                    // §7 step 3 needs capabilities.registry_did — fetched
537                    // from the same authority the context came from.
538                    let caps = client.capabilities().await?;
539                    let receipt = super::receipt::verify_lineage_head_receipt_value(
540                        value,
541                        lineage_id,
542                        &served_ctx_id,
543                        ctx.body.version,
544                        &ctx.registry_state.status,
545                        true, // /current always serves the attested head
546                        &serving_authority,
547                        &caps.registry_did,
548                        chrono::Duration::seconds(
549                            policy.lineage_head.max_clock_skew_seconds as i64,
550                        ),
551                        resolver,
552                    )
553                    .await?;
554                    let stale = policy.lineage_head.max_age_seconds.map(|max| {
555                        receipt.age_at(chrono::Utc::now()) > chrono::Duration::seconds(max as i64)
556                    });
557                    (Some(receipt), stale)
558                }
559            };
560
561        Ok(Self {
562            inner: ctx,
563            key_status,
564            verified_receipt,
565            verified_head_receipt,
566            head_receipt_stale,
567        })
568    }
569
570    /// The shared retrieve-side verification pipeline: body schema,
571    /// hash recomputation, RFC-ACDP-0010 receipt phase, signature
572    /// phase (with the receipt-gated historical-key fallback), and the
573    /// unknown-status policy check.
574    #[cfg_attr(
575        feature = "tracing",
576        tracing::instrument(
577            name = "acdp.verify_retrieved",
578            skip_all,
579            fields(ctx_id = %expected_ctx_id),
580            err(Display)
581        )
582    )]
583    async fn verify_retrieved(
584        client: &RegistryClient,
585        resolver: &WebResolver,
586        ctx: &FullContext,
587        expected_ctx_id: &CtxId,
588        policy: &VerificationPolicy,
589    ) -> Result<
590        (
591            KeyAuthorization,
592            Option<acdp_types::receipt::RegistryReceipt>,
593        ),
594        AcdpError,
595    > {
596        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
597        // the resolved identity"): refuse a served body whose `ctx_id`
598        // differs from the one requested, before any crypto or network
599        // work. `ctx_id` is registry-assigned and outside both the
600        // `content_hash` and signature coverage (RFC-ACDP-0001 §5.7's
601        // exclusion set), so this equality check is the only binding
602        // available when no receipt is served. See RFC-ACDP-0008 §9.1 for
603        // the threat this closes; it does not close §9.1 in full (a
604        // genuine republish under a new `ctx_id` still passes — only
605        // serve-time substitution is caught). Step 7 permits a
606        // consumer-side "equivalent typed error" in place of the
607        // registry-side `cross_registry_resolution_failed` wire code —
608        // `ContextIdMismatch` is that typed error.
609        if ctx.body.ctx_id != *expected_ctx_id {
610            return Err(AcdpError::ContextIdMismatch {
611                requested: expected_ctx_id.as_str().to_string(),
612                served: ctx.body.ctx_id.as_str().to_string(),
613            });
614        }
615
616        if policy.validate_body_schema {
617            acdp_validation::validate_body(&ctx.body)?;
618        }
619
620        // Hash recomputation first: from here on `ctx.body.content_hash`
621        // IS the independently recomputed value, which the receipt
622        // cross-check below relies on.
623        let verifier = Verifier::new(resolver);
624        verifier.verify_body_hash(&ctx.body)?;
625
626        // ── Receipt phase (RFC-ACDP-0010) ───────────────────────────
627        // Verified BEFORE the signature phase because the historical-
628        // key path is gated on a verified receipt.
629        let serving_authority = client
630            .authority()
631            .unwrap_or_else(|| expected_ctx_id.authority().to_string());
632        let verified_receipt = match (policy.receipts, &ctx.registry_receipt) {
633            (ReceiptPolicy::Ignore, _) | (ReceiptPolicy::VerifyIfPresent, None) => None,
634            (ReceiptPolicy::Require, None) => {
635                return Err(AcdpError::InvalidReceipt(
636                    "policy requires a registry receipt but the response carries none \
637                     (registry without the acdp-registry-receipts profile, or a \
638                     pre-receipts context)"
639                        .into(),
640                ));
641            }
642            (_, Some(value)) => {
643                let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
644                    &ctx.body.signature.key_id,
645                    &ctx.body.signature.algorithm,
646                    resolver,
647                )
648                .await?;
649                Some(
650                    super::receipt::verify_receipt_value(
651                        value,
652                        expected_ctx_id,
653                        &ctx.body,
654                        &ctx.body.content_hash,
655                        &fingerprint,
656                        &serving_authority,
657                        resolver,
658                    )
659                    .await?,
660                )
661            }
662        };
663
664        // ── Revocation phase (RFC-ACDP-0014 §7) ─────────────────────
665        // Runs after the receipt phase because the boundary comparison
666        // accepts ONLY a receipt-attested publish time (§7 step 1 —
667        // the bare body created_at is registry-assigned and MUST NOT
668        // be used). The verified receipt's key_fingerprint was already
669        // cross-checked against the body's signing key above (§8 step
670        // 5), so `verified_receipt.created_at` genuinely places THIS
671        // key's signature in time.
672        let revocation_verdict = if policy.revocations.known.is_empty() {
673            None
674        } else {
675            let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
676                &ctx.body.signature.key_id,
677                &ctx.body.signature.algorithm,
678                resolver,
679            )
680            .await?;
681            super::revocation::classify_under_revocation(
682                &policy.revocations.known,
683                &fingerprint,
684                verified_receipt.as_ref().map(|r| r.created_at),
685            )?
686        };
687
688        // ── Signature phase ──────────────────────────────────────────
689        // Standard path enforces assertionMethod membership. A
690        // KeyNotAuthorized failure falls back to the historical path
691        // only under AcceptWithReceipt AND a verified receipt — the
692        // receipt's key_fingerprint (already cross-checked against this
693        // exact key above) is what attests publish-time authorization.
694        let key_status = match revocation_verdict {
695            // Pre-compromise (§7 step 2): the signature is verified
696            // under the RFC-ACDP-0010 §10 historical rule — the key may
697            // legitimately have left assertionMethod (and SHOULD, §9),
698            // and even a key still in assertionMethod MUST NOT be
699            // reported as fully current once revoked. did:key material
700            // cannot rotate, so it takes the plain envelope path.
701            Some(pre_compromise) => {
702                if ctx.body.agent_id.as_str().starts_with("did:key:") {
703                    verifier.verify_body_signature(&ctx.body).await?;
704                } else {
705                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
706                }
707                pre_compromise
708            }
709            None => match verifier.verify_body_signature(&ctx.body).await {
710                Ok(()) => KeyAuthorization::CurrentlyAuthorized,
711                Err(AcdpError::KeyNotAuthorized(_))
712                    if policy.historical_keys == HistoricalKeyPolicy::AcceptWithReceipt
713                        && verified_receipt.is_some() =>
714                {
715                    acdp_verify::verify_body_signature_historical(&ctx.body, resolver).await?;
716                    KeyAuthorization::HistoricallyAuthorized
717                }
718                Err(e) => return Err(e),
719            },
720        };
721
722        if !policy.allow_unknown_status {
723            if let Some(other) = ctx.registry_state.status.as_other() {
724                return Err(AcdpError::SchemaViolation(format!(
725                    "policy.allow_unknown_status=false; registry returned '{other}'"
726                )));
727            }
728        }
729
730        Ok((key_status, verified_receipt))
731    }
732
733    /// Retrieve + verify, returning a structured [`VerificationReport`]
734    /// alongside the verified context. Does NOT attempt external
735    /// `DataRef` fetches — use [`Self::fetch_report_with_fetcher`] for
736    /// that. Each `data_ref_external` slot in the returned report is
737    /// `None`.
738    ///
739    /// Unlike [`Self::fetch_with_policy`], per-`DataRef` embedded-hash
740    /// failures are recorded in the report instead of aborting the
741    /// verification. The top-level checks (schema, body hash,
742    /// signature) remain hard-fail: if any of them fails, the method
743    /// returns an `AcdpError` and produces no report.
744    ///
745    /// For diagnostic callers that want a populated report even when
746    /// a top-level check fails (e.g. an audit walker that needs to
747    /// distinguish "wrong hash" from "wrong signature"), use
748    /// [`Self::fetch_report_diagnose`] instead.
749    pub async fn fetch_report(
750        client: &RegistryClient,
751        resolver: &WebResolver,
752        ctx_id: &CtxId,
753        policy: &VerificationPolicy,
754    ) -> Result<(Self, VerificationReport), AcdpError> {
755        Self::fetch_report_inner::<NoFetcher>(client, resolver, ctx_id, policy, None).await
756    }
757
758    /// Diagnostic variant of [`Self::fetch_report`] that never
759    /// short-circuits on a top-level failure — schema, body-hash, and
760    /// signature outcomes are each recorded individually in the
761    /// returned [`VerificationReport`]. Returns `Ok((None, report))`
762    /// when any top-level probe failed (the report shows which one);
763    /// `Ok((Some(verified), report))` only when every check passed
764    /// (FEAT-05) — and "every check" now genuinely means every
765    /// authorization phase (receipt, revocation, signature/
766    /// historical-key, unknown-status), not just the top-level probes:
767    /// once the probes pass, this method additionally runs the same
768    /// `verify_retrieved` phase `fetch_with_policy` does, and withholds
769    /// the handle — recording the cause in
770    /// [`VerificationReport::policy_phase_error`] — if that phase fails
771    /// too. Either way the method still returns `Ok`; it never converts
772    /// a policy-phase failure into an `Err`.
773    ///
774    /// Use cases:
775    /// - Audit walkers that need to classify failures by stage.
776    /// - Admin tooling that wants to distinguish "hash mismatch"
777    ///   (probable tampering / encoding drift) from "signature
778    ///   verification failed" (key compromise / DID resolution
779    ///   problem).
780    ///
781    /// Network errors from the initial retrieval still propagate as
782    /// `Err` — there's no body to inspect when the registry is
783    /// unreachable. But network/DID-resolution errors that occur
784    /// *inside* the `verify_retrieved` phase (e.g. resolving the
785    /// fingerprint for a receipt cross-check, or the historical-key
786    /// fallback) are caught there and land in
787    /// [`VerificationReport::policy_phase_error`] instead of `Err`,
788    /// same as any other phase failure — this method never
789    /// short-circuits once retrieval has succeeded. That means a
790    /// transient network flake at that stage can read as a policy
791    /// rejection (`Ok((None, report))`) rather than an `Err`. A caller
792    /// that needs to tell a flake from a genuine rejection should
793    /// inspect `policy_phase_error`'s [`AcdpError::is_transient`].
794    pub async fn fetch_report_diagnose(
795        client: &RegistryClient,
796        resolver: &WebResolver,
797        ctx_id: &CtxId,
798        policy: &VerificationPolicy,
799    ) -> Result<(Option<Self>, VerificationReport), AcdpError> {
800        let ctx = client.retrieve(ctx_id).await?;
801        let mut report = VerificationReport {
802            body_hash_ok: false,
803            signature_ok: false,
804            schema_ok: false,
805            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
806            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
807            ctx_id_ok: ctx.body.ctx_id == *ctx_id,
808            key_status: None,
809            policy_phase_error: None,
810        };
811
812        // Schema (structural) — record pass/fail.
813        if policy.validate_body_schema {
814            match acdp_validation::validate_body_structural(&ctx.body) {
815                Ok(()) => report.schema_ok = true,
816                Err(_) => { /* keep schema_ok=false; continue collecting */ }
817            }
818        } else {
819            report.schema_ok = true;
820        }
821
822        // Per-DataRef embedded hashes — same as fetch_report_inner.
823        for dr in &ctx.body.data_refs {
824            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
825                let outcome = acdp_validation::verify_embedded_hash(dr)
826                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
827                report.data_ref_embedded.push(outcome);
828            } else {
829                report.data_ref_embedded.push(Ok(0));
830            }
831        }
832
833        // Hash + signature recorded independently (FEAT-05).
834        let verifier = Verifier::new(resolver);
835        report.body_hash_ok = verifier.verify_body_hash(&ctx.body).is_ok();
836        report.signature_ok = verifier.verify_body_signature(&ctx.body).await.is_ok();
837
838        // External fetches were not attempted (this method has no
839        // fetcher param — diagnostic callers can wire their own).
840        for _ in &ctx.body.data_refs {
841            report.data_ref_external.push(None);
842        }
843
844        // Decide whether to surface the verified handle. The probes above
845        // are diagnostic — their whole value is continuing past failure —
846        // but the handle is a trust assertion (`VerifiedContext`'s
847        // invariant: "the accessors below can be trusted without
848        // re-deriving anything"), so it is only ever issued once the real
849        // authorization phases (receipt, revocation, signature/historical,
850        // unknown-status) have actually run and passed through
851        // `verify_retrieved` — never on the probes alone.
852        let all_top_level_pass =
853            report.schema_ok && report.body_hash_ok && report.signature_ok && report.ctx_id_ok;
854        let verified = if all_top_level_pass {
855            // The call MUST be hoisted out of the `match` scrutinee: in a
856            // match, scrutinee temporaries live to the end of the match,
857            // so the awaited future would still be holding `&ctx` inside
858            // the arms and `Self { inner: ctx, .. }` below would fail
859            // borrowck (E0505).
860            let outcome = Self::verify_retrieved(
861                client,
862                resolver,
863                &ctx,
864                ctx_id,
865                &policy.derived_for_report(),
866            )
867            .await; // borrow of `ctx` ends here
868            match outcome {
869                Ok((key_status, verified_receipt)) => {
870                    report.key_status = Some(key_status);
871                    Some(Self {
872                        inner: ctx,
873                        key_status,
874                        verified_receipt,
875                        verified_head_receipt: None,
876                        head_receipt_stale: None,
877                    })
878                }
879                Err(e) => {
880                    // Reports; never short-circuits — `fetch_report_diagnose`
881                    // still returns `Ok` in every case it does today.
882                    report.policy_phase_error = Some(e);
883                    None
884                }
885            }
886        } else {
887            None
888        };
889        Ok((verified, report))
890    }
891
892    /// Retrieve + verify like [`Self::fetch_report`], and additionally
893    /// fetch every `DataRef` whose `location` resolves through `fetcher`.
894    /// Each external fetch outcome is recorded in `report.data_ref_external`.
895    pub async fn fetch_report_with_fetcher<F: DataRefFetcher>(
896        client: &RegistryClient,
897        resolver: &WebResolver,
898        ctx_id: &CtxId,
899        policy: &VerificationPolicy,
900        fetcher: &F,
901    ) -> Result<(Self, VerificationReport), AcdpError> {
902        Self::fetch_report_inner(client, resolver, ctx_id, policy, Some(fetcher)).await
903    }
904
905    async fn fetch_report_inner<F: DataRefFetcher>(
906        client: &RegistryClient,
907        resolver: &WebResolver,
908        ctx_id: &CtxId,
909        policy: &VerificationPolicy,
910        fetcher: Option<&F>,
911    ) -> Result<(Self, VerificationReport), AcdpError> {
912        let ctx = client.retrieve(ctx_id).await?;
913
914        // Identifier binding — RFC-ACDP-0006 §4.1 step 7 (NORMATIVE, "Bind
915        // the resolved identity"). Same check as `verify_retrieved`,
916        // applied here (in addition to `verify_retrieved`'s own re-check
917        // below) so this fails before schema validation too — the early
918        // copy preserves fail-before-schema ordering that
919        // `tests/receipts.rs` depends on.
920        if ctx.body.ctx_id != *ctx_id {
921            return Err(AcdpError::ContextIdMismatch {
922                requested: ctx_id.as_str().to_string(),
923                served: ctx.body.ctx_id.as_str().to_string(),
924            });
925        }
926
927        let mut report = VerificationReport {
928            body_hash_ok: false,
929            signature_ok: false,
930            schema_ok: false,
931            data_ref_embedded: Vec::with_capacity(ctx.body.data_refs.len()),
932            data_ref_external: Vec::with_capacity(ctx.body.data_refs.len()),
933            ctx_id_ok: true,
934            key_status: None,
935            policy_phase_error: None,
936        };
937
938        // Structural-only schema validation — embedded-hash checks are
939        // intentionally skipped here so per-DataRef hash failures land
940        // in the report (below) instead of short-circuiting the whole
941        // verification. That's the diagnostic shape `fetch_report`
942        // promises in its docstring.
943        if policy.validate_body_schema {
944            acdp_validation::validate_body_structural(&ctx.body)?;
945        }
946        report.schema_ok = true;
947
948        // Per-DataRef embedded-hash outcomes — recorded individually.
949        for dr in &ctx.body.data_refs {
950            if let (Some(emb), Some(_)) = (&dr.embedded, &dr.content_hash) {
951                let outcome = acdp_validation::verify_embedded_hash(dr)
952                    .and_then(|()| acdp_validation::embedded_decoded_bytes(emb).map(|b| b.len()));
953                report.data_ref_embedded.push(outcome);
954            } else {
955                report.data_ref_embedded.push(Ok(0));
956            }
957        }
958
959        // Delegate the remaining phases — content_hash recomputation,
960        // RFC-ACDP-0010 receipt, RFC-ACDP-0014 revocation, signature (with
961        // the historical-key fallback), and the unknown-status check — to
962        // `verify_retrieved`, the sole reader of those policy fields. The
963        // derived policy forces `validate_body_schema` off (P1 was already
964        // handled, structurally-only, above) and passes everything else
965        // through verbatim — see `VerificationPolicy::derived_for_report`.
966        let (key_status, verified_receipt) =
967            Self::verify_retrieved(client, resolver, &ctx, ctx_id, &policy.derived_for_report())
968                .await?;
969        report.body_hash_ok = true;
970        report.signature_ok = true;
971        report.key_status = Some(key_status);
972
973        // External fetches — record per-ref outcomes when a fetcher is
974        // supplied; otherwise leave each slot as `None` so callers can
975        // distinguish "skipped" from "failed".
976        for dr in &ctx.body.data_refs {
977            let slot: Option<Result<usize, AcdpError>> = match (fetcher, &dr.location) {
978                (Some(f), Some(_)) => Some(fetch_and_verify_data_ref(dr, f).await.map(|b| b.len())),
979                _ => None,
980            };
981            report.data_ref_external.push(slot);
982        }
983
984        Ok((
985            Self {
986                inner: ctx,
987                key_status,
988                verified_receipt,
989                verified_head_receipt: None,
990                head_receipt_stale: None,
991            },
992            report,
993        ))
994    }
995
996    pub fn body(&self) -> &acdp_types::body::Body {
997        &self.inner.body
998    }
999
1000    pub fn registry_state(&self) -> &acdp_types::body::RegistryState {
1001        &self.inner.registry_state
1002    }
1003
1004    /// The verified [`FullContext`] (body + registry state + any
1005    /// receipts) in its retrieval shape. Every field was reached only
1006    /// after this context's hash + signature were verified.
1007    pub fn full_context(&self) -> &FullContext {
1008        &self.inner
1009    }
1010
1011    /// Whether the body verified against a currently authorized key, a
1012    /// receipt-attested historical one, or a receipt-attested
1013    /// pre-compromise one (ACDP 0.2 WS-B / RFC-ACDP-0014 §7). This is
1014    /// the real verdict regardless of which `fetch*`/`fetch_report*`
1015    /// entry point produced this `VerifiedContext` — every construction
1016    /// path runs the same `verify_retrieved` phase to derive it.
1017    pub fn key_status(&self) -> KeyAuthorization {
1018        self.key_status
1019    }
1020
1021    /// The verified registry receipt (RFC-ACDP-0010), when one was
1022    /// present and the policy verified it. `None` under
1023    /// [`ReceiptPolicy::Ignore`] or when the registry minted none — this
1024    /// is exhaustive; there is no additional "or you used a report path"
1025    /// carve-out, since `fetch_report`/`fetch_report_with_fetcher`/
1026    /// `fetch_report_diagnose` verify the receipt exactly like
1027    /// `fetch_with_policy` does. For the raw on-wire value see
1028    /// [`Self::receipt`].
1029    pub fn verified_receipt(&self) -> Option<&acdp_types::receipt::RegistryReceipt> {
1030        self.verified_receipt.as_ref()
1031    }
1032
1033    /// The verified lineage-head receipt (ACDP 0.3, RFC-ACDP-0011),
1034    /// populated only by [`Self::fetch_current`] /
1035    /// [`Self::fetch_current_with_policy`] when one was present and the
1036    /// policy verified it. For the raw on-wire value see
1037    /// [`Self::lineage_head_receipt`].
1038    pub fn verified_head_receipt(&self) -> Option<&acdp_types::receipt::LineageHeadReceipt> {
1039        self.verified_head_receipt.as_ref()
1040    }
1041
1042    /// RFC-ACDP-0011 §6 freshness verdict for the verified head
1043    /// receipt: `Some(true)` when the (genuine, verified) receipt's
1044    /// `as_of` is older than [`LineageHeadPolicy::max_age_seconds`];
1045    /// `Some(false)` when within policy; `None` when there is no
1046    /// verified head receipt or the max-age knob is disabled.
1047    pub fn head_receipt_stale(&self) -> Option<bool> {
1048        self.head_receipt_stale
1049    }
1050
1051    /// Raw registry receipt value as served on the wire
1052    /// (RFC-ACDP-0010), preserved verbatim. For the verified, typed
1053    /// form see [`Self::verified_receipt`].
1054    pub fn receipt(&self) -> Option<&serde_json::Value> {
1055        self.inner.registry_receipt.as_ref()
1056    }
1057
1058    /// Raw lineage-head receipt value as served on the wire
1059    /// (RFC-ACDP-0011), preserved verbatim. For the verified, typed
1060    /// form see [`Self::verified_head_receipt`].
1061    pub fn lineage_head_receipt(&self) -> Option<&serde_json::Value> {
1062        self.inner.lineage_head_receipt.as_ref()
1063    }
1064
1065    /// Verify the registry receipt, when one is present
1066    /// (RFC-ACDP-0010).
1067    ///
1068    /// Standalone variant for contexts obtained via the report paths;
1069    /// `fetch_with_policy` already does this under
1070    /// [`ReceiptPolicy::VerifyIfPresent`]/`Require`. The serving
1071    /// authority is taken from the context's own `ctx_id` — this method
1072    /// performs no requested-id binding of its own (it has no requested
1073    /// id to compare against; it only ever sees `self.inner.body.ctx_id`),
1074    /// so deriving the serving authority this way is sound only for a
1075    /// `VerifiedContext` obtained through a pipeline that already bound
1076    /// the served `ctx_id` to the one requested. Every construction path
1077    /// does: `fetch_with_policy` and `CrossRegistryResolver::resolve`
1078    /// check it directly; `fetch_current_with_policy` does too,
1079    /// tautologically, since `/current` has no requested id to diverge
1080    /// from; `fetch_report`/`fetch_report_with_fetcher` check it and
1081    /// return `ContextIdMismatch` on failure; and `fetch_report_diagnose`
1082    /// folds it into its `all_top_level_pass` gate, so it only ever
1083    /// hands back `Some(VerifiedContext)` when `ctx_id_ok` held. All of
1084    /// these implement RFC-ACDP-0006 §4.1 step 7, so the type invariant
1085    /// — every `VerifiedContext` was bound to its requested `ctx_id` —
1086    /// holds unconditionally.
1087    ///
1088    /// Returns `Ok(None)` when no receipt is present, `Ok(Some(_))`
1089    /// with the verified receipt otherwise.
1090    ///
1091    /// The receipt cross-check (RFC-ACDP-0010 §8 step 4) relies on
1092    /// `body.content_hash` being the independently recomputed value.
1093    /// That is guaranteed by the type invariant — every
1094    /// `VerifiedContext` is built only after its constructing pipeline
1095    /// verified the body hash (`Verifier::verify_body_hash` /
1096    /// `verify_body_signed`), and the fields are private so no caller
1097    /// can substitute an unverified body — so no re-derivation is
1098    /// needed here.
1099    pub async fn verify_receipt(
1100        &self,
1101        resolver: &WebResolver,
1102    ) -> Result<Option<acdp_types::receipt::RegistryReceipt>, AcdpError> {
1103        let Some(value) = &self.inner.registry_receipt else {
1104            return Ok(None);
1105        };
1106        let fingerprint = acdp_crypto::fingerprint::fingerprint_for_key_id(
1107            &self.inner.body.signature.key_id,
1108            &self.inner.body.signature.algorithm,
1109            resolver,
1110        )
1111        .await?;
1112        let receipt = super::receipt::verify_receipt_value(
1113            value,
1114            &self.inner.body.ctx_id,
1115            &self.inner.body,
1116            &self.inner.body.content_hash,
1117            &fingerprint,
1118            self.inner.body.ctx_id.authority(),
1119            resolver,
1120        )
1121        .await?;
1122        Ok(Some(receipt))
1123    }
1124}
1125
1126/// Structured diagnostic outcome from [`VerifiedContext::fetch_report`].
1127///
1128/// Top-level booleans report the per-stage outcome of the verification
1129/// pipeline. Per-`DataRef` slots track outcomes for each entry in
1130/// `body.data_refs`, in declaration order:
1131///
1132/// - `data_ref_embedded[i]` — `Ok(decoded_size_bytes)` when the embedded
1133///   payload's `content_hash` matched; `Err` when it didn't (or the
1134///   embedded was malformed). Refs without an embedded payload or
1135///   without a declared `content_hash` produce `Ok(0)`.
1136/// - `data_ref_external[i]` — `None` when no external fetch was
1137///   attempted (either no `location` or no `fetcher` was provided);
1138///   `Some(Ok(bytes_len))` when the fetch + hash succeeded;
1139///   `Some(Err(_))` on any failure (SSRF rejection, hash mismatch,
1140///   timeout, …).
1141///
1142/// `AcdpError` doesn't implement `Clone`, so the report is move-only.
1143///
1144/// `#[non_exhaustive]`: this struct has already gained a field once as a
1145/// non-optional consequence of a security fix (the RFC-ACDP-0006 §4.1
1146/// context-identity binding), and it is output-only — constructed solely
1147/// inside this crate (`verified.rs`) — so downstream loses nothing by
1148/// being unable to construct it directly. Same rationale as `SsrfReason`
1149/// in `crates/acdp-safe-http/src/lib.rs` ("future spec revisions may add
1150/// ranges"): future fields stop being breaking changes for callers that
1151/// only read this report.
1152#[derive(Debug)]
1153#[non_exhaustive]
1154pub struct VerificationReport {
1155    /// `content_hash` recomputed from the body matches the declared one.
1156    pub body_hash_ok: bool,
1157    /// The producer signature verified against the resolved DID key.
1158    pub signature_ok: bool,
1159    /// `validate_body` passed (or was disabled by policy).
1160    pub schema_ok: bool,
1161    /// Per-`DataRef` embedded-hash outcome, in `body.data_refs` order.
1162    pub data_ref_embedded: Vec<Result<usize, AcdpError>>,
1163    /// Per-`DataRef` external-fetch outcome, in `body.data_refs` order.
1164    /// `None` indicates "not attempted" (no fetcher provided or no
1165    /// `location` to fetch from).
1166    pub data_ref_external: Vec<Option<Result<usize, AcdpError>>>,
1167    /// The served body's `ctx_id` equals the one requested
1168    /// (RFC-ACDP-0006 §4.1 step 7, NORMATIVE — "Bind the resolved
1169    /// identity"). `false` means the registry served a different,
1170    /// validly-signed body under the requested id (context
1171    /// substitution); see `VerifiedContext::verify_retrieved`'s doc for
1172    /// the full rationale. This flag gates whether
1173    /// [`VerifiedContext::fetch_report_diagnose`] hands back a
1174    /// `Some(VerifiedContext)` — appended last so any positional
1175    /// construction fails loudly rather than silently binding the wrong
1176    /// field.
1177    pub ctx_id_ok: bool,
1178    /// The real P3-P6 verdict from `verify_retrieved`'s authorization
1179    /// phases (receipt, revocation, signature/historical, unknown-status),
1180    /// when they ran and all passed. `None` means either "not reached"
1181    /// (a top-level probe — schema, body hash, signature, ctx_id — failed
1182    /// first, so `verify_retrieved` was never invoked) or "the phase ran
1183    /// and failed" (see [`Self::policy_phase_error`] for which one).
1184    pub key_status: Option<KeyAuthorization>,
1185    /// Which of `verify_retrieved`'s policy-governed phases (receipt,
1186    /// revocation, signature/historical-key, unknown-status) failed, when
1187    /// one did. `None` when every phase passed, or when `verify_retrieved`
1188    /// was never invoked because a top-level probe failed first.
1189    /// `AcdpError` is not `Clone`, so — like `data_ref_embedded` above —
1190    /// this field is populated by moving the error in, and asserting on it
1191    /// requires `matches!` rather than `==`/`assert_eq!` (`AcdpError`
1192    /// derives only `Debug, Error`).
1193    pub policy_phase_error: Option<AcdpError>,
1194}
1195
1196/// Sentinel `DataRefFetcher` used as the type parameter for
1197/// `fetch_report_inner` when no fetcher is supplied. `fetch` is never
1198/// actually called — the option is matched out before that — but
1199/// providing a real impl lets the generic monomorphize cleanly without
1200/// requiring `fetch_report`'s callers to name a type.
1201struct NoFetcher;
1202
1203impl DataRefFetcher for NoFetcher {
1204    async fn fetch(
1205        &self,
1206        _location: &acdp_types::data_ref::Location,
1207    ) -> Result<Vec<u8>, AcdpError> {
1208        Err(AcdpError::NotImplemented(
1209            "NoFetcher should never be called — this is a fetch_report sentinel".into(),
1210        ))
1211    }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::{HistoricalKeyPolicy, ReceiptPolicy, VerificationPolicy};
1217
1218    /// The RFC-ACDP-0001 §9.2 named constructor preserves exact v0.1.0
1219    /// semantics: receipts inert, assertionMethod-only keys. It is
1220    /// deliberately NOT the 0.2 default (which is receipt-aware).
1221    #[test]
1222    fn strict_v0_1_0_preserves_v0_1_0_semantics() {
1223        let strict = VerificationPolicy::strict_v0_1_0();
1224        assert!(strict.validate_body_schema);
1225        assert!(strict.allow_unknown_status);
1226        assert_eq!(strict.receipts, ReceiptPolicy::Ignore);
1227        assert_eq!(strict.historical_keys, HistoricalKeyPolicy::Reject);
1228        assert!(
1229            strict.revocations.known.is_empty(),
1230            "a 0.1.0-pinned consumer is unaffected by RFC-ACDP-0014"
1231        );
1232        assert_ne!(
1233            strict,
1234            VerificationPolicy::default(),
1235            "the 0.2 default is receipt-aware; the v0.1.0 profile is not"
1236        );
1237    }
1238
1239    /// Phase 2 acceptance criterion 6 — the spine lock.
1240    ///
1241    /// `verify_retrieved` must be the SOLE reader of the four
1242    /// authorization-policy fields (`receipts`, `revocations`,
1243    /// `historical_keys`, `allow_unknown_status`) anywhere in this file.
1244    /// Every public entry point (the four `fetch*` forms plus the three
1245    /// report forms) reaches every authorization phase through that one
1246    /// function, so a future RFC phase added anywhere else — instead of
1247    /// inside `verify_retrieved` — trips this test instead of silently
1248    /// reintroducing the exact divergence this phase fixed.
1249    ///
1250    /// Implemented as a plain `str` scan (no `regex` — it is not a
1251    /// dependency of `acdp-client`) over this file's own source, read via
1252    /// `include_str!`. `verify_retrieved`'s body span is located by
1253    /// brace-counting from its own opening brace (its signature has no
1254    /// braces of its own — only angle brackets in the return type — so
1255    /// the first `{` after the `fn` keyword IS the body's opening brace),
1256    /// not by hard-coded line numbers, so the check survives any diff.
1257    /// Lines whose trimmed start is `//` (covers `///` too), and matches
1258    /// that fall inside a string literal (detected by an odd count of
1259    /// unescaped `"` before the match on its line — this file's one
1260    /// in-string occurrence, the `allow_unknown_status=false` error
1261    /// message, already lives inside `verify_retrieved` regardless), are
1262    /// excluded.
1263    ///
1264    /// The four search patterns are built by runtime concatenation
1265    /// (`policy.` + each field name) rather than written as contiguous
1266    /// `"policy.receipts"`-style literals, so this test's own source —
1267    /// included verbatim via `include_str!` — does not self-match its
1268    /// own patterns.
1269    #[test]
1270    fn verify_retrieved_is_sole_reader_of_authorization_policy_fields() {
1271        const SRC: &str = include_str!("verified.rs");
1272
1273        let policy_prefix = "policy.";
1274        let fields = [
1275            "receipts",
1276            "revocations",
1277            "historical_keys",
1278            "allow_unknown_status",
1279        ];
1280        let patterns: Vec<String> = fields
1281            .iter()
1282            .map(|f| format!("{policy_prefix}{f}"))
1283            .collect();
1284
1285        // Locate `verify_retrieved`'s body span.
1286        let fn_start = SRC
1287            .find("async fn verify_retrieved(")
1288            .expect("verify_retrieved must exist in verified.rs");
1289        let body_open = fn_start
1290            + SRC[fn_start..]
1291                .find('{')
1292                .expect("verify_retrieved must have a body");
1293        let mut depth = 0i32;
1294        let mut body_close = None;
1295        for (i, ch) in SRC[body_open..].char_indices() {
1296            match ch {
1297                '{' => depth += 1,
1298                '}' => {
1299                    depth -= 1;
1300                    if depth == 0 {
1301                        body_close = Some(body_open + i);
1302                        break;
1303                    }
1304                }
1305                _ => {}
1306            }
1307        }
1308        let body_close =
1309            body_close.expect("verify_retrieved's matching closing brace must be found");
1310        assert!(
1311            body_close > body_open,
1312            "sanity: verify_retrieved's body must be non-empty"
1313        );
1314
1315        // Scan the whole file, tracking byte offsets so each match's
1316        // position can be tested against the body span.
1317        let mut offset = 0usize;
1318        let mut checked_any = false;
1319        for line in SRC.split_inclusive('\n') {
1320            let trimmed = line.trim_start();
1321            let is_comment_line = trimmed.starts_with("//");
1322            if !is_comment_line {
1323                for pattern in &patterns {
1324                    let mut search_from = 0usize;
1325                    while let Some(rel) = line[search_from..].find(pattern.as_str()) {
1326                        let match_col = search_from + rel;
1327                        let match_start = offset + match_col;
1328                        let before = &line[..match_col];
1329                        let in_string_literal = before.matches('"').count() % 2 == 1;
1330                        if !in_string_literal {
1331                            checked_any = true;
1332                            assert!(
1333                                match_start >= body_open && match_start < body_close,
1334                                "found `{pattern}` outside verify_retrieved's body \
1335                                 (byte offset {match_start}, line: {line:?}) — every \
1336                                 authorization-policy-field read must live inside \
1337                                 verify_retrieved, the sole reader"
1338                            );
1339                        }
1340                        search_from = match_col + pattern.len();
1341                    }
1342                }
1343            }
1344            offset += line.len();
1345        }
1346        assert!(
1347            checked_any,
1348            "sanity: the scan must find at least one non-comment, non-string-literal \
1349             match for at least one pattern (verify_retrieved itself reads these \
1350             fields) — zero hits would mean the patterns are miscomputed, not that \
1351             the invariant holds"
1352        );
1353    }
1354}