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), supersedingdid: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
- Purpose and scope
- Responsibilities
- Module layout and boundaries
- Domain model
- Runtime state
- Core interfaces
- API surface
- Request pipeline
- 8.1 Pipeline order
- 8.2 Envelope profile
- 8.3 Offline token verification
- 8.4 Scope and tier enforcement
- 8.5 Idempotency
- 8.6 Tier 3 DPoP seam
- 8.7 Ingress safety
- Receipts and audit submission
- Identity, AgentFacts, and registration serving
- Key flows
- Concurrency model
- Validation
- Security summary
- Observability
- Configuration
- Error model
- Testing and conformance
- Deployment models
- Phase 2 hooks (deferred)
- Versioning
- 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 theAuthorization: Bearertoken, verify it offline, enforce scope and tier, then dispatch to the adopter’sHandlecallback. - 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
proofand bind the request totransaction_id/message_id; deduplicate state-changing intents. - Build the response
{context, message}envelope, sign itsproof, and canonicalize{context, message}(proofexcluded) for receipt digest alignment with the Seeker SDK. - Build, sign, and asynchronously submit a
UAIReceipton every completion — success and failure — carrying hashes only (spec §5.2, §12.3). - Serve
GET /.well-known/did.jsonandGET /.well-known/agentfacts.jsonderived from oneConfig. - 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
kidis 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)
| Field | Required | Notes |
|---|---|---|
DID | yes | This Provider’s did:webvh:... |
Identity | yes | A 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) |
TrustServerDID | yes | did:webvh whose published log/keys the Provider caches for offline token verification |
RegistryURL | yes | Registry base URL for registration client calls and for building htu in the proof-of-control bound object (Registry LLD §8.1) |
AuditLedgerURL | yes | Control plane Audit Ledger base URL (receipt submission) |
RiskTierMax | yes | Ceiling; requests above this tier are rejected; also submitted to the Registry at registration |
SupportedScopes | yes | Exact scopes this Provider answers; anything else is default-denied; must match Registry record |
Capabilities | yes | Capability taxonomy ids from the controlled vocabulary (Registry LLD §9.6); advertised at registration and in AgentFacts |
Endpoints | yes | Advertised P2P endpoints ({uri, transport:https, auth:bearer} per Registry schema) for the Registry record |
FactsRef | yes | URL to /.well-known/agentfacts.json; must match what the Provider serves |
IndiaCompliance | yes | {dpdp_category, data_residency} per Registry schema |
VCRefs | when RiskTierMax >= 3 | Required by Registry conditional rules (Registry LLD §11) |
HTTPServer | no | Test override; default uses pkg/transport (TLS 1.3) |
AuditAuth | no | Optional. 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) |
MaxRequestBytes | no | Hard 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 thecrypto.Signerform 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, andcrypto.Signeris the seam that makes that possible without an API change.
Request and response types
| Type | Fields (summary) |
|---|---|
Request | Intent (seeker payload), Claims *jwtverify.Claims (verified token claims), Raw *envelope.Envelope (full request envelope, for hashing) |
Response | Data any (flat domain JSON the framework wraps into a response envelope) |
Handler | func(ctx context.Context, req Request) (Response, error) |
Capability | id, version — Registry taxonomy entry |
Outcome | status (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.Signerhandle (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
kidand refreshed on cache expiry (Cache-ControlTTL; no rotation push — §8.3), - a bounded verified
did:webvhdocument cache provided bypkg/did(post log-walk LRU,Cache-Controlexpiry, negative entries, singleflight — §8.3), - the scope →
Handlerregistration map (immutable afterNew()), - an idempotency cache keyed by
transaction_id+message_idfor 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.
| Method | Purpose |
|---|---|
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.Handler | Public P2P surface: /uai/v1/query, /.well-known/did.json, /.well-known/agentfacts.json |
(*Provider) AdminRouter() http.Handler | Internal/admin surface only: /admin/self-register — not mounted on Router(); bind to a separate internal listener (localhost / mTLS / network policy) |
(*Provider) Register(ctx) error | Registry 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:
- Ingress guard: enforce TLS 1.3, cap body size (
MaxRequestBytes), reject malformed content type (§8.7). - Parse & schema-validate envelope (local): decode
{context, message, proof}and validate againstpkg/schema. Do not verify theproofyet. - Extract token: read
Authorization: Bearer <jwt>; missing/empty →401 token_invalid. - Verify token offline (local, cached keys):
pkg/jwtverify.Verify(rawJWT, aud=cfg.DID)against cached Trust Server keys; checkiss,sub,aud,exp/nbf/iatwithin clock skew,jti,scope,txn(§8.3). - Bind request (local): assert token
txnequals envelopecontext.transaction_id, tokensubequalscontext.seeker_did, tokenaudequalscfg.DID/context.provider_did. - Resolve Seeker
did:webvh(cached) & verify requestproof: resolve the Seeker’sdid:webvhdocument via the SSRF-hardenedpkg/didresolver (served from the verified-document cache when warm; §8.3) and verify the requestproofover canonical{context, message}. This is the only step that may issue an outbound fetch. - Enforce policy: resolve scope via
pkg/policy; requirescope ∈ SupportedScopes(default-deny) andtier ≤ RiskTierMax(§8.4). - Seam: Tier 3 verification hook — invoked but unenforced in Phase 1 (§8.6).
- Idempotency: for state-changing intents, dedup on
transaction_id+message_id(§8.5). - Dispatch: call the registered
Handle(scope, fn)with the populatedRequest. - Build & sign response envelope: wrap
Response.Datainto{context, message}, signproof. - Receipt: build + sign a
UAIReceiptfor the outcome (success or error) and hand it to async audit submission (§9). - 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.
| Layer | Phase 1 behaviour |
|---|---|
| On the wire (P2P HTTP body) | Full {context, message, proof} envelope JSON |
Request proof | Verified against the Seeker’s did:webvh key over canonical {context, message} |
Response proof | Detached JWS (EdDSA) over canonical {context, message}; verification_method = <provider_did>#<kid>, signed via Config.Identity (crypto.Signer) |
| For hashing / receipt | Canonicalize {context, message} via pkg/jcs; proof excluded (spec §3.4) |
| Audit Ledger | request_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 atNew(). - Unknown
kid(rotation edge case). If a token’s headerkidis 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 boguskidvalues 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 embeddedjwk— 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 —
EdDSAonly, case-sensitive. Rejectnone,HS256, any HMAC/RSA/ECDSA algorithm, and any case variant (eddsa,EDDSA, …). The permittedalgis compared with an exact, case-sensitive match against a one-element allowlist — this closes algorithm-confusion / key-confusion attacks. - Required
typcheck. The headertypmust equal the expected media type for a UAI delegation token; any other or missingtypis 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 headerkid, and treatskidas an untrusted lookup hint. Unknownkidafter one refetch → reject. - Claim validation (Trust Server LLD §7.2). Mandatory JWT claims:
iss(=TrustServerDID),sub,aud(=cfg.DID),iat/nbf/expwithin a bounded clock-skew window,jti,scope,txn. The header carriesalg: EdDSAandkidof the form<trust_server_did>#<key>, selecting a verification method from the cached ring.tieris not a JWT claim — the Provider derives it fromscopeviapkg/policyusing the same scope→tier mapping the Trust Server evaluated at mint time (Trust Server LLD §8.2).consent_idis 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_hashin the receipt isbase64url(sha256(compact JWT))— the same digest the Trust Server stores inGrant.token_hashand DPoP uses asath(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-ControlTTL, 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:webvhresolution + cache behaviour inpkg/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-Controlheader (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
SupportedScopes→403 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/policyresolves the scope’s tier using the same UAIRiskPolicy mapping the Trust Server loaded at mint time;tier > RiskTierMax→403 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/htumatch the P2P request;athequalsbase64url(sha256(compact JWT))(same asauthorization.token_hash).jtiunseen within the token TTL + skew window (Redis replay guard on the Trust Server side at mint; Provider-side replay cache in Phase 2).iatwithin 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
proofverification, 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(= tokeniss).authorization:token_hash(=base64url(sha256(compact JWT)), Trust Server LLD §7.2),scope,tier(derived from scope viapkg/policy) — hashes only, never the token.artifacts:request_hash/response_hashfrom the envelopes’CanonicalBytes();canonicalization = "jcs:rfc8785".outcome:status(success|error|timeout),http_status,error_code.signature: EdDSA over the canonical receipt body viaConfig.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:
| Status | Meaning | Retry? |
|---|---|---|
202 Accepted | Receipt appended (or already present with identical content) | No — done |
409 Conflict | Same (transaction_id, message_id) but different receipt content | No — log at error severity; indicates a bug or split-brain (same idempotency key, divergent hashes) |
422 Unprocessable Entity | Schema / signature / intake-rule violation | No — log at error severity; fix the receipt builder or canonicalization constant |
429 Too Many Requests | Rate-limited | Yes — honour Retry-After when present, else exponential backoff + jitter |
503 Service Unavailable | Ledger temporarily down or overloaded | Yes — honour Retry-After when present, else exponential backoff + jitter |
Other 5xx / transport errors | Transient failure | Yes — bounded retry with backoff |
Other 4xx | Permanent client error | No — 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 thedid:webvhhistory logdid.jsonl) — built frompkg/did.BuildDocument(cfg.DID, signer, services); underdid:webvhthe served artifact is the hash-chained, signed DID log (with SCID) thatpkg/didresolves and verifies.GET /.well-known/agentfacts.json— built frompkg/agentfacts.Build(cfg); passesagentfacts.ComplianceCheck(spec §2.3); carriesuai_registration,delegation_scopes,risk_tier_max,india_compliance,consent_requirements,vc_refs. The URL of this document isConfig.FactsRefand must match the Registry submission.POST /admin/self-register— exposed only onAdminRouter(), not on the publicRouter(). Mirrors the Registry’s two-listener split (Registry LLD §3): the adopter bindsAdminRouter()to a separate internal listener (localhost, mTLS, or network-policy gated) and triggersRegister()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):
GET /registry/v1/challenge?did=<provider_did>→ one-time nonce (5-min TTL, single-use).- Build the registration body from
Config:did,role: provider,endpoints,capabilities,supported_scopes,risk_tier_max,facts_ref,india_compliance, andvc_refswhenRiskTierMax >= 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) )
POST /registry/v1/agentswith 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(fromNew()) 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.Contextand 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:
- Config (structural).
New()validatesDIDformat,Identity, control-plane URLs,RiskTierMax, non-emptySupportedScopes/Capabilities/Endpoints. Failure before any listener starts. - Request shaping (pipeline). Envelope schema (
pkg/schema), seekerproof, token binding, scope support, tier ceiling — each maps to a canonicalpkg/uaierrcode. - 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: Bearerverified 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’skidis 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’sdid:webvhkey over canonical{context, message}; responseproofsigned with the Provider’s key so the Seeker can verify and receipt digests align. - Request binding (anti-replay/confusion): token
txnmust equalcontext.transaction_id,submust equalcontext.seeker_did,audmust equalcfg.DID;exp/nbf/iatpass a bounded clock-skew window;jtisupports replay detection. - Default-deny + tier ceiling: unknown scope →
403 scope_not_supported;tier > RiskTierMax→403 tier_insufficient. - Key custody: signing is reached only through
Config.Identity(acrypto.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/ed25519xis 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_iddedup prevents double-processing state-changing intents.
15. Observability
- Logging: structured
log/slog; one summary line per request withtransaction_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.jsonrefetch 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 / option | Meaning |
|---|---|
DID | This Provider’s DID |
Identity | crypto.Signer for receipts, response proof, and Register(); KMS/HSM-capable |
TrustServerDID / RegistryURL / AuditLedgerURL | Control plane references; RegistryURL also builds proof htu (Registry LLD §8.1) |
RiskTierMax | Highest tier this Provider will serve |
SupportedScopes / Capabilities / Endpoints / FactsRef / IndiaCompliance / VCRefs | Registry record + AgentFacts fields (§10.1) |
MaxRequestBytes | Inbound body size cap |
AuditRetryPolicy | Backoff/attempts/backlog bound for async submission |
IdempotencyTTL | Dedup window for transaction_id + message_id |
HTTPServer / AuditAuth | Test 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 / code | When |
|---|---|
ErrConfigInvalid | Missing 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) |
ErrAuditSubmitFailed | Ledger 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_hashmust match the Seeker byte-for-byte. Cross-service token vector: a token minted withpkg/jwtmint(Trust Server) must verify withpkg/jwtverify(Provider) byte-for-byte (Trust Server LLD §15). did:webvhconformance:pkg/didcarries its own resolution + verifiable-history vectors, using the officialdid:webvhtest suite where one exists.
| Test area (spec §12.3) | Asserts |
|---|---|
| Offline verify | Token verified with no Trust Server callback per call |
| JWT alg confusion | Token with alg ≠ EdDSA (e.g. none, HS256, case variants) rejected |
JWT wrong typ | Token with missing or unexpected typ rejected |
| JWT malformed charset | Token with non-base64url JWS segments rejected before JSON decode |
JWT jku ignored | Token carrying jku/x5u/embedded jwk verified only against cached Trust Server ring by kid |
| Key rotation overlap | Token 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 refetch | Token with a newly rotated kid verifies after one bounded DID-document refetch; bogus kid flood is rate-capped |
| Mandatory claims | Token missing any Trust Server §7.2 claim (iss, sub, aud, exp, jti, scope, txn) rejected |
| Token hash alignment | Receipt authorization.token_hash equals base64url(sha256(compact JWT)) |
| Cross-service token vector | Token minted with pkg/jwtmint verifies with pkg/jwtverify byte-for-byte (Trust Server LLD §15) |
| Scope enforcement | Exact-match; unknown scope default-denied |
| Tier ceiling | tier > RiskTierMax rejected |
| Receipt on success | Signed receipt built and submitted |
| Receipt on failure | Signed error receipt (outcome.status:"error", correct code/status) |
| Data minimization | Receipt contains hashes only — no payloads |
| Canonicalization | request_hash/response_hash use CanonicalBytes; jcs:rfc8785; matches #71 |
| Signature | Receipt EdDSA signature verifies |
| Idempotency | Duplicate transaction_id+message_id not processed twice |
| Async audit | Slow/failing ledger does not delay or fail the response |
| Ingress | Oversized body rejected; TLS 1.3 enforced |
| Proof-of-control registration | Bound object (method, URI, kid, nonce, body hash) verifies; §8.1 reference vector matches Registry |
| Capability taxonomy | Unknown or deprecated capability id rejected at registration |
| Config/Registry drift | AgentFacts 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{...})+ oneHandle(scope, fn)+Register+ListenAndServeTLSonRouter()(public P2P) and optionallyAdminRouter()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.Intentto a configured backend URL and normalizes the response intoResponse{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.goseam (spec §4.3);jtireplay 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_urlpast 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; av2+release lives under a/v2import path. - Public API stability. The stable surface is exactly the §7 API (
New,Config,Handle,Router,AdminRouter,Register,Request,Response). Anything underprovider/internal/...may change in any release. - Protocol version. The UAI protocol version is carried in envelope
context.versionand is independent of the module version; a protocol-breaking change is aMAJORbump. uai-corecompatibility. Each release documents the requireduai-coreversion; 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 oneMINORbefore removal in the nextMAJOR, naming the replacement. - Reporting. The kit exposes its version (
provider.Version) for logs/support and in theUser-Agenton outbound control-plane calls.
22. Open items to confirm
messagenesting. Flat payload asmessagedirectly vsmessage.intent— must match the Seeker SDK and #71 vectors.- 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. - Envelope
proofmechanism (#64). Phase 1 signsproofas a detached EdDSA JWS over canonical{context, message}(spec §3.3 alternative); confirm versus RFC 9421 as the v1 baseline and align the exactproofshape with the Seeker SDK. - Error-receipt boundary. Exactly which pre-verification failures (e.g. unparseable body, missing token) produce a receipt vs. a bare
4xxwith no hashable envelope. - Clock-skew window. Fix the allowed skew for token
exp/nbf/iatand align it with the Trust Server tier TTLs and DPoP skew (Trust Server LLD §8.2, §8.4). - Idempotency store. In-memory (single instance) vs. shared store for horizontally scaled Providers in Phase 1.
- Canonicalization constant.
artifacts.canonicalization— the spec shows bothjcs:rfc8785(§3.4, issue #77) anduai-envelope-c14n:v1(§5.2 example). This LLD usesjcs:rfc8785to 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). - Trust Server rotation parameters. Confirm the operational numbers from Trust Server LLD §17 open item #4 (the retirement rule of §8.6):
did.jsoncache TTL, publish-ahead interval, and rotation cadence (ring size default 3 follows from these). - 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. - Re-registration semantics. Confirm Registry preserves
registered_aton re-POST (Registry LLD §18 open item #5). - Trust Server DID method. Provider and services use
did:webvh(D15); the Trust Server LLD Phase 1 draft still usesdid:web(Trust Server LLD §17 open item #6). Confirm the resolution path for Trust Server keys under D15. - Risk-policy source of truth. Confirm
pkg/policyloads the same UAIRiskPolicy file the Trust Server uses so scope→tier resolution matches mint-time evaluation (Trust Server LLD §8.2).