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 acdp SDK (acdp-rs, imported via its acdp-py bindings). The playground's client never goes through the SDK's Rust RegistryClient, so it keeps only the parts a host application must own: the httpx calls, 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

ModuleResponsibility
client.pyAcdpClient — async client for one registry; typed HTTP errors
models.pyPydantic wire types + error-envelope parsing + error-code tables
token_manager.pyDrives the registry's challenge → sign → token flow; caches + refresh telemetry
signing.pyThin Producer abstraction + verify helpers over the SDK
identifiers.pyAuthority + reserved-tenant validation (mirrors the server rule client-side)
safe_http.pyHost-language orchestration for the consumer SSRF guard
retry_after.pyRFC 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.

MethodMaps toNotes
publish(request_json, idempotency_key=None)POST /contextsForwards 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}/bodyBody
search(...)GET /contexts/searchFilters: q, context_type, domain, agent_id, tags, derived_from, visibility, limit, cursorSearchResponse; raises CursorError
search_all(...)paginated searchAsync-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-registryRoutes retrieval to the right registry by authority
healthz()GET /healthz→ bool
fetch_data_ref(data_ref, policy)SSRF-guarded fetchDelegates 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" suppresses X-Tenant-Id when an authenticated JWT carries the authoritative tenant claim, 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:

ExceptionTrigger
AcdpHTTPErrorAny non-2xx registry response
SupersededErrorRejected supersession; .reason carries the registry subtype
NotAuthorizedError403 — authenticated but not permitted (terminal)
PayloadTooLargeError413 — oversized body, even from outer middleware
CursorError400 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 7009

What the playground adds:

  • Proactive refresh before expiry (configurable leeway) and one reactive 401 retry.
  • Cooperative throttling — honors a 429/503 + Retry-After with one capped retry.
  • Refresh-reason telemetry — every mint logs refresh_reason (first_use / proactive_refresh / reactive_401), algorithm, ttl_seconds, elapsed_ms. CachedToken.aud peeks the JWT aud claim for diagnostics only — verification belongs to the issuer.

Exceptions: TokenError (base), ChallengeError, TokenIssueError, TokenAuthError (401 even after refresh — a real authz problem).

signing.pyProducer 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().

HelperReturns
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 httpx fetch, same-authority redirect enforcement, and size/timeout caps
  • content_hash verification after fetch (DataRefHashMismatch on 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).