Client library (`acdp_client`)
acdp_client is the playground's own async httpx + Pydantic layer that
drives the acdp SDK over HTTP. It owns transport and host-language
orchestration; it does not reimplement any protocol primitive.
Boundary. All cryptography (signing/verification), JCS canonicalization, and SSRF IP/scheme classification are delegated to the
acdpSDK (acdp-rs, imported via itsacdp-pybindings). The playground's client never goes through the SDK's RustRegistryClient, so it keeps only the parts a host application must own: thehttpxcalls, DNS resolution, the mixed-answer loop, redirect handling, and Python-friendly typed exceptions. For the primitives themselves see the SDK docs: producing, consuming, errors, security.
Modules
| Module | Responsibility |
|---|---|
client.py | AcdpClient — async client for one registry; typed HTTP errors |
models.py | Pydantic wire types + error-envelope parsing + error-code tables |
token_manager.py | Drives the registry's challenge → sign → token flow; caches + refresh telemetry |
signing.py | Thin Producer abstraction + verify helpers over the SDK |
identifiers.py | Authority + reserved-tenant validation (mirrors the server rule client-side) |
safe_http.py | Host-language orchestration for the consumer SSRF guard |
retry_after.py | RFC 9110 Retry-After parsing |
AcdpClient
An async client bound to one registry. Construct with a base URL; optionally
attach a Producer + TokenManager for transparent bearer-token injection,
proactive refresh, and a single 401 retry.
client = AcdpClient(
base_url,
*,
bearer_token=None,
run_id=None,
timeout=30.0,
producer=None,
token_manager=None,
tenant_id=None,
tenant_header_mode="fallback",
)Methods
These wrap the registry's HTTP surface — see the registry's HTTP-API.md for the endpoint contracts they call.
| Method | Maps to | Notes |
|---|---|---|
publish(request_json, idempotency_key=None) | POST /contexts | Forwards Idempotency-Key verbatim → PublishResponse |
retrieve(ctx_id) | GET /contexts/{id} | → FullContext |
retrieve_raw(ctx_id) | GET /contexts/{id} | Unparsed dict (preserves registry-assigned fields) |
retrieve_body(ctx_id) | GET /contexts/{id}/body | → Body |
search(...) | GET /contexts/search | Filters: q, context_type, domain, agent_id, tags, derived_from, visibility, limit, cursor → SearchResponse; raises CursorError |
search_all(...) | paginated search | Async-yields every SearchHit; continues through empty-but-cursored pages |
lineage(lineage_id) | GET /lineages/{id} | → list[FullContext] |
current(lineage_id) | GET /lineages/{id}/current | → newest FullContext |
resolve(ctx_id, authority_map) | cross-registry | Routes retrieval to the right registry by authority |
healthz() | GET /healthz | → bool |
fetch_data_ref(data_ref, policy) | SSRF-guarded fetch | Delegates to safe_http; verifies content_hash |
What the client adds on top of the registry
- Bearer precedence: static
bearer_token>token_manager> none. - Retry: only 401 triggers a refresh-and-retry (stale token). A 403 is terminal.
- Tenant header:
tenant_header_mode="fallback"suppressesX-Tenant-Idwhen an authenticated JWT carries the authoritativetenantclaim, so the header can never contradict the claim. It is only a fallback for unbound producer-signed publishes. The full tenancy rules are the registry's — see MULTI-TENANCY.md. - Cursor pagination that walks the whole sequence, continuing through an empty-but-cursored page (discovery semantics defined in RFC-ACDP-0005).
Typed errors
The playground parses the registry's error envelope into Python exceptions for
ergonomic handling. The envelope format and the error codes are defined by the
protocol/SDK, not here — see
acdp-rs errors.md.
All subclass AcdpHTTPError, which exposes the parsed .code, .message,
.details, .reason:
| Exception | Trigger |
|---|---|
AcdpHTTPError | Any non-2xx registry response |
SupersededError | Rejected supersession; .reason carries the registry subtype |
NotAuthorizedError | 403 — authenticated but not permitted (terminal) |
PayloadTooLargeError | 413 — oversized body, even from outer middleware |
CursorError | 400 with a cursor error code |
models.py also re-exports the code tables (ERROR_CODES,
SIGNATURE_ERROR_CODES) and parse_error_envelope(payload) so scenarios can
branch on a machine code. These mirror the registry's emitted codes; they are
not an independent source of truth.
TokenManager
Drives the registry's challenge → sign → token flow and caches the result
per (agent, registry) with single-flight refresh. The flow itself (challenge
issuance, signature verification, JWT minting) is the registry's / control
plane's — see
registry AUTHENTICATION.md
and control-plane AUTH.md.
tm = TokenManager(leeway_seconds=30, timeout=15.0)
cached = await tm.token_for(producer, registry_base_url) # CachedToken
tm.invalidate(producer, registry_base_url) # force refresh
await tm.revoke(producer, registry_base_url) # RFC 7009What the playground adds:
- Proactive refresh before expiry (configurable leeway) and one reactive 401 retry.
- Cooperative throttling — honors a
429/503+Retry-Afterwith one capped retry. - Refresh-reason telemetry — every mint logs
refresh_reason(first_use/proactive_refresh/reactive_401),algorithm,ttl_seconds,elapsed_ms.CachedToken.audpeeks the JWTaudclaim for diagnostics only — verification belongs to the issuer.
Exceptions: TokenError (base), ChallengeError, TokenIssueError,
TokenAuthError (401 even after refresh — a real authz problem).
signing.py — Producer abstraction
A thin duck-typed union over the SDK's signers so scenarios don't branch on
algorithm. Producer is acdp.AcdpProducer (Ed25519) or
acdp.AcdpP256Producer (P-256); both expose agent_did, key_id,
sign_challenge(), build_publish_request(), build_supersede_request().
| Helper | Returns |
|---|---|
producer_algorithm(producer) | "ed25519" or "ecdsa-p256" |
is_p256(producer) | bool |
public_key_material(producer) | The public key in the algorithm's encoding |
verify_signature(...) | Delegates to the SDK verifier |
The actual signing/verification math lives in the SDK — see
acdp-rs producing.md
and security.md.
These helpers only pick the right SDK call and normalize the wire encoding.
identifiers.py
Mirrors server-side validation client-side so a caller fails fast:
is_valid_authority(host)/validate_origin_registry(value)— enforce a bare DNS hostname (the context-body rules in RFC-ACDP-0002).RESERVED_TENANT = "default"+reject_reserved_tenant(t)— the reserved tenant can never be asserted (the registry returns 400, the CP 403); this mirrors that rule. See scenario S20 and the registry's MULTI-TENANCY.md.
safe_http.py — consumer SSRF guard (orchestration only)
Screens any data_refs[].location fetch before the consumer pulls it. The
per-address/URL classification — which IP ranges and schemes are forbidden — is
the SDK's AcdpSsrfPolicy, defined by
RFC-ACDP-0008 (security)
and documented in
acdp-rs security.md.
This module owns only the host-language pieces that the SDK can't:
- DNS resolution and the mixed-answer loop (reject the whole answer set if any resolved IP is forbidden)
- The
httpxfetch, same-authority redirect enforcement, and size/timeout caps content_hashverification after fetch (DataRefHashMismatchon mismatch)
SsrfPolicy (the Python wrapper) carries the knobs — allow_loopback,
max_redirects, max_bytes, connect_timeout, total_timeout — and exposes
production() / allow_test_loopback(). SsrfError.reason surfaces the SDK's
stable rejection token (loopback, private, imds, non_https,
cross_authority, …) plus host-language reasons (dns_failure,
cross_authority_redirect, response_too_large, …).
A Resolver callable can be injected for tests — this is how S16 runs fully
offline.
Wire types (models.py)
Pydantic types over the registry's JSON, all extra="allow" for forward
compatibility: Body, FullContext, PublishResponse, SearchHit,
SearchResponse, Signature, RegistryState, WebhookEvent, StepEvent.
These track the context-body
and publish
shapes — they are a convenience mirror, not the normative schema (the JSON
Schemas live in the spec repo).