UAI Provider Kit: Low-Level Design (LLD)

How the Provider Kit is built — the reusable framework Providers embed to serve requests, verify tokens offline, and file receipts.

Scope. This is the how of the Provider Kit. The what (notary model, control-plane shape, lifecycle ownership) lives in the platform HLD. Where the HLD says a Provider exists, this page says how the reusable framework is built. The UAI Technical Specifications and the frozen JSON Schemas remain the normative contract; this LLD implements them and must not contradict them.

Consistency. The Provider Kit shares one repository, one set of pkg/ libraries, and one architectural style with the Seeker SDK and the control-plane services (Registry, Trust Server, Audit Ledger). Where a mechanism already exists in those LLDs — proof-of-control (Registry LLD §8.1, docs/v1/registry_lld.md), the SSRF-hardened DID resolver (Registry LLD §9.2), offline token verification and the Trust Server key ring (Trust Server LLD §7.2, §8.6, docs/v1/trust_server_lld.md), the receipt schema and hash-only intake (Audit Ledger LLD) — this document points to it and describes only what differs.

DID method. UAI uses did:webvh (decision D15, per the Audit Ledger LLD), superseding did:web, for all agents and for the UAI services’ own identities.

Status. First draft for iteration. Library and shape choices marked (proposal) are open to change.


Index

  1. Purpose and scope
  2. Responsibilities
  3. Module layout and boundaries
  4. Domain model
  5. Runtime state
  6. Core interfaces
  7. API surface
  8. Request pipeline
  1. Receipts and audit submission
  2. Identity, AgentFacts, and registration serving
  3. Key flows
  4. Concurrency model
  5. Validation
  6. Security summary
  7. Observability
  8. Configuration
  9. Error model
  10. Testing and conformance
  11. Deployment models
  12. Phase 2 hooks (deferred)
  13. Versioning
  14. Open items to confirm

1. Purpose and scope

A Provider is any service that answers a UAI interaction: verify a delegation token offline, enforce scope and tier, serve data peer-to-peer over TLS 1.3, and record a hash-only receipt in the Audit Ledger.

The Provider Kit (provider/) is a reusable Go framework built entirely on uai-core/pkg/*. A new Provider constructs provider.New(Config), registers one Handle(scope, fn) per supported scope, and mounts Router(); the framework owns the entire validate → enforce → dispatch → receipt → submit pipeline plus the well-known and registration endpoints. The adopter writes configuration and per-scope business logic — no protocol code.

This document covers the framework’s module layout, domain model, public API, request pipeline, envelope profile, receipts, identity serving, concurrency, validation, security, observability, errors, deployment models, and conformance. It does not cover the Registry, Trust Server, or Audit Ledger implementations except where the Provider calls them, nor the NAFPO Provider app (sub-epic 3B) built on top of the kit.

Phase 1 delivers the kit (sub-epic 3A) and its first adopter — the NAFPO Gateway Proxy (sub-epic 3B) — on top of a stable control plane (Epic #3). Phase 1 is read-only, Tier 1–2, single-party provider-signed receipts.

2. Responsibilities

The Provider Kit does:

  • Serve POST /uai/v1/query: parse the {context, message, proof} envelope, extract the Authorization: Bearer token, verify it offline, enforce scope and tier, then dispatch to the adopter’s Handle callback.
  • Verify the delegation JWT offline against the Trust Server’s published keys (no Trust Server call on the hot path; ZTAA, spec §8.1).
  • Enforce default-deny on unknown scope and reject any request whose tier exceeds the declared RiskTierMax.
  • Verify the request envelope proof and bind the request to transaction_id / message_id; deduplicate state-changing intents.
  • Build the response {context, message} envelope, sign its proof, and canonicalize {context, message} (proof excluded) for receipt digest alignment with the Seeker SDK.
  • Build, sign, and asynchronously submit a UAIReceipt on every completion — success and failure — carrying hashes only (spec §5.2, §12.3).
  • Serve GET /.well-known/did.json and GET /.well-known/agentfacts.json derived from one Config.
  • Self-register in the Registry via proof-of-control (challenge → sign → POST /registry/v1/agents, role: provider).
  • Expose a small, stable public API (New, Config, Handle, Router, AdminRouter, Register, Request, Response).

The Provider Kit does not:

  • Reimplement canonicalization, envelope hashing, JWT verification, DID resolution, receipts, or control-plane HTTP (all from pkg/*).
  • Call the Trust Server token-issuance API on the query hot path (offline verification only; a single rate-capped DID-document refetch on unknown kid is permitted — §8.3).
  • Issue delegation tokens or evaluate risk policy (Trust Server).
  • Store or forward payloads through UAI control-plane services; receipts carry hashes only.
  • Block or fail the seeker’s response on audit submission (submission is async with retry/backoff).
  • Verify Tier 3 DPoP proofs, co-sign receipts, or serve write/transactional scopes in Phase 1 (seams only; see §8.6, §20).

3. Module layout and boundaries

One importable library, compiler-walled internals, shared primitives imported from pkg/. Domain types and port interfaces live in internal/model/; each concern (pipeline, receipt, identity, register) is a separate package behind those interfaces.

provider/
  provider.go               public API surface (New, Handle, Router, AdminRouter, Register)
  config.go                 Config validation
  options.go                functional options (timeouts, audit retry policy, idempotency TTL)
  request.go                Request / Response / Handler types
  tokenopts/
    dpop.go                 Phase 2 no-op verification hook (seam)
  internal/
    model/                  domain types + sentinel errors
                            + port interfaces: TokenVerifier, PolicyEnforcer, EnvelopeCodec,
                              ReceiptBuilder, AuditSubmitter, Registrar, DIDPublisher
    pipeline/               /uai/v1/query validate-enforce-dispatch pipeline
    verify/                 offline token verification (uses pkg/jwtverify, pkg/did)
    policy/                 scope/tier enforcement (uses pkg/policy)
    envelope/               {context, message} build, sign/verify proof + canonicalization
                            (uses pkg/envelope, pkg/jcs, pkg/ed25519x)
    receipt/                receipt build/sign + async audit submit (uses pkg/receipt, pkg/clients/audit)
    identity/               did.json + agentfacts.json serving (uses pkg/did, pkg/agentfacts)
    register/              Registry self-registration (uses pkg/clients/registry)
    idem/                   idempotency cache (transaction_id + message_id)
cmd/provider-template/      runnable minimal Provider (native + gateway-proxy variants)
conformance/provider/       operational definition of correct Provider behaviour (spec 12.3, CI)
pkg/                        shared, byte-identical across services and kits
  jcs/                      RFC 8785 canonicalization
  envelope/                 Envelope type + CanonicalBytes()
  jwtverify/                offline JWT verify (EdDSA) + Claims — shared verify-side library
                            (Trust Server / Audit Ledger use the same pkg/jwtverify; the mint
                            side lives in pkg/jwtmint on the Trust Server)
  policy/                   scope -> tier resolution
  receipt/                  receipt.Builder + receipt.Verify()
  did/                      did:webvh resolver + verified-document cache (LRU, negative, singleflight)
                            + did.BuildDocument (web + verifiable history); cache interface injectable
  agentfacts/               agentfacts.Build + ComplianceCheck
  keys/                     crypto.Signer abstraction (in-process, KMS, HSM)
  ed25519x/                 Ed25519 sign/verify
  transport/                TLS 1.3 HTTP server + client
  schema/                   frozen JSON Schemas + loader (request/response validation)
  uaierr/                   canonical error codes
  clients/                  registry, audit REST clients (generated from OpenAPI)

Boundary rule. Nothing in provider/internal/... may reimplement logic that already lives in pkg/. Cross-service HTTP goes only through pkg/clients/*. This is a depguard rule in CI, so a violation fails the build rather than relying on review.

Dependency direction. internal/model/ defines domain types, sentinel errors, and port interfaces, and imports nothing else internal. Every other internal package depends on model/, never the reverse. The public provider package wires concrete implementations in New() and exposes the stable surface. Keeping interfaces at the boundary means unit tests use fakes (no network), and swapping a client implementation does not touch pipeline code.

Sibling: Seeker SDK. The Provider Kit and the Seeker SDK (Epic #2) share pkg/jcs, pkg/envelope, and conformance vectors (#71). Both must implement the same Phase 1 envelope profile (§8.2) so receipt digests match byte-for-byte; if the two canonicalize differently, the request/response hashes diverge and the audit trail stops being reliable evidence.

One contract, generated clients. Control-plane REST surfaces are described in each service’s openapi.yaml. From them, oapi-codegen (proposal) generates pkg/clients types the kit uses. Client and server cannot drift; CI fails if generated code is stale.

4. Domain model

The adopter supplies Config and one Handler per scope. The framework never exposes raw protocol wiring to the handler; a handler receives a verified, scope-checked Request and returns a flat Response.

Configuration (Config)

FieldRequiredNotes
DIDyesThis Provider’s did:webvh:...
IdentityyesA crypto.Signer (pkg/keys) for receipt proof, response envelope proof, and Register() proof-of-control. The kit holds only this interface, never raw key bytes, so the key can live in a KMS/HSM (pkg/ed25519x provides an in-process Ed25519 implementation for dev/tests)
TrustServerDIDyesdid:webvh whose published log/keys the Provider caches for offline token verification
RegistryURLyesRegistry base URL for registration client calls and for building htu in the proof-of-control bound object (Registry LLD §8.1)
AuditLedgerURLyesControl plane Audit Ledger base URL (receipt submission)
RiskTierMaxyesCeiling; requests above this tier are rejected; also submitted to the Registry at registration
SupportedScopesyesExact scopes this Provider answers; anything else is default-denied; must match Registry record
CapabilitiesyesCapability taxonomy ids from the controlled vocabulary (Registry LLD §9.6); advertised at registration and in AgentFacts
EndpointsyesAdvertised P2P endpoints ({uri, transport:https, auth:bearer} per Registry schema) for the Registry record
FactsRefyesURL to /.well-known/agentfacts.json; must match what the Provider serves
IndiaComplianceyes{dpdp_category, data_residency} per Registry schema
VCRefswhen RiskTierMax >= 3Required by Registry conditional rules (Registry LLD §11)
HTTPServernoTest override; default uses pkg/transport (TLS 1.3)
AuditAuthnoOptional. Receipt submission needs no bearer — the receipt’s own signature authenticates it (Audit Ledger LLD §8.1). Only set this if the Provider also reads receipts (GET /audit/v1/receipts, scope: read:receipts, aud = ledger DID)
MaxRequestBytesnoHard cap on inbound request body size (default aligned with envelope profile)

Key handling (proposal). Issue #76 sketches PrivateKeyPEM []byte // or a keys.Signer. This LLD selects the crypto.Signer form as the only supported shape so the framework never holds raw private-key material — spec §8.1 requires HSM storage for Tier 3/4 agents, and crypto.Signer is the seam that makes that possible without an API change.

Request and response types

TypeFields (summary)
RequestIntent (seeker payload), Claims *jwtverify.Claims (verified token claims), Raw *envelope.Envelope (full request envelope, for hashing)
ResponseData any (flat domain JSON the framework wraps into a response envelope)
Handlerfunc(ctx context.Context, req Request) (Response, error)
Capabilityid, version — Registry taxonomy entry
Outcomestatus (success | error | timeout), http_status, error_code — feeds the receipt; the Audit Ledger accepts exactly this enum

Critical invariant

The {context, message} portion of the response envelope the Provider signs must canonicalize identically to what the Seeker hashes when verifying the receipt, and the request_hash the Provider records must match the canonical form of the request the Seeker sent. The proof is excluded from the hash (spec §3.4), so the wire body and the hashed form never diverge. The framework builds, signs, and hashes envelopes via pkg/jcs + pkg/envelope + pkg/ed25519x; handlers never construct or sign envelopes.

5. Runtime state

The Provider Kit is near-stateless. Per Config it holds:

  • the crypto.Signer handle (not the key material),
  • a cached copy of the Trust Server’s published verification key ring (active + retained keys, default size 3 — Trust Server LLD §8.6), keyed by kid and refreshed on cache expiry (Cache-Control TTL; no rotation push — §8.3),
  • a bounded verified did:webvh document cache provided by pkg/did (post log-walk LRU, Cache-Control expiry, negative entries, singleflight — §8.3),
  • the scope → Handler registration map (immutable after New()),
  • an idempotency cache keyed by transaction_id + message_id for state-changing intents (§8.5),
  • a bounded async queue / retry state for audit submission.

During a single query it keeps in memory only the request and response {context, message, proof} envelopes, the verified Claims, and the receipt being built — never persisted, never forwarded. Receipts in the Audit Ledger store hashes only (spec §5.2).

6. Core interfaces

The pipeline depends on these ports, not on raw HTTP clients.

type TokenVerifier interface {
    // Verify checks the JWT offline against the Trust Server keys and aud=providerDID.
    Verify(ctx context.Context, rawJWT, providerDID string) (*jwtverify.Claims, error)
}

type PolicyEnforcer interface {
    // Resolve maps a scope to its tier and asserts scope support + tier ceiling.
    Resolve(scope string, supported []string, tierMax int) (tier int, err error)
}

type EnvelopeCodec interface {
    // ParseRequest parses {context, message, proof} and verifies the seeker proof.
    ParseRequest(body []byte) (*envelope.Envelope, error)
    // BuildResponse wraps the handler Data into {context, message} and signs proof.
    BuildResponse(reqCtx envelope.Context, data any) (*envelope.Envelope, error)
}

type ReceiptBuilder interface {
    Build(req, resp *envelope.Envelope, claims *jwtverify.Claims, outcome Outcome) (*receipt.Receipt, error)
}

type AuditSubmitter interface {
    // Submit is asynchronous; it never blocks or fails the response path.
    Submit(ctx context.Context, r *receipt.Receipt)
}

type Registrar interface {
    Register(ctx context.Context) error
}

type DIDPublisher interface {
    Document() ([]byte, error)   // did.json
    AgentFacts() ([]byte, error) // agentfacts.json
}

Phase 1 implementations delegate to pkg/jwtverify, pkg/policy, pkg/envelope, pkg/receipt, pkg/clients/audit, pkg/clients/registry, pkg/did, and pkg/agentfacts.

7. API surface

Public Go API — a small, stable product surface; the integration guide and template are written against exactly this.

MethodPurpose
New(Config) (*Provider, error)Construct and validate; fetch/cache Trust Server keys
(*Provider) Handle(scope string, h Handler)Register one handler per supported scope
(*Provider) Router() http.HandlerPublic P2P surface: /uai/v1/query, /.well-known/did.json, /.well-known/agentfacts.json
(*Provider) AdminRouter() http.HandlerInternal/admin surface only: /admin/self-registernot mounted on Router(); bind to a separate internal listener (localhost / mTLS / network policy)
(*Provider) Register(ctx) errorRegistry self-registration (proof-of-control)

Minimal adopter code (native):

p, err := provider.New(cfg)
p.Handle("read:fpo_data", func(ctx context.Context, req provider.Request) (provider.Response, error) {
    q := req.Intent // flat domain payload, already token- and scope-checked
    data := lookupFPO(q)
    return provider.Response{Data: data}, nil
})
_ = p.Register(ctx)
log.Fatal(http.ListenAndServeTLS(addr, cert, key, p.Router()))

The only adopter-written code is Config plus the handler body. Everything else — token verify, scope/tier enforce, receipt build/sign, audit submit, did.json/agentfacts.json serving, self-register — is owned by the framework.

8. Request pipeline

8.1 Pipeline order

On each POST /uai/v1/query, ordered so all local, cached checks fail fast before any outbound network work. The only step that can touch the network is the Seeker proof verification (step 6), because resolving the Seeker’s did:webvh may require an outbound HTTPS fetch and log-walk; it is therefore placed after the cheap local token checks and request binding so a bad token or mismatched binding is rejected without ever paying that cost:

  1. Ingress guard: enforce TLS 1.3, cap body size (MaxRequestBytes), reject malformed content type (§8.7).
  2. Parse & schema-validate envelope (local): decode {context, message, proof} and validate against pkg/schema. Do not verify the proof yet.
  3. Extract token: read Authorization: Bearer <jwt>; missing/empty → 401 token_invalid.
  4. Verify token offline (local, cached keys): pkg/jwtverify.Verify(rawJWT, aud=cfg.DID) against cached Trust Server keys; check iss, sub, aud, exp/nbf/iat within clock skew, jti, scope, txn (§8.3).
  5. Bind request (local): assert token txn equals envelope context.transaction_id, token sub equals context.seeker_did, token aud equals cfg.DID / context.provider_did.
  6. Resolve Seeker did:webvh (cached) & verify request proof: resolve the Seeker’s did:webvh document via the SSRF-hardened pkg/did resolver (served from the verified-document cache when warm; §8.3) and verify the request proof over canonical {context, message}. This is the only step that may issue an outbound fetch.
  7. Enforce policy: resolve scope via pkg/policy; require scope ∈ SupportedScopes (default-deny) and tier ≤ RiskTierMax (§8.4).
  8. Seam: Tier 3 verification hook — invoked but unenforced in Phase 1 (§8.6).
  9. Idempotency: for state-changing intents, dedup on transaction_id + message_id (§8.5).
  10. Dispatch: call the registered Handle(scope, fn) with the populated Request.
  11. Build & sign response envelope: wrap Response.Data into {context, message}, sign proof.
  12. Receipt: build + sign a UAIReceipt for the outcome (success or error) and hand it to async audit submission (§9).
  13. Respond: return the response envelope to the Seeker immediately; audit submission never blocks this step.

8.2 Envelope profile

Identical to the Seeker SDK Phase 1 profile so digests match byte-for-byte.

LayerPhase 1 behaviour
On the wire (P2P HTTP body)Full {context, message, proof} envelope JSON
Request proofVerified against the Seeker’s did:webvh key over canonical {context, message}
Response proofDetached JWS (EdDSA) over canonical {context, message}; verification_method = <provider_did>#<kid>, signed via Config.Identity (crypto.Signer)
For hashing / receiptCanonicalize {context, message} via pkg/jcs; proof excluded (spec §3.4)
Audit Ledgerrequest_hash / response_hash only; no envelope stored (spec §5.2)

Canonicalization. Envelope.CanonicalBytes() returns JCS bytes of {context, message} with proof excluded (spec §3.4). Hash label in receipt: jcs:rfc8785. Response context echoes the request transaction_id, message_id (or a linked id), sets provider_did = cfg.DID, and refreshes timestamp/ttl.

8.3 Offline token verification

The Provider never calls the Trust Server on the hot path (ZTAA, spec §8.1). At New(), and thereafter when the cache entry expires, it resolves the Trust Server’s did:webvh log via the shared, SSRF-hardened pkg/did resolver (Registry LLD §9.2 — rejects private/loopback/link-local targets, disables redirects, caps response size, TLS 1.3; under D15 extended to verifiable-history log-walk for did:webvh) and caches the published EdDSA verification keys by kid.

Key-cache freshness (Trust Server LLD §8.6). The Trust Server exposes no rotation push; rotation is an admin action (POST /admin/v1/keys/rotate on its internal listener) and the Provider learns of new keys only by fetching /.well-known/did.json (or the did:webvh log equivalent under D15). Refresh is therefore pull-based:

  • What is cached. The Trust Server publishes one active signing key plus retained previous keys (default ring size 3, sized by the retirement rule: DID-document cache TTL + maximum token TTL + safety buffer — Trust Server LLD §8.6). The Provider caches the full published verification set keyed by kid, not a single active key, so a token minted just before rotation still verifies against the retained key that signed it.
  • When to refresh. On cache expiry (TTL from the Trust Server’s served Cache-Control: public, max-age=<TTL>, must-revalidate, with a sane floor/ceiling) and at New().
  • Unknown kid (rotation edge case). If a token’s header kid is not in the cached ring, the Provider refetches the Trust Server DID document once before rejecting — the ring may have just rotated and the new key may not yet be in cache. Refetches are rate-capped (a few minutes per Trust Server DID) so a flood of bogus kid values cannot hammer the endpoint (Trust Server LLD §8.6). This is the only Trust Server fetch permitted on the query hot path; it is not a per-call callback and does not contact the token-issuance API.
  • What is never used for key selection. Header-supplied key material and URLs — jku, x5u, x5c, and any embedded jwk — are ignored entirely; keys come only from the cached or refetched Trust Server DID document.

pkg/jwtverify.Verify — RFC 8725 hardening. Verification follows the JWT BCP (RFC 8725) and its in-progress successor draft:

  • Algorithm allowlist — EdDSA only, case-sensitive. Reject none, HS256, any HMAC/RSA/ECDSA algorithm, and any case variant (eddsa, EDDSA, …). The permitted alg is compared with an exact, case-sensitive match against a one-element allowlist — this closes algorithm-confusion / key-confusion attacks.
  • Required typ check. The header typ must equal the expected media type for a UAI delegation token; any other or missing typ is rejected.
  • Base64url format precheck before parsing. Each JWS segment is validated as strict base64url (correct charset, no padding) before any JSON decode, so malformed-charset tokens are rejected without feeding untrusted bytes to the parser.
  • Keys only from the cached Trust Server ring, by kid. The verifier selects the key from the cached (or once-refetched) ring using the header kid, and treats kid as an untrusted lookup hint. Unknown kid after one refetch → reject.
  • Claim validation (Trust Server LLD §7.2). Mandatory JWT claims: iss (= TrustServerDID), sub, aud (= cfg.DID), iat/nbf/exp within a bounded clock-skew window, jti, scope, txn. The header carries alg: EdDSA and kid of the form <trust_server_did>#<key>, selecting a verification method from the cached ring. tier is not a JWT claim — the Provider derives it from scope via pkg/policy using the same scope→tier mapping the Trust Server evaluated at mint time (Trust Server LLD §8.2). consent_id is not in the JWT — Tier 2+ consent is validated by the Trust Server at issuance (§8.3); the Provider trusts that validation and does not re-fetch consent in Phase 1.
  • Token hash (receipt alignment). authorization.token_hash in the receipt is base64url(sha256(compact JWT)) — the same digest the Trust Server stores in Grant.token_hash and DPoP uses as ath (Trust Server LLD §7.2); the Provider computes it from the raw bearer token, never stores the token.

Failure → 401 token_invalid (from pkg/uaierr).

Seeker did:webvh document cache (pkg/did). Verifying the Seeker’s request proof (pipeline step 6) requires the Seeker’s verification key, which lives in its did:webvh document on the web. Under did:webvh, resolving that document means fetching the full history log and walking it to check the SCID and hash chain — the slow part. The resolver applies the same SSRF hardening as Registry LLD §9.2 (plus log-walk integrity checks for did:webvh). Caching only the Trust Server keys (as above) is not enough; without a Seeker cache the Provider would redo the fetch-and-walk on every request.

Decision: cache in shared pkg/did, not Provider-local. The verified-document cache lives in pkg/did alongside the SSRF-hardened resolver — the same placement the Registry and Trust Server use for their resolver cache (Registry LLD §3: pkg/did declares a small cache interface; main or provider.New() wires a concrete backend). Rationale:

  • One implementation of post-log-walk caching (LRU, Cache-Control TTL, negative cache, singleflight) shared with the Audit Ledger and any service that resolves agent DIDs — no forked Provider-only cache logic.
  • One conformance suite for did:webvh resolution + cache behaviour in pkg/did (Seeker LLD §16, Provider §18).
  • Consistent semantics — a DID verified once is byte-identical everywhere; eviction and negative-cache rules cannot drift between Provider and Ledger.

The Provider Kit calls pkg/did.Resolve(); it does not reimplement caching in provider/internal/. Phase 1 default: an in-memory LRU backend wired at New(). Multi-instance Providers that need a shared cache across replicas can inject an external backend through the same pkg/did cache interface in Phase 2 (§20).

Cache behaviour (implemented in pkg/did):

  • In-memory LRU with bounded size and eviction (default backend).
  • Expiry from the served Cache-Control header (floored/ceilinged).
  • Short-lived negative caching (default TTL 30s, configurable) so bad or made-up DIDs are not re-resolved on every request (defends against resolution-amplification).
  • Singleflight: a burst of requests for one uncached DID collapses into a single in-flight resolution.
  • Resolver hit/miss metric hooks (§15) to observe cache effectiveness.

8.4 Scope and tier enforcement

  • Default-deny. A scope not in SupportedScopes403 scope_not_supported. There is no wildcard fallback on the Provider side (the Trust Server may issue tokens for scopes resolved from wildcards in the risk policy — Trust Server LLD §8.2 — but this Provider only serves scopes it explicitly registered).
  • Tier ceiling. pkg/policy resolves the scope’s tier using the same UAIRiskPolicy mapping the Trust Server loaded at mint time; tier > RiskTierMax403 tier_insufficient.
  • Consent-bearing tiers. Tier 2+ consent is validated by the Trust Server at mint time (Trust Server LLD §8.3); the Provider trusts the issued token and does not re-fetch consent in Phase 1. Phase 2 adds a consent revocation re-check for Tier 3+ tokens past half TTL (Trust Server LLD §8.7, Provider §20).

8.5 Idempotency

For state-changing intents the framework deduplicates on the pair (transaction_id, message_id). A repeated pair returns the previously computed response (and does not re-run the handler or emit a second receipt). Phase 1 is read-only, so the cache is a bounded, TTL’d in-memory map by default; the interface allows an external store (e.g. Redis) for multi-instance deployments.

8.6 Tier 3 DPoP seam

A pluggable verification step (tokenopts/dpop.go) sits at pipeline step 8. In Phase 1 it is a no-op that is invoked but unenforced, so the Tier 1–2 path is unchanged. In Phase 2 it validates the DPoP proof per spec §4.3 and Trust Server LLD §8.4:

  • Signature matches the key whose RFC 7638 JWK thumbprint equals the token’s cnf.jkt (Ed25519: crv, kty, x — same computation as the Trust Server minter).
  • htm / htu match the P2P request; ath equals base64url(sha256(compact JWT)) (same as authorization.token_hash).
  • jti unseen within the token TTL + skew window (Redis replay guard on the Trust Server side at mint; Provider-side replay cache in Phase 2).
  • iat within skew tolerance (30 seconds Tier 3, 10 seconds Tier 4 — Trust Server LLD §8.4).

8.7 Ingress safety

  • TLS 1.3 only on the P2P listener (pkg/transport); no 1.2 fallback (spec §8.2).
  • Body size cap. Inbound bodies are read through MaxRequestBytes (http.MaxBytesReader) to bound memory against hostile or buggy Seekers.
  • Fail-closed on unknown critical fields. After proof verification, reject a request that carries an envelope field marked critical which the framework does not recognize (400 envelope_unrecognized_critical).
  • Error hygiene. Error responses and logs never echo the request/response payload; they carry transaction_id, DIDs, scope, and the error code only.

9. Receipts and audit submission

Spec §7 (Settlement) and §12.3 require a signed receipt on every completion — success or error — containing hashes only, submitted asynchronously.

Build (pkg/receipt.Builder). After the handler returns (or errors), the framework builds the response envelope, then builds a UAIReceipt (spec §5.2) from the request + response envelopes, the verified Claims, and an Outcome:

  • participants: seeker_did, provider_did (= cfg.DID), trust_server_did (= token iss).
  • authorization: token_hash (= base64url(sha256(compact JWT)), Trust Server LLD §7.2), scope, tier (derived from scope via pkg/policy) — hashes only, never the token.
  • artifacts: request_hash / response_hash from the envelopes’ CanonicalBytes(); canonicalization = "jcs:rfc8785".
  • outcome: status (success | error | timeout), http_status, error_code.
  • signature: EdDSA over the canonical receipt body via Config.Identity; signer = <provider_did>#<kid>.

Single-party provider signing is sufficient for v1 (spec §5.3); co-signing fields stay absent.

Submit (pkg/clients/audit). The signed receipt is handed to AuditSubmitter.Submit, which posts POST /audit/v1/receipts (async) with retry + exponential backoff. No bearer token is sent — the receipt’s own EdDSA signature is the authentication, verified by the ledger against the Provider’s did:webvh key (Audit Ledger LLD §8.1). A slow or failing ledger is logged, never allowed to delay or fail the Seeker’s response. The response to the Seeker returns immediately after step 13; submission runs on a separate goroutine bounded by the retry policy.

Ledger response classification (retry policy). Each submission attempt classifies the Audit Ledger HTTP status before deciding whether to retry:

StatusMeaningRetry?
202 AcceptedReceipt appended (or already present with identical content)No — done
409 ConflictSame (transaction_id, message_id) but different receipt contentNo — log at error severity; indicates a bug or split-brain (same idempotency key, divergent hashes)
422 Unprocessable EntitySchema / signature / intake-rule violationNo — log at error severity; fix the receipt builder or canonicalization constant
429 Too Many RequestsRate-limitedYes — honour Retry-After when present, else exponential backoff + jitter
503 Service UnavailableLedger temporarily down or overloadedYes — honour Retry-After when present, else exponential backoff + jitter
Other 5xx / transport errorsTransient failureYes — bounded retry with backoff
Other 4xxPermanent client errorNo — log and drop

Retry budgets are bounded (max attempts + total deadline per receipt) and respect context.Context cancellation. A 429 or 503 with Retry-After uses that hint instead of the computed backoff (same pattern as the Seeker SDK control-plane clients, Seeker LLD §8.4).

Ledger intake contract (align to accept). So the ledger appends rather than rejecting (422/409), the framework guarantees: signature.signer equals participants.provider_did (optionally with a #kid fragment); artifacts.canonicalization is the frozen v1 constant (§22 open item); outcome.status ∈ {success, error, timeout}; and the (transaction_id, message_id) pair is stable across retries. Because the ledger deduplicates on that same pair, a retried submission is idempotent there — matching the Provider’s own idempotency key (§8.5). Late submission after backoff is legitimate; the ledger accepts arbitrarily old issued_at.

Self-verification. In tests and conformance, pkg/receipt.Verify re-derives the hashes from the same envelopes and checks the signature → Verified, guaranteeing byte-alignment with the Seeker.

10. Identity, AgentFacts, and registration serving

All three derive from one Config, so a Provider’s identity, discovery metadata, and Registry record cannot drift.

  • GET /.well-known/did.json (and the did:webvh history log did.jsonl) — built from pkg/did.BuildDocument(cfg.DID, signer, services); under did:webvh the served artifact is the hash-chained, signed DID log (with SCID) that pkg/did resolves and verifies.
  • GET /.well-known/agentfacts.json — built from pkg/agentfacts.Build(cfg); passes agentfacts.ComplianceCheck (spec §2.3); carries uai_registration, delegation_scopes, risk_tier_max, india_compliance, consent_requirements, vc_refs. The URL of this document is Config.FactsRef and must match the Registry submission.
  • POST /admin/self-register — exposed only on AdminRouter(), not on the public Router(). Mirrors the Registry’s two-listener split (Registry LLD §3): the adopter binds AdminRouter() to a separate internal listener (localhost, mTLS, or network-policy gated) and triggers Register() on demand.

10.1 Registry self-registration (Register())

Uses the Registry client (pkg/clients/registry) with the same proof-of-control mechanism as the Registry LLD §8.1 and the Seeker SDK’s optional registration (Seeker LLD §9.2). The body is a pure RegistryAgentRecord; the proof rides in headers:

  • X-UAI-Nonce: <nonce from challenge>
  • X-UAI-Signature: <base64url Ed25519 signature, no padding>

Flow (Registry LLD §9.1):

  1. GET /registry/v1/challenge?did=<provider_did> → one-time nonce (5-min TTL, single-use).
  2. Build the registration body from Config: did, role: provider, endpoints, capabilities, supported_scopes, risk_tier_max, facts_ref, india_compliance, and vc_refs when RiskTierMax >= 3.
  3. Sign the RFC 8785 (JCS) canonical form of the bound object via Config.Identity:
bound = {
  "did":         "<provider DID>",                         // body's did
  "htm":         "POST",                                   // uppercase
  "htu":         "<RegistryURL>/registry/v1/agents",  // query dropped
  "kid":         "<verification-method fragment>",         // e.g. key-1
  "nonce":       "<nonce from challenge>",
  "body_sha256": "<base64url(sha256(JCS(body))), no padding>"
}
signature = Ed25519_sign( JCS(bound) )
  1. POST /registry/v1/agents with the body and proof headers. New DID → 201 Created; re-registration of an existing DID → 200 Updated (Registry LLD §9.1). Each attempt consumes the nonce; a failed attempt burns it and requires a fresh challenge before retry (Seeker LLD §8.4 pattern).

Normalization (MUST). Identical to Registry LLD §8.1: htm uppercase; htu built from RegistryURL (the same base URL used for the HTTP call); DID raw and unencoded in path segments; all bound-object fields are strings; the §8.1 reference test vector is asserted as a cross-language unit test so client and server cannot drift.

Data quality. Capability ids must exist in the controlled taxonomy (Registry LLD §9.6); deprecated ids are rejected after the grace window. AgentFacts scopes/tier, facts_ref, and the Registry submission all derive from the same Config (no drift).

Kill-switch awareness. The Provider does not call the Registry on the query hot path, but a suspended Provider stops receiving new delegation tokens immediately — the Trust Server’s one internal hop reads status from GET /registry/v1/agents/{did} and denies non-active Providers (Registry LLD §9.5, Trust Server LLD §8.5).

11. Key flows

11.1 Query() service path

sequenceDiagram
    autonumber
    participant Seeker as Seeker SDK
    participant Prv as Provider Kit
    participant App as Handler
    participant TSDID as Trust Server did.json
    participant Aud as Audit Ledger

    Note over Prv,TSDID: key ring cached at New()/expiry; one refetch on unknown kid (§8.3)
    Seeker->>Prv: POST /uai/v1/query<br/>{context, message, proof} · Bearer JWT
    Prv->>Prv: TLS 1.3 · cap body · parse + schema-validate (local, no proof yet)
    Prv->>Prv: jwtverify.Verify(jwt, aud=DID) offline (local, cached keys)
    Prv->>Prv: bind txn/sub/aud (local)
    Prv->>Prv: resolve seeker did:webvh (cached) · verify request proof
    Prv->>Prv: enforce scope+tier (default-deny) · Tier 3 hook (no-op) · idempotency
    Prv->>App: Handle(scope, Request{Intent, Claims, Raw})
    App-->>Prv: Response{Data} or error
    Prv->>Prv: build + sign response {context, message, proof}
    Prv->>Prv: receipt.Build (success or error) + sign
    Prv-->>Seeker: {context, message, proof} response
    Prv--)Aud: POST /audit/v1/receipts (async, 202, retry/backoff)

The receipt is built for both a successful return and a handler error; audit submission is fire-and-forget with retry and never sits on the response path.

11.2 Register() path

sequenceDiagram
    autonumber
    participant Prv as Provider Kit
    participant Reg as Registry
    participant DR as pkg/did Resolver

    Prv->>Reg: GET /registry/v1/challenge?did=provider_did
    Reg-->>Prv: nonce (5-min TTL, single-use)
    Prv->>Prv: build RegistryAgentRecord from Config
    Prv->>Prv: sign JCS bound object (did, htm, htu, kid, nonce, body_sha256)
    Prv->>Reg: POST /registry/v1/agents (body + X-UAI-Nonce + X-UAI-Signature)
    Note over Reg,DR: Registry-side (§9.1): consume nonce, resolve DID, verify signature, validate VCs
    Reg-->>Prv: 201 Created / 200 Updated (record + ETag)

Registration is infrequent (startup or admin trigger); it is not on the P2P query hot path. Re-registration preserves registered_at on the Registry side (Registry LLD §18 open item #5).

12. Concurrency model

The framework is I/O-bound and serves many Seekers at once.

  • Concurrency-safe by construction. A provider.Provider (from New()) is safe for concurrent requests. The scope→handler map and cached keys are immutable/read-mostly after construction; per-request state (Request, envelopes, Claims, receipt) lives on the request goroutine’s stack. Handlers must themselves be safe for concurrent calls.
  • Async audit submission. One bounded worker path per submission with retry/backoff; failures are logged and never block the response. A backlog bound prevents unbounded goroutine/memory growth under ledger outage.
  • Context cancellation. Every outbound call (audit, registry, did resolution) and the handler dispatch respect context.Context and request deadlines.
  • Idempotency cache. Concurrency-safe; a duplicate (transaction_id, message_id) in flight coalesces onto the first result.

13. Validation

Three layers, in order:

  1. Config (structural). New() validates DID format, Identity, control-plane URLs, RiskTierMax, non-empty SupportedScopes/Capabilities/Endpoints. Failure before any listener starts.
  2. Request shaping (pipeline). Envelope schema (pkg/schema), seeker proof, token binding, scope support, tier ceiling — each maps to a canonical pkg/uaierr code.
  3. External (runtime). Offline token verification against cached Trust Server keys; receipt self-consistency; typed control-plane clients validate control-plane responses.

14. Security summary

  • P2P auth (Phase 1): Authorization: Bearer verified offline on every call (RFC 6750, ZTAA); no Trust Server token-issuance callback on the hot path. The only permitted Trust Server fetch on the query path is a single, rate-capped DID-document refetch when the token’s kid is not yet in the cached ring (rotation edge case; Trust Server LLD §8.6).
  • Transport: TLS 1.3 only (pkg/transport); body size capped (MaxRequestBytes).
  • Request proof: verified against the Seeker’s did:webvh key over canonical {context, message}; response proof signed with the Provider’s key so the Seeker can verify and receipt digests align.
  • Request binding (anti-replay/confusion): token txn must equal context.transaction_id, sub must equal context.seeker_did, aud must equal cfg.DID; exp/nbf/iat pass a bounded clock-skew window; jti supports replay detection.
  • Default-deny + tier ceiling: unknown scope → 403 scope_not_supported; tier > RiskTierMax403 tier_insufficient.
  • Key custody: signing is reached only through Config.Identity (a crypto.Signer); the framework never holds raw private-key bytes, so keys can live in a KMS/HSM (spec §8.1 requires HSM for Tier 3/4). pkg/ed25519x is the in-process dev/test signer.
  • Receipts: signed on success and failure; hashes only, never payloads (spec §5.2); async submission never blocks or fails the response. Submission carries no bearer — the receipt’s EdDSA signature is the authentication at the ledger (Audit Ledger LLD §8.1).
  • Fail-closed on unknown critical fields; error messages and logs never echo payloads; log transaction_id, DIDs, scope, tier, outcome.
  • Registration: Registry proof-of-control per Registry LLD §8.1; capability ids from controlled taxonomy (Registry LLD §9.6).
  • Idempotency: transaction_id + message_id dedup prevents double-processing state-changing intents.

15. Observability

  • Logging: structured log/slog; one summary line per request with transaction_id, seeker_did, scope, tier, latency, outcome, receipt-submission status.
  • Metrics (optional Phase 1): verify/enforce/dispatch/receipt outcome counters; DID resolver hit/miss counters (Trust Server key cache and Seeker verified-document cache, with a negative-hit series); Trust Server did.json refetch rate (spike after rotation is a broken-rotation signal — Trust Server LLD §12); provider-response and receipt-submission duration histograms (P95 provider response < 800ms, receipt submission < 200ms — spec §11).
  • Tracing (optional Phase 1): OpenTelemetry spans on parse, verify, dispatch, receipt submit.

16. Configuration

Typed Config struct; environment loading lives in cmd/provider-template/, not in the library.

Field / optionMeaning
DIDThis Provider’s DID
Identitycrypto.Signer for receipts, response proof, and Register(); KMS/HSM-capable
TrustServerDID / RegistryURL / AuditLedgerURLControl plane references; RegistryURL also builds proof htu (Registry LLD §8.1)
RiskTierMaxHighest tier this Provider will serve
SupportedScopes / Capabilities / Endpoints / FactsRef / IndiaCompliance / VCRefsRegistry record + AgentFacts fields (§10.1)
MaxRequestBytesInbound body size cap
AuditRetryPolicyBackoff/attempts/backlog bound for async submission
IdempotencyTTLDedup window for transaction_id + message_id
HTTPServer / AuditAuthTest override; optional ledger read auth (submission is signature-authenticated, no bearer)

17. Error model

One sentinel-error set in internal/model/, mapped to canonical pkg/uaierr codes; public API wraps with %w for errors.Is.

Error / codeWhen
ErrConfigInvalidMissing or malformed Config
token_invalid (401)Missing/expired/bad-signature token or failed binding
scope_not_supported (403)Scope not in SupportedScopes (default-deny)
tier_insufficient (403)Resolved tier exceeds RiskTierMax
envelope_invalid (400)Schema or request proof verification failure
envelope_unrecognized_critical (400)Unknown envelope field marked critical
request_too_large (413)Body exceeds MaxRequestBytes
dpop_required (401)Tier 3+ seam (Phase 2)
ErrAuditSubmitFailedLedger submission exhausted retries (logged, non-fatal to response)

Every terminal outcome — including the 4xx denials above — still produces a signed error receipt (spec §12.3), except failures that occur before a verifiable envelope/token exists to hash.

18. Testing and conformance

  • Unit: pipeline accept/deny paths and receipt build against fakes of §6 interfaces; no network.
  • Integration: template Provider + fixture Registry/Trust/Audit; full parse → verify → enforce → dispatch → receipt.
  • Conformance: conformance/provider/ implements the spec §12.3 checks and runs in CI against the running template Provider.
  • Shared vectors: envelope hashes from #71; request_hash/response_hash must match the Seeker byte-for-byte. Cross-service token vector: a token minted with pkg/jwtmint (Trust Server) must verify with pkg/jwtverify (Provider) byte-for-byte (Trust Server LLD §15).
  • did:webvh conformance: pkg/did carries its own resolution + verifiable-history vectors, using the official did:webvh test suite where one exists.
Test area (spec §12.3)Asserts
Offline verifyToken verified with no Trust Server callback per call
JWT alg confusionToken with algEdDSA (e.g. none, HS256, case variants) rejected
JWT wrong typToken with missing or unexpected typ rejected
JWT malformed charsetToken with non-base64url JWS segments rejected before JSON decode
JWT jku ignoredToken carrying jku/x5u/embedded jwk verified only against cached Trust Server ring by kid
Key rotation overlapToken signed under the previous active Trust Server key still verifies while that key remains in the published ring (Trust Server LLD §8.6)
Unknown kid refetchToken with a newly rotated kid verifies after one bounded DID-document refetch; bogus kid flood is rate-capped
Mandatory claimsToken missing any Trust Server §7.2 claim (iss, sub, aud, exp, jti, scope, txn) rejected
Token hash alignmentReceipt authorization.token_hash equals base64url(sha256(compact JWT))
Cross-service token vectorToken minted with pkg/jwtmint verifies with pkg/jwtverify byte-for-byte (Trust Server LLD §15)
Scope enforcementExact-match; unknown scope default-denied
Tier ceilingtier > RiskTierMax rejected
Receipt on successSigned receipt built and submitted
Receipt on failureSigned error receipt (outcome.status:"error", correct code/status)
Data minimizationReceipt contains hashes only — no payloads
Canonicalizationrequest_hash/response_hash use CanonicalBytes; jcs:rfc8785; matches #71
SignatureReceipt EdDSA signature verifies
IdempotencyDuplicate transaction_id+message_id not processed twice
Async auditSlow/failing ledger does not delay or fail the response
IngressOversized body rejected; TLS 1.3 enforced
Proof-of-control registrationBound object (method, URI, kid, nonce, body hash) verifies; §8.1 reference vector matches Registry
Capability taxonomyUnknown or deprecated capability id rejected at registration
Config/Registry driftAgentFacts scopes/tier/capabilities match Registry submission

Contributor flow: go test ./provider/...; go test ./conformance/provider/...; CI runs both plus depguard.

19. Deployment models

The template (cmd/provider-template/) ships two starters (spec §2.4):

  • Native. A new UAI service: provider.New(Config{...}) + one Handle(scope, fn) + Register + ListenAndServeTLS on Router() (public P2P) and optionally AdminRouter() on a separate internal listener. Compiles, runs, self-registers, serves did.json/agentfacts.json, answers a Tier 1 query, and emits a receipt out of the box.
  • Gateway proxy. Wrap an existing HTTP backend (the NAFPO pattern): the handler forwards req.Intent to a configured backend URL and normalizes the response into Response{Data: ...}, with zero code changes to the legacy service. Backend calls use bounded timeouts and a response size cap; the backend URL is operator-configured (trusted), not taken from untrusted input.

The only adopter-written code in either variant is Config plus the handler body.

20. Phase 2 hooks (deferred)

  • Tier 3 DPoP: enforce the tokenopts/dpop.go seam (spec §4.3); jti replay cache.
  • RFC 9421 HTTP Message Signatures: optional second sanctioned request-integrity mechanism alongside the Phase 1 JWS proof.
  • Consent revocation re-check: Tier 3+ providers re-check revocation_check_url past half-TTL (spec §4.4).
  • VC verification chain + Status List 2021: provider/insurance/residency VCs at registration and per-tier.
  • Receipt co-signing: reserved schema fields (spec §5.3).
  • Write / transactional scopes: confirm, track, cancel; idempotency becomes load-bearing.
  • External idempotency store: shared cache (e.g. Redis) for multi-instance providers.
  • MCP bridge production-hardening.

21. Versioning

The Provider Kit is a library other teams import, so version discipline is part of its contract.

  • Module version (SemVer). MAJOR.MINOR.PATCH; a v2+ release lives under a /v2 import path.
  • Public API stability. The stable surface is exactly the §7 API (New, Config, Handle, Router, AdminRouter, Register, Request, Response). Anything under provider/internal/... may change in any release.
  • Protocol version. The UAI protocol version is carried in envelope context.version and is independent of the module version; a protocol-breaking change is a MAJOR bump.
  • uai-core compatibility. Each release documents the required uai-core version; the kit pins the frozen JSON Schemas and shared vectors (#71) it was built against.
  • Deprecation policy. A method/field is marked // Deprecated: for at least one MINOR before removal in the next MAJOR, naming the replacement.
  • Reporting. The kit exposes its version (provider.Version) for logs/support and in the User-Agent on outbound control-plane calls.

22. Open items to confirm

  1. message nesting. Flat payload as message directly vs message.intent — must match the Seeker SDK and #71 vectors.
  2. Wrap-rule alignment. Provider and Seeker must hash the wrapped {context, message} envelope using identical wrap rules, or E2E receipt verification will not match. Locked by the shared vectors in #71.
  3. Envelope proof mechanism (#64). Phase 1 signs proof as a detached EdDSA JWS over canonical {context, message} (spec §3.3 alternative); confirm versus RFC 9421 as the v1 baseline and align the exact proof shape with the Seeker SDK.
  4. Error-receipt boundary. Exactly which pre-verification failures (e.g. unparseable body, missing token) produce a receipt vs. a bare 4xx with no hashable envelope.
  5. Clock-skew window. Fix the allowed skew for token exp/nbf/iat and align it with the Trust Server tier TTLs and DPoP skew (Trust Server LLD §8.2, §8.4).
  6. Idempotency store. In-memory (single instance) vs. shared store for horizontally scaled Providers in Phase 1.
  7. Canonicalization constant. artifacts.canonicalization — the spec shows both jcs:rfc8785 (§3.4, issue #77) and uai-envelope-c14n:v1 (§5.2 example). This LLD uses jcs:rfc8785 to match the Seeker SDK and issue #77; confirm the exact frozen-schema value the Audit Ledger enforces at intake so submissions are not rejected (Audit Ledger LLD §9.1 semantic-sanity check).
  8. Trust Server rotation parameters. Confirm the operational numbers from Trust Server LLD §17 open item #4 (the retirement rule of §8.6): did.json cache TTL, publish-ahead interval, and rotation cadence (ring size default 3 follows from these).
  9. Seeker DID cache placement (decided, §8.3). The verified-document cache lives in shared pkg/did (not Provider-local), mirroring the Registry/Trust Server resolver-cache pattern and the Audit Ledger’s verified-document caching; confirm the negative-cache TTL floor/ceiling around the 30s default operationally.
  10. Re-registration semantics. Confirm Registry preserves registered_at on re-POST (Registry LLD §18 open item #5).
  11. Trust Server DID method. Provider and services use did:webvh (D15); the Trust Server LLD Phase 1 draft still uses did:web (Trust Server LLD §17 open item #6). Confirm the resolution path for Trust Server keys under D15.
  12. Risk-policy source of truth. Confirm pkg/policy loads the same UAIRiskPolicy file the Trust Server uses so scope→tier resolution matches mint-time evaluation (Trust Server LLD §8.2).