acdp 0.2.0

Rust client library for the Agent Context Distribution Protocol (ACDP v0.1.0)
Documentation
# 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("acdp://registry.example.com/…".into());

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 |
|---|---|---|
| 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 ||

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)`.

### Diagnostics: fetch_report

When you need to know *which* stage failed rather than just that it did, use
`fetch_report`. It runs the same pipeline but returns a structured
`VerificationReport` alongside the context:

```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` passed (or was disabled by policy). |
| `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. |

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

## 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 seven-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).

## 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).