acdp 0.14.2

Rust client library for the Agent Context Distribution Protocol (ACDP v0.1.0 + v0.2.0 Trust & Hardening)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
# Consuming & Verifying Contexts

This page covers the consumer side: fetching contexts, verifying them
end-to-end, following `acdp://` references, and fetching data refs. Everything
here requires the **`client`** feature (the default).

This crate implements the **`acdp-consumer`** profile. The verification
algorithm is specified in
[RFC-ACDP-0001 §5.11](https://github.com/agentcontextdistributionprotocol/agentcontextdistributionprotocol/blob/main/rfcs/RFC-ACDP-0001-core.md);
retrieval semantics in
[RFC-ACDP-0004](https://github.com/agentcontextdistributionprotocol/agentcontextdistributionprotocol/blob/main/rfcs/RFC-ACDP-0004-retrieval.md);
cross-registry resolution in
[RFC-ACDP-0006](https://github.com/agentcontextdistributionprotocol/agentcontextdistributionprotocol/blob/main/rfcs/RFC-ACDP-0006-cross-registry.md).

## Two layers: transport and verification

The crate separates *getting bytes* from *trusting them*:

- **`RegistryClient`** — the HTTP driver. Talks to one registry. Returns wire
  objects (`FullContext`, `CapabilitiesDocument`, `SearchResponse`) **without**
  verifying signatures.
- **`VerifiedContext`** — the trust layer. Wraps a fetch with the full
  verification pipeline and only hands you back a context that passed.

**Use `VerifiedContext` unless you have a specific reason not to.** A raw
`RegistryClient::retrieve` gives you a structurally-parsed body, but a registry
is not a trusted party — only the producer's signature is.

## RegistryClient

```rust,no_run
# #[cfg(feature = "client")]
# async fn run() -> Result<(), acdp::AcdpError> {
use acdp::client::RegistryClient;

let client = RegistryClient::new("https://registry.example.com")?;   // HTTPS-only
# Ok(()) }
```

`new` applies the [security defaults](security.md) automatically: HTTPS-only,
IP-literal rejection, DNS-time SSRF filtering, 1 MB body cap, 3-redirect
same-authority limit, 5 s connect / 30 s total timeouts.

| Method | Endpoint | Returns |
|---|---|---|
| `capabilities()` | `GET /.well-known/acdp.json` | `CapabilitiesDocument` |
| `retrieve(ctx_id)` | `GET /contexts/{ctx_id}` | `FullContext` (body + registry_state) |
| `retrieve_body(ctx_id)` | `GET /contexts/{ctx_id}/body` | body only |
| `lineage(lineage_id)` | `GET /lineages/{lineage_id}` | `Vec<FullContext>` (all versions) |
| `current(lineage_id)` | `GET /lineages/{lineage_id}/current` | latest active version |
| `search(&params)` | `GET /contexts/search` | `SearchResponse` |
| `publish(&req)` | `POST /contexts` | `PublishResponse` |

Conditional and idempotent variants exist too: `retrieve_with_metadata`,
`retrieve_if_none_match` (ETag), `capabilities_with_ttl`, `publish_idempotent`,
and `publish_with_retry`. The client is `Clone` (cheap — an inner `Arc`).

### Searching

Use the builder for ergonomic queries:

```rust,no_run
# #[cfg(feature = "client")]
# async fn run(client: &acdp::client::RegistryClient) -> Result<(), acdp::AcdpError> {
let results = client
    .search_builder()
    .q("revenue")
    .context_type("data_snapshot")
    .domain("finance")
    .tag("q1-2026")
    .limit(50)
    .send()
    .await?;

for m in &results.matches {
    println!("{}", m.ctx_id);
}
if let Some(cursor) = results.next_cursor {
    // pass `.cursor(cursor)` on the next page
}
# Ok(()) }
```

Search ranking is registry-defined (RFC-ACDP-0005); the crate does not re-rank.
See [Discovery in the spec](https://github.com/agentcontextdistributionprotocol/agentcontextdistributionprotocol/blob/main/rfcs/RFC-ACDP-0005-discovery.md).

## VerifiedContext — the verification pipeline

`VerifiedContext::fetch` runs the whole pipeline and returns only on full
success:

```rust,no_run
# #[cfg(feature = "client")]
# async fn run() -> Result<(), acdp::AcdpError> {
use acdp::{client::{RegistryClient, VerifiedContext}, did::WebResolver, types::CtxId};

let client   = RegistryClient::new("https://registry.example.com")?;
let resolver = WebResolver::new();
let ctx_id   = CtxId::parse("acdp://registry.example.com/a1b2c3d4-e5f6-4789-8abc-def012345678")?;

let ctx = VerifiedContext::fetch(&client, &resolver, &ctx_id).await?;
println!("{} — {:?}", ctx.body().title, ctx.registry_state().status);
# Ok(()) }
```

The stages, in order (it returns on the **first** failure):

| # | Stage | Failure error |
|---|---|---|
| 0 | **Identifier binding** — the served body's `ctx_id` must equal the one requested, per RFC-ACDP-0006 §4.1 step 7 (NORMATIVE); see RFC-ACDP-0008 §9.1 for the threat rationale | `ContextIdMismatch` |
| 1 | **Schema validation** (`validate_body`) — structural + embedded `data_ref` hashes | `SchemaViolation`, `DataRefHashMismatch` |
| 2 | **`content_hash` recompute** — `sha256(JCS(ProducerContent))` vs declared | `HashMismatch` |
| 3 | **`did:web` key resolution** via `WebResolver` | `KeyResolution`, `KeyResolutionUnreachable` |
| 4 | **Signature verification** against the resolved key (algorithm must match) | `InvalidSignature`, `UnsupportedAlgorithm` |
| 5 | **Status check** per policy | — |

Stage 0 is the client-side form of the check: §4.1 step 7 permits "an
equivalent typed error" in place of the registry-side
`cross_registry_resolution_failed` wire code, and `ContextIdMismatch` is
that typed error. It does not close RFC-ACDP-0008 §9.1 in full — a registry
that genuinely republishes the same content under a new `ctx_id` still
passes; only serve-time substitution (a different id claimed to be the one
requested) is caught.

`VerifiedContext::fetch` is specific to the Rust `client` feature; a
caller building an equivalent pipeline outside Rust (or outside
`VerifiedContext` — e.g. the Python/Node/WASM bindings, or a bare
`RegistryClient` caller who skips `VerifiedContext`) must perform this
same comparison itself. `acdp::verify::verify_ctx_id_binding` is the
standalone function this stage wraps; the bindings expose it directly as
`verify_ctx_id_binding` (Python) / `verifyCtxIdBinding` (Node, WASM) — see
[`docs/bindings.md`](bindings.md).

This is exactly what the offline `cargo run --example consumer` demonstrates,
step by step.

### Verification policy

`VerificationPolicy` tunes the pipeline. The default **is** the strict v0.1.0
profile — `VerificationPolicy::strict_v0_1_0()` is an alias for `Default`.

```rust
use acdp::client::VerificationPolicy;

let policy = VerificationPolicy::strict_v0_1_0();   // == VerificationPolicy::default()
```

| Field | Default | Effect |
|---|---|---|
| `validate_body_schema` | `true` | Run stage 1. Set `false` only in diagnostics that want to attempt signature checks on a body known to fail structural checks. |
| `allow_unknown_status` | `true` | Accept `Status::Other` and degrade to active (RFC-ACDP-0004 §4.1). `false` rejects unknown statuses. |
| `verify_registry_receipt` | `false` | Reserved for v0.1+ (RFC-ACDP-0009 §2.7); no-op today. |

> There is no "relaxed `did:web`" or "skip-hash" mode in v0.1.0. The strict
> profile is the only one the `acdp-consumer` conformance suite covers. To
> apply a custom policy use `fetch_with_policy(&client, &resolver, &ctx_id, &policy)`.

### Revocation auto-discovery

By default `VerificationPolicy::revocations.known` is caller-supplied: you
look up revocations yourself and hand the list to the policy, and
verification enforces them but issues no network calls of its own for
this phase. `RevocationPolicy::with_discovery` opts a policy into having
verification look revocations up itself (RFC-ACDP-0014 §8), in addition
to (not instead of) whatever `known` already carries:

```rust,no_run
# #[cfg(feature = "client")]
# fn build() -> acdp::client::VerificationPolicy {
use acdp::client::{RevocationDiscovery, RevocationPolicy, VerificationPolicy};

let policy = VerificationPolicy {
    revocations: RevocationPolicy::new(vec![])
        .with_discovery(RevocationDiscovery::producer_signed_only()),
    ..VerificationPolicy::strict_v0_1_0()
};
# policy
# }
```

`RevocationDiscovery` has no `Default` — construct it via
`producer_signed_only()` or `all_trust_classes()`. The two searches are
distinct trust classes: producer-signed revocations are always searched
once discovery is on, while registry-attested revocations (the §6
"producer lost every key" fallback) are searched only when
`include_registry_attested` is `true` (`all_trust_classes()`), because
that search unconditionally fetches the registry's capabilities
document and roughly doubles worst-case discovery cost — a
cost/availability default, not a claim that registry-attested
revocations matter less.

**`on_failure`: `FailClosed` vs `ProceedWithKnown`.** When discovery
itself fails — a transport error, the search-safety-cap error
`AcdpError::SearchTruncated`, or (issue #258) the budget error
`AcdpError::RevocationDiscoveryBudgetExceeded` covered below —
`DiscoveryFailurePolicy::FailClosed` (the default) fails verification.
`ProceedWithKnown` instead proceeds using `known` alone and records the
failure, retrievable via both
`VerifiedContext::revocation_discovery_failure()` and
`VerificationReport::revocation_discovery`. Choose `ProceedWithKnown`
with open eyes: a transport error (e.g. a 503) is an ordinary
availability blip, but `SearchTruncated` and
`RevocationDiscoveryBudgetExceeded` are **not** equivalent to it, even
though all three take this same path. `SearchTruncated` means "this
producer has more revocations than we will page through" — a hostile
producer or registry can pad the search result set specifically to
exhaust the page cap, hiding a real revocation from discovery — an
attacker-inducible security downgrade. `RevocationDiscoveryBudgetExceeded`
is attacker-inducible the same way: a hostile registry that learns a
caller's `max_requests`/`max_bytes` budget can pad harmless-looking
traffic specifically to exhaust it before a real revocation is found.
`ProceedWithKnown` waives all three, not just transient unavailability.

**`total_timeout` requires a Tokio time driver.** Discovery wraps both
searches in a single `tokio::time::timeout(discover.total_timeout, ..)`,
which requires the executing runtime to have its time driver enabled
(`enable_time`, on by default under `#[tokio::main]` / `#[tokio::test]`,
but not under a hand-built `Builder::new_current_thread()` runtime
unless `.enable_time()` / `.enable_all()` is called). Calling any
`_with_policy` or `fetch_report*` entry point with `discover: Some(..)`
from a runtime without the time driver **panics**, it does not return
`Err`.

**Bounding request count and bytes (issue #258).** `total_timeout` bounds
wall clock only — a hostile-but-fast registry can still drive a large
number of requests and a large volume of parsed bytes well inside the
timeout. `RevocationDiscovery::max_requests` (an `Option<NonZeroUsize>`)
and `RevocationDiscovery::max_bytes` (an `Option<u64>`) close that gap:

```rust,no_run
# #[cfg(feature = "client")]
# fn build_budgeted_discovery() -> acdp::client::RevocationDiscovery {
use acdp::client::RevocationDiscovery;
use std::num::NonZeroUsize;

let mut discovery = RevocationDiscovery::producer_signed_only();
discovery.max_requests = Some(NonZeroUsize::new(200).unwrap());
discovery.max_bytes = Some(10 * 1024 * 1024); // 10 MB
# discovery
# }
```

Both default to `None` (unbounded) from both named constructors, so
adding either knob is opt-in and changes nothing for an existing caller.
When set, the two knobs bound the **combined** total across both trust-
class searches — enabling `include_registry_attested` does not double
the ceiling — and are checked **before** each request is issued, so a
request that would exceed the budget is never sent. Exhaustion raises
`AcdpError::RevocationDiscoveryBudgetExceeded`, wrapped the same way a
`SearchTruncated` failure is (`AcdpError::RevocationDiscoveryFailed`,
dispatched through `on_failure` exactly like the case above) and is
**never** transient.

Two honesty caveats:

- **Registry traffic only.** The budget counts requests issued through
  `RegistryClient` (`capabilities`, `retrieve`, `lineage`, `search`).
  DID-document fetches issued via `WebResolver` during discovery are
  not counted — they are LRU-cached (1000 entries) but unbounded in
  count.
- **Successfully-parsed bodies only — for `max_bytes`.** The byte
  budget counts bytes read from a *successful* response. A non-success
  response's small error-envelope read (capped at 64 KB) is never
  charged, and the size of an in-flight request cannot be reserved in
  advance — since the two trust-class lookups run concurrently, **up
  to two** requests can push the running total past `max_bytes` before
  the *next* check observes the overrun. `max_requests` has no such
  exemption: its slot is reserved *before* the request is issued, so a
  503, a parse failure, or a `PayloadTooLarge` still consumes it.

Without a `RevocationCache` attached, every call still re-discovers from
scratch, budgeted or not — see the next section for the opt-in cache.

### Caching discovered revocations (issue #257)

`RegistryClient::with_revocation_cache` attaches a `RevocationCache` —
`RevocationCache::new()`, cheap to `Clone` (an `Arc` handle) — to a client.
Reuse the returned client across many `VerifiedContext::fetch*` calls
against the same producer(s) to amortize discovery.

**The cache is two objects, and the difference matters.** Read this before
reaching for the `freshness` knob:

- **Facts** — verified `KeyRevocation`s discovery finds. RFC-ACDP-0014
  §7:114 licenses caching these *indefinitely* ("the statement is
  permanent"), and they are **always** unioned into classification —
  never used to replace or subset a discovery result, and **never gated
  behind `freshness`**. A revocation is monotone (more revocations ⇒ an
  earlier effective compromise boundary ⇒ strictly more fail-closed
  verdicts), so seeding from cached facts can only *tighten* a verdict,
  never loosen one. This is what makes the fact store an **anti-rollback
  security control**, not a performance feature: a registry that serves a
  revocation on one call and hides it on the next (or simply goes offline)
  cannot make an already-warmed client forget it. Attaching a cache is
  itself the opt-in for this protection — a call made with
  `RevocationPolicy::discover: None` still seeds cached **producer-signed**
  facts (never registry-attested ones — see below), which is what extends
  anti-rollback to `VerifiedContext::fetch`/`fetch_current`, the two entry
  points that can never set `discover` at all.
- **Freshness markers** — "vantage V completed a full, untruncated
  discovery for this producer/trust-class at time T." This is a cached
  *absence*, which §7:114 does **not** license and which §8 warns about
  directly ("a malicious registry can hide a revocation … absence of
  search results is not evidence of absence"). A marker is bounded by
  `RevocationDiscovery::freshness` (a `Duration`), per vantage
  (`RegistryClient::authority()`), per trust class, and minted **only**
  when discovery completes fully and successfully — a transport error, a
  `SearchTruncated`, a budget exhaustion, or a `total_timeout` trip never
  mints one, so a transient blip can never turn into a silent
  window-long downgrade. A candidate that fails RFC-ACDP-0014 §5
  verification (bad signature, wrong scope, self-signed) is simply
  dropped from the result and does **not** prevent a marker from being
  minted — "completes fully and successfully" describes the *lookup*
  reaching its natural end within its page/lineage/budget caps, not every
  candidate it examined turning out to be valid. This grants no new
  power to a hostile vantage: the marker is per-vantage already, and that
  vantage already controls what it serves.

**`freshness` defaults to `Duration::ZERO` (off) from both
`producer_signed_only()` and `all_trust_classes()`.** Attaching a cache at
the default changes nothing observable except anti-rollback — **the
default cache saves zero requests.** Set `freshness` above zero (a
recommended ceiling: 3600 s, matching `WebResolver`'s own DID-document
cache TTL cap) to additionally let a fresh marker skip a repeat lookup
entirely:

```rust,no_run
# #[cfg(feature = "client")]
# fn build_cached_client(client: &acdp::client::RegistryClient) -> acdp::client::RegistryClient {
use acdp::client::RevocationCache;

let cache = RevocationCache::new();
client.with_revocation_cache(cache)
# }
```

```rust,no_run
# #[cfg(feature = "client")]
# fn build_discovery_with_freshness() -> acdp::client::RevocationDiscovery {
use acdp::client::RevocationDiscovery;
use std::time::Duration;

let mut discovery = RevocationDiscovery::producer_signed_only();
discovery.freshness = Duration::from_secs(300); // opt in to skipping repeat lookups
# discovery
# }
```

A caller sharing one cache across producers or across a whole
`CrossRegistryResolver` walk shares that cache's exposure too — but two
independent filters bound it:

- **Trust class.** A registry-attested fact is read back filtered to the
  classes the CURRENT call's discovery configuration opted into
  (`producer_signed_only()` never applies a cached registry-attested
  fact, even one warmed by an earlier `all_trust_classes()` call against
  the same producer).
- **Origin (RFC-ACDP-0014 §6).** A registry-attested fact is *additionally*
  tagged with the vantage (`RegistryClient::authority()`) that minted it
  and is applied only when reading through a client talking to that SAME
  authority — never merely because its `trust_class` matches. §6 licenses
  a registry-attested claim only "for contexts served by or receipted by
  that same registry"; storing `trust_class` alone answers a different
  question ("did this caller opt into the class") than "was this claim
  made by the registry now serving this context," so both are tracked. A
  caller sharing one cache across clients for two *different* registries
  never has registry A's attestation apply to a context served by
  registry B.

A **producer-signed** fact, once verified, is unconditionally
self-contained (RFC-ACDP-0014 §5/§8) and applies regardless of which
registry served it — it is filtered by neither trust class (once opted
into `known`/discovery at all) nor origin.

Facts are deduplicated on insert and capped per producer; once the cap is
reached an entry stops accepting new facts and drops its markers,
degrading to plain pass-through rather than false completeness. The cache
itself is capacity-bounded (oldest-is-not-tracked; a plain
capacity-triggered eviction, since losing an entry can only cost a future
re-discovery, never safety).

**Where discovery is, and is not, reachable.** All five
policy-taking entry points — `fetch_with_policy`,
`fetch_current_with_policy`, `fetch_report`, `fetch_report_diagnose`,
and `fetch_report_with_fetcher` — honor `discover` through the shared
verification pipeline. `CrossRegistryResolver` (issue #260) now does
too: `CrossRegistryResolver::with_revocation_policy` injects a
`RevocationPolicy` into every node the resolver verifies, and
`CrossRegistryResolver::with_revocation_cache` shares one
`RevocationCache` across the walk. One path still structurally cannot
carry it:

- `fetch` and `fetch_current` hardcode `VerificationPolicy::default()`
  and take no policy argument at all; use the `_with_policy` forms if
  you need discovery. This is a known limitation, not an oversight —
  see the issue #248 plan's LIM-2. (LIM-1, the `CrossRegistryResolver`
  gap, was closed by issue #260.)

**`CrossRegistryResolver` and revocation discovery (issue #260).**
`with_revocation_policy` takes a `RevocationPolicy`, never a full
`VerificationPolicy` — the resolver derives `receipts` itself, per
node, from that node's own advertised capabilities (`Require` iff the
upstream claims `acdp-registry-receipts`), a capability-dependent
escalation a caller cannot express statically for a walk whose
authorities are not known in advance. `known` still travels with the
policy, so a caller can enforce a pre-discovered revocation set across
a whole walk without enabling live discovery at all.

```rust,no_run
# #[cfg(feature = "client")]
# fn build_resolver() -> acdp::client::CrossRegistryResolver {
use acdp::client::{CrossRegistryResolver, RevocationDiscovery, RevocationPolicy};

CrossRegistryResolver::new().with_revocation_policy(
    RevocationPolicy::new(vec![]).with_discovery(RevocationDiscovery::producer_signed_only()),
)
# }
```

**The cache is walk-scoped by default.** Unless
`with_revocation_cache` is called, `CrossRegistryResolver` creates a
FRESH `RevocationCache` for each `walk_derived_from` call and shares it
across every node that walk visits — so discovery for a given
`(authority, trust class)` runs at most once per walk, not once per
node, regardless of `max_nodes`, **on genuine default configuration**.
For this resolver-built cache, the effective `RevocationDiscovery::freshness`
is derived internally from `ResolverOptions::total_timeout` — a caller
using `RevocationDiscovery::producer_signed_only()`/`all_trust_classes()`
completely untouched still gets suppression, with no `freshness` knob to
set. This is safe because no cached absence outlives the call, and the
call's own duration is already bounded by `total_timeout`.

A **caller-supplied** cache (`with_revocation_cache`) does not get this
derived value: its own `freshness` governs unmodified, so
`Duration::ZERO` (the type default) still suppresses nothing there —
merely sharing a cache object is not, on its own, what skips a lookup
for that path (the same rule as the direct, non-resolver path above). A
caller who wants discovery to stay warm ACROSS separate walks opts in
explicitly via `with_revocation_cache` and picks that cache's own
`freshness` (and so its staleness exposure) themselves.

Two caveats:

- **The 30s / 30s default collision.** `RevocationDiscovery::total_timeout`
  and `ResolverOptions::total_timeout` both default to 30s, but they
  nest: a discovery-enabled walk on all defaults can have its entire
  walk budget consumed by one node's discovery. This fails closed (the
  walk simply times out), so it is safe, but surprising — set
  `discovery.total_timeout` well below `ResolverOptions::total_timeout`,
  or raise the latter, when enabling discovery here. The walk-scoped
  cache substantially mitigates this by collapsing repeat discoveries.
- **A bare `resolve()` call, outside `walk_derived_from`, is bounded
  only by the discovery timeout** — `ResolverOptions::total_timeout`
  wraps `walk_derived_from`, not `resolve` on its own — and gets the
  walk-scoped cache's benefit only if `with_revocation_cache` was
  called explicitly.

`CrossRegistryResolver` does not add its own request/byte budget on top
of this — issue #258's `RevocationDiscovery::max_requests`/`max_bytes`
already bound the per-node discovery cost; a duplicate resolver-level
knob would be redundant *per node*. Composed across a walk, though, that
per-node bound is not the whole story: `DiscoveryBudget` is enforced once
per `verify_retrieved` call, i.e. once per node the walk visits. Under
the default `DiscoveryFailurePolicy::FailClosed`, the first node to
exhaust its budget aborts the whole walk, so the aggregate cost stays
bounded by one node's budget. Under `ProceedWithKnown`, a budget
exhaustion at one node no longer stops the walk — so the effective
walk-wide ceiling becomes `max_requests` (or `max_bytes`) **times**
`ResolverOptions::max_nodes`, since every node can independently spend up
to its own budget before falling back to `known`.

### Diagnostics: fetch_report

When you need to know *which* stage failed rather than just that it did, use
`fetch_report`. It runs the same authorization pipeline as
`fetch_with_policy` — receipt (RFC-ACDP-0010), revocation (RFC-ACDP-0014
§7), signature with the historical-key fallback, and the unknown-status
check all honor the caller's `VerificationPolicy` identically — and
additionally returns a structured `VerificationReport` alongside the
context. The one deliberate difference is schema/embedded-hash handling:
`fetch_report` runs structural validation only and records each
`DataRef`'s embedded-hash outcome in the report instead of treating a
mismatch as fatal (see `data_ref_embedded` below).

```rust,no_run
# #[cfg(feature = "client")]
# async fn run(client: &acdp::client::RegistryClient, resolver: &acdp::did::WebResolver, ctx_id: &acdp::types::CtxId) -> Result<(), acdp::AcdpError> {
use acdp::client::VerificationPolicy;

let policy = VerificationPolicy::strict_v0_1_0();
let (verified, report) =
    VerifiedContext::fetch_report(client, resolver, ctx_id, &policy).await?;

assert!(report.schema_ok && report.body_hash_ok && report.signature_ok);
# Ok(()) }
```

`VerificationReport` fields:

| Field | Meaning |
|---|---|
| `schema_ok` | `validate_body_structural` passed (or was disabled by policy) — the structural half only; embedded-`DataRef` hashes are recorded separately, below. |
| `body_hash_ok` | recomputed `content_hash` matched the declared one. |
| `signature_ok` | producer signature verified against the resolved DID key. |
| `data_ref_embedded` | per-`DataRef` embedded-hash outcome, in `body.data_refs` order. |
| `data_ref_external` | per-`DataRef` external-fetch outcome; `None` = not attempted. |
| `ctx_id_ok` | the served body's `ctx_id` matched the one requested (RFC-ACDP-0006 §4.1 step 7, NORMATIVE). |
| `key_status` | the real `KeyAuthorization` verdict once the receipt/revocation/signature phases ran and passed; `None` if a top-level probe failed first (so those phases never ran) or one of them failed. |
| `policy_phase_error` | which of the receipt/revocation/signature/unknown-status phases failed, if one did; `None` when every phase passed or none ran. |
| `revocation_discovery` | `Some(Ok(DiscoveryOutcome))` / `Some(Err(AcdpError))` when `policy.revocations.discover` was `Some`, else `None`. Counts *discovered* revocations only — never `policy.revocations.known` — so it stays meaningful when discovery is off. See "Revocation auto-discovery" above. |

`fetch_report_with_fetcher` additionally fetches and verifies external
`data_ref` locations (see below).

`fetch_report` and `fetch_report_with_fetcher` return
`AcdpError::ContextIdMismatch` on a mismatch, while `fetch_report_diagnose`
— which reports rather than short-circuits — returns `Ok((None, report))`
with `ctx_id_ok == false`, i.e. it withholds the `VerifiedContext` handle.
`ctx_id_ok == false` is not the only cause of a withheld handle, though:
once the top-level probes (schema, body hash, signature, ctx_id) all pass,
`fetch_report_diagnose` also runs the receipt/revocation/signature/
unknown-status phases, and withholds the handle — recording the cause in
`policy_phase_error` — if any of those fails too, all while still
returning `Ok`.

## Fetching data references

A verified context tells you *where* its data lives; fetching that data is a
separate, SSRF-guarded step. `HttpsDataRefFetcher` applies the same network
defenses as the registry client.

```rust,no_run
# #[cfg(feature = "client")]
# async fn run(data_ref: &acdp::types::DataRef) -> Result<(), acdp::AcdpError> {
use acdp::client::{fetch_and_verify_data_ref, HttpsDataRefFetcher};

let fetcher = HttpsDataRefFetcher::default();
let bytes = fetch_and_verify_data_ref(&fetcher, data_ref).await?;
# Ok(()) }
```

If the `data_ref` carries a `content_hash`, the fetched bytes are verified
against it — a mismatch is `DataRefHashMismatch`. Fetches are size-capped
(`DEFAULT_MAX_BYTES`). `DataRefFetcher` is a trait, so you can supply your own
transport (e.g. for `s3://` or authenticated origins).

## Cross-registry resolution

`CrossRegistryResolver` walks `derived_from` provenance edges across registries,
following the eight-step algorithm in RFC-ACDP-0006 §4.1 with cycle detection,
depth/node/fan-out caps, and a wall-clock budget.

```rust,no_run
# #[cfg(feature = "client")]
# async fn run() -> Result<(), acdp::AcdpError> {
use acdp::client::CrossRegistryResolver;

let resolver = CrossRegistryResolver::new()
    .with_max_depth(5);                    // tighten the default 10

// (resolution methods walk the derived_from graph from a starting context,
//  verifying each hop's signature and registry-DID web binding)
# Ok(()) }
```

`ResolverOptions` (via `.with_options(...)`) bounds the walk:

| Option | Default | Purpose |
|---|---|---|
| `max_depth` | 10 | per-edge depth limit |
| `max_nodes` | 100 | hard ceiling on total contexts verified |
| `max_fanout` | 32 | max `derived_from` entries on any single context (reject hostile fan-out) |
| `total_timeout` | 30 s | wall-clock budget for the whole walk |
| `capabilities_ttl` | 5 min | how long to cache each foreign registry's `/.well-known/acdp.json` |

Use `.with_allowlist([...])` to restrict which authorities the resolver will
contact, and `.seed_client(authority, client)` to pre-wire a configured client
(e.g. with a custom CA) for a known authority. Every URL the resolver builds is
checked against its `SsrfPolicy` — see [Security](security.md).

`.with_revocation_policy(...)` and `.with_revocation_cache(...)` (issue #260)
let the resolver carry RFC-ACDP-0014 revocation discovery into every node it
verifies — see "Caching discovered revocations" above for the full model,
including the walk-scoped cache default and the 30s/30s timeout caveat.
`seed_client` is fill-if-absent for the cache too: a seeded client with no
`RevocationCache` of its own is given the resolver's (or the current walk's);
one that already carries its own keeps it.

## Publishing from the client

The producer ([Producing contexts](producing.md)) builds the request; the
client transmits it:

```rust,no_run
# #[cfg(feature = "client")]
# async fn run(client: &acdp::client::RegistryClient, req: &acdp::PublishRequest) -> Result<(), acdp::AcdpError> {
let resp = client.publish(req).await?;
println!("assigned ctx_id: {}", resp.ctx_id);

// or, idempotent / with retry on transient failures:
// client.publish_idempotent(req, "my-idempotency-key").await?;
// client.publish_with_retry(req, "my-idempotency-key", 4).await?;  // bounded backoff
# Ok(()) }
```

`publish_with_retry` retries only **transient** errors — see
[Errors & retries](errors.md#retryability).