UAI Seeker SDK: Low-Level Design (LLD)
How the Seeker SDK is built — discovering Providers, obtaining delegation tokens, and interacting peer-to-peer.
Scope. This is the how of the Seeker SDK. The what (lifecycle ownership, trust model, platform shape) lives in the platform HLD. Where the HLD says a Seeker exists, this page says how the library is built. The UAI Technical Specifications and frozen JSON Schemas remain the normative contract; this LLD implements them and must not contradict them.
Status. First draft for iteration. Library and shape choices marked (proposal) are open to change.
Index
- Purpose and scope
- Responsibilities
- Domain model
- Runtime state
- Core interfaces
- API surface
- Key flows
- Concurrency model
- Validation
- Security summary
- Observability
- Configuration
- Error model
- Testing and conformance
- Phase 2 hooks (deferred)
- Open items to confirm
- Versioning
1. Purpose and scope
A Seeker is any application that initiates a UAI interaction: discover a Provider, obtain a delegation token, call the Provider peer-to-peer over TLS 1.3, and verify that a hash-only receipt was recorded in the Audit Ledger.
The Seeker SDK (seeker/) is a thin Go library built entirely on uai-core/pkg/*. An adopter supplies configuration and domain logic; the SDK owns discovery, token requests, envelope construction and signing, P2P transport (the signed {context, message, proof} envelope on the wire over TLS 1.3), Bearer presentation (RFC 6750), and receipt verification.
This document covers the SDK’s module layout, domain model, public API, core flows, envelope profile, concurrency, validation, security, observability, errors, and testing. It does not cover the Registry, Trust Server, Audit Ledger, or Provider Kit implementations except where the Seeker calls them.
Phase 1 delivers the SDK (sub-epic 4A) and the first adopter — NAFPO Seeker (sub-epic 4B) — on top of a stable control plane (Epic #2).
2. Responsibilities
The Seeker SDK does:
- Optionally register the Seeker in the Registry (challenge → proof-of-control →
POST /registry/v1/agents,role: seeker). - Discover Providers via
GET /registry/v1/discover, rank results, and select a Provider forQuery(). - Request delegation tokens via
POST /trust/v1/token(Tier 1–2 in Phase 1), first proving control of the Seeker DID with the Trust Server’sGET /trust/v1/challengeproof-of-control (Trust Server LLD §7.1). - Bind the one
transaction_idminted by the Trust Server (returned inTokenResponse) across envelopecontext, JWTtxnclaim, and receipt. The SDK never generates its owntransaction_id; minting server-side keeps the namespace under Trust Server control. - Build the
{context, message}envelope from the adopter’s flat payload, sign it (envelopeproof), and send the{context, message, proof}envelope on the P2P wire withAuthorization: Bearer(RFC 6750). - Verify the Provider’s response envelope
proof, and canonicalize{context, message}(proofexcluded) for receipt digest alignment with the Provider Kit. - Poll the Audit Ledger and verify
request_hash,response_hash, Provider EdDSA signature, and participant DIDs. - Expose a small, stable public API (
New,Discover,Token,Query,VerifyReceipt,ListReceipts,Register).
The Seeker SDK does not:
- Reimplement canonicalization, envelope hashing, JWT verification, or control-plane HTTP (all from
pkg/*). - Store or forward Provider payloads through UAI control-plane services.
- Issue delegation tokens or evaluate risk policy (Trust Server).
- Fetch or validate consent artifacts (adopter passes
consent_id; Trust Server validates). - Generate Tier 3 DPoP proofs in Phase 1 (seam only; see §17).
- Perform NANDA federation or semantic discovery ranking in Phase 1.
Module layout
One importable library, compiler-walled internals, shared primitives imported from pkg/. Domain types and port interfaces live in internal/model/; each outbound integration (Registry, Trust, Audit, P2P) is a separate package behind those interfaces.
seeker/
seeker.go public API surface
config.go Config validation
options.go functional options (timeouts, poll policy)
tokenopts/
dpop.go Phase 2 no-op stub
internal/
model/ domain types + sentinel errors
+ port interfaces: Discoverer, TokenMinter, EnvelopeBuilder,
Transporter, ReceiptVerifier, Registrar
query/ Query() orchestration
discover/ Registry discover + ranking
token/ Trust Server token shaping
envelope/ {context, message} construction, sign/verify proof + canonicalization (uses pkg/envelope, pkg/jcs, pkg/ed25519x)
transport/ P2P POST /uai/v1/query — {context, message, proof} envelope body, Bearer, TLS 1.3
receipt/ ledger poll + verify (uses pkg/receipt)
register/ optional Registry self-registration
cmd/seeker-template/ runnable minimal Seeker against toy Provider
conformance/seeker/ operational definition of correct Seeker behaviour (CI)
pkg/ shared, byte-identical across services and kits
jcs/ RFC 8785 canonicalization
envelope/ Envelope type + CanonicalBytes()
receipt/ receipt.Verify()
transport/ TLS 1.3 HTTP client
did/ did:webvh resolver (web + verifiable history)
ed25519x/ Ed25519 sign/verify (Register() proof-of-control + envelope proof)
schema/ frozen JSON Schemas + loader (response validation)
clients/ registry, trust, audit REST clients (generated from OpenAPI)
Boundary rule. Nothing in seeker/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 seeker 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 orchestration code.
Sibling: Provider Kit. The Seeker SDK and Provider Kit (Epic #3) share pkg/jcs, pkg/envelope, and conformance vectors (#71). Both must implement the same Phase 1 envelope profile (§8.1) so receipt digests match byte-for-byte.
Why interfaces at the boundaries. Because Discoverer, TokenMinter, EnvelopeBuilder, Transporter, ReceiptVerifier, and Registrar are interfaces, orchestration stays testable with fakes, outbound adapters are swappable, and a contributor has a small, clear surface to satisfy.
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 SDK uses. Client and server cannot drift; CI fails if generated code is stale.
5. Domain model
The adopter supplies Config and a flat domain payload. The SDK never exposes raw HTTP to adopters.
Configuration (Config)
| Field | Required | Notes |
|---|---|---|
SeekerDID | yes | This agent’s did:webvh:... |
RegistryURL | yes | Control plane Registry base URL |
TrustURL | yes | Trust Server base URL |
AuditURL | yes | Audit Ledger base URL |
Identity | yes | Ed25519 signer for Register() proof-of-control and P2P envelope proof (pkg/ed25519x) |
HTTPClient | no | Test override; default uses pkg/transport (TLS 1.3) |
AuditAuth | no | Mints Seeker-scoped JWT for ledger reads |
DefaultCountry | no | Default envelope context.country (e.g. IND) |
PollTimeout | no | Max wait for receipt after P2P (default aligned with P95 E2E < 2s) |
Request and response types
| Type | Fields (summary) |
|---|---|
DiscoverQuery | scope, optional capability, tier_max, data_residency, cursor, page_size |
ProviderRecord | did, endpoints[], supported_scopes[], risk_tier_max, india_compliance.data_residency, facts_ref |
TokenRequest | provider_did, scope, optional consent_id (Tier 2+) |
TokenResponse | access_token, transaction_id (minted by the Trust Server), tier, expires_in |
QueryRequest | scope, optional provider_did, payload (flat domain JSON), optional consent_id |
QueryResponse | data, transaction_id, receipt (VerifiedReceipt) |
VerifiedReceipt | Ledger receipt + status: verified | unverifiable |
Envelope | {context, message, proof} — serialized as the P2P HTTP body; proof signs the canonical {context, message} |
Critical invariant
The {context, message} portion of the envelope the SDK sends on the wire must canonicalize identically to what the Provider hashes when signing its receipt. The envelope proof signs that same canonical form and is itself excluded from the hash (spec §3.4), so the wire body and the hashed form never diverge. The SDK builds and signs the envelope from the adopter’s flat payload via pkg/jcs + pkg/ed25519x; adopters never construct or sign envelopes themselves.
6. Runtime state
The Seeker SDK is stateless between calls. It holds no database and persists no Provider payloads. During a single Query() it keeps in memory only:
- the request and response
{context, message, proof}envelopes, - the delegation JWT (never logged),
- poll state for the receipt fetch.
This matches the notary model: payloads flow P2P only; the SDK and control plane never store them. Receipts in the Audit Ledger store hashes only (spec §5.2).
7. Core interfaces
Orchestration depends on these ports, not on raw HTTP clients.
type Discoverer interface {
Discover(ctx context.Context, q DiscoverQuery) ([]ProviderRecord, error)
Select(providers []ProviderRecord, scope string) (*ProviderRecord, error)
}
type TokenMinter interface {
Mint(ctx context.Context, req TokenRequest) (TokenResponse, error)
}
type EnvelopeBuilder interface {
// BuildRequest wraps the flat payload into {context, message} and signs it (proof).
BuildRequest(meta ContextMeta, payload map[string]any) (*envelope.Envelope, error)
// ParseResponse parses the {context, message, proof} response and verifies proof.
ParseResponse(responseBody []byte) (*envelope.Envelope, error)
}
type Transporter interface {
Query(ctx context.Context, endpoint string, token string, body []byte) ([]byte, error)
}
type ReceiptVerifier interface {
WaitAndVerify(ctx context.Context, txnID string, req, resp *envelope.Envelope) (VerifiedReceipt, error)
List(ctx context.Context, filter ReceiptFilter) ([]VerifiedReceipt, error)
}
type Registrar interface {
Register(ctx context.Context) error
}
Phase 1 implementations delegate to pkg/clients/registry, pkg/clients/trust, pkg/clients/audit, pkg/envelope, pkg/transport, and pkg/receipt.
8. API surface
Public Go API — stable product surface; integration guide and template are written against exactly this.
| Method | Lifecycle step | Purpose |
|---|---|---|
New(Config) | — | Construct SDK; validate config |
Register() | 1 | Optional Registry self-registration |
Discover() | 2 | Find Providers by scope / tier / residency |
Token() | 3 | Obtain delegation JWT |
Query() | 2→4→6 | Full orchestration |
VerifyReceipt() | 6 | Fetch and cross-check one receipt |
ListReceipts() | 7 | List receipts visible to this seeker |
Minimal adopter call:
s, err := seeker.New(cfg)
resp, err := s.Query(ctx, seeker.QueryRequest{
Scope: "read:fpo_data",
Payload: map[string]any{"state": "Maharashtra", "district": "Pune"},
})
Lower-level methods exist for multi-provider chains; adopters must not reimplement protocol HTTP.
8.1 Phase 1 envelope profile
The spec defines a full {context, message, proof} envelope (§3.3) and requires one signing mechanism for v1 conformance (§3.3). Phase 1 sends the complete signed envelope on the wire: the SDK signs the canonical {context, message} and carries the signature in proof.
| Layer | Phase 1 behaviour |
|---|---|
| On the wire (P2P HTTP body) | Full {context, message, proof} envelope JSON |
Envelope proof | Detached JWS (EdDSA) over canonical {context, message}; verification_method = <seeker_did>#<kid>, signed with Config.Identity (pkg/ed25519x) |
| For hashing / receipt verify | Canonicalize {context, message} via pkg/jcs; proof excluded from the hash (spec §3.4) |
| Provider agreement | Provider verifies the request proof and signs its response envelope proof; digests match byte-for-byte |
| Audit Ledger | request_hash / response_hash only; no envelope stored (spec §5.2) |
The proof signs the same canonical {context, message} that feeds the receipt hash, and proof is excluded from that hash (spec §3.4). So the wire body and the hashed form never diverge, while the signature adds end-to-end sender authentication, body integrity, and non-repudiation that survive TLS termination. The adopter still supplies only a flat domain payload; the SDK wraps and signs it (adopters never build or sign envelopes by hand).
Signing mechanism (v1). UAI v1 requires a single request-integrity mechanism (spec §3.3, recommending RFC 9421 HTTP Message Signatures or JWS in proof). Phase 1 selects EdDSA JWS in the envelope proof, reusing the Ed25519 DID key already used for registration proof-of-control (Registry LLD §8.1) and receipt signatures, so one key type spans the system. Confirm this choice against the Provider Kit and shared vectors (§18).
Request authenticity (Phase 1). Three layers: envelope proof (application-layer signature), Bearer delegation JWT (RFC 6750), and TLS 1.3. The Provider validates the JWT offline (ZTAA, spec §8.1) and verifies the envelope proof by resolving the Seeker’s did:webvh and checking the signature over canonical {context, message}. Tier 3+ additionally binds the token with a DPoP HTTP header when cnf.jkt is present (Phase 2 seam, §17).
Context fields the SDK populates: domain, country, version, action, seeker_did, provider_did, transaction_id, message_id, timestamp, ttl; Tier 2 adds citizen_context.consent_id.
Canonicalization. Envelope.CanonicalBytes() returns JCS bytes of {context, message} with proof excluded (spec §3.4). Hash label in receipt: jcs:rfc8785.
8.2 Discovery filters and ranking
Discover() calls GET /registry/v1/discover with filters (all optional, AND-combined; contract per Registry LLD §8.2):
| Param | Purpose |
|---|---|
scope | Provider must support this scope |
capability | Subtree match over the Registry capability taxonomy (Registry LLD §9.6) |
tier_max | Ceiling on risk_tier_max (risk_tier_max <= tier_max) |
data_residency | Hard filter (e.g. IN) |
cursor / page_size | Opaque cursor pagination; page_size default 20, max 100 |
The Registry response envelope is {items, next_cursor, page_size}; Discover() follows next_cursor transparently when the caller requests more than one page.
Capability grounding. When the adopter supplies a capability, the SDK expects a canonical taxonomy id. It may first fetch GET /registry/v1/capabilities (cacheable, version-stamped) to ground a supplied label/alias onto a canonical id, rather than free-forming a urn.
Zero results. A zero-result discover carries the Registry’s broaden_to hint (the parent capability id). The SDK surfaces this via ErrNoProviders; it never silently broadens a hard filter to manufacture a match.
Ranking (kit-side, deterministic, applied after the Registry returns candidates):
- Prefer
data_residency = INwhen configured. - Prefer lower
risk_tier_max. - Prefer records with
facts_refpresent.
Query() selects the highest-scoring Provider unless QueryRequest.ProviderDID is set. Hard filters are never relaxed to manufacture a result. Ranking is a client-side convenience only; the Registry match itself stays authoritative and unranked in Phase 1 (Registry LLD §9.6).
8.3 P2P transport
Every Provider call:
POST {endpoints[].uri} // typically .../uai/v1/query
Content-Type: application/json
Authorization: Bearer <delegation-jwt>
<body> = signed {context, message, proof} envelope, built from adopter payload
- TLS 1.3 only; no 1.2 fallback.
- Resolve endpoint from the selected Provider’s Registry record.
- Response body is a
{context, message, proof}envelope; the SDK verifies the Providerproof, then canonicalizes{context, message}for receipt verification.
Endpoint safety (SSRF defense). The P2P target URL comes from a Registry record — data controlled by whoever registered the Provider. A malicious Provider could register http://169.254.169.254/... or another internal address and turn every adopter’s Seeker into an SSRF proxy inside their network. The Registry LLD already hardens its own resolver; the SDK mirrors those controls on the client side before and during every P2P call, and rejects an unsafe endpoint with ErrUnsafeEndpoint before dialing:
- Require
https. Reject any non-httpsscheme; combined with the TLS 1.3-only rule there is no plaintext or downgrade path. - Block internal / non-routable targets. Resolve the host and reject loopback (
127.0.0.0/8,::1), link-local (169.254.0.0/16, cloud metadata), and RFC 1918 / unique-local private ranges. Re-check after DNS resolution to defeat DNS-rebinding. - Disable redirects. The HTTP client does not follow
3xx; a redirect to an internal address is treated as a failure, not chased. - Cap response size. Read the response body through a hard byte limit (
io.LimitReader) so a hostile or buggy Provider cannot exhaust memory. - Bounded timeouts. Explicit connect, TLS-handshake, read, and total-request timeouts (aligned with the
PollTimeoutbudget), all undercontext.Contextcancellation.
Response envelope binding checks. Verifying the Provider’s proof signature is necessary but not sufficient — a valid signature only proves the bytes were signed by that key, not that they belong to this interaction. Before accepting a response the SDK enforces all of the following, and fails with ErrResponseBindingMismatch if any check fails:
- Transaction binding. Response
context.transaction_idmust equal the request’stransaction_id. Without this check a Provider can replay an old, correctly signed response. - Provider identity. Response
context.provider_didmust equal the Provider the SDK selected and called. - Key origin. The
proof.verification_methodmust resolve under that sameprovider_did(did:webvhdocument), so the signature comes from a key the Provider actually controls. - Freshness.
context.timestampandttlmust pass a defined clock-skew window (bounded allowed skew; reject stale or future-dated responses), closing the replay window that a matching txn alone does not.
These checks run after signature verification and before the response is handed to receipt verification or returned to the adopter.
8.4 Retry semantics
Retries are decided per endpoint by idempotency, not applied blindly. The SDK only auto-retries operations that are safe to repeat; everything else is surfaced to the caller.
| Endpoint | Method | Safe to retry? | Rule |
|---|---|---|---|
GET /registry/v1/discover | GET | Yes (idempotent) | Idempotent read. Retry with exponential backoff + jitter on 5xx, 429 (honour Retry-After), and transport errors. No retry on 4xx (except 429). |
GET /registry/v1/capabilities | GET | Yes (idempotent) | Idempotent, cacheable read; same backoff as discover. |
GET /registry/v1/challenge | GET | Yes (idempotent) | Idempotent; each call yields a fresh single-use nonce. |
POST /trust/v1/token | POST | Only with a fresh challenge | Retryable only after a new GET /trust/v1/challenge — a consumed challenge/nonce cannot be reused. On a retryable failure, re-fetch the challenge, re-sign, then re-POST. Never replay the same signed request. |
POST /uai/v1/query (P2P) | POST | No (not idempotent) | Not idempotent — the Provider may have already processed the request and submitted a receipt. Do not blind-retry (risks duplicate side effects and a second receipt). Surface ErrP2PFailed; the adopter decides whether a new Query() (new transaction_id) is appropriate. |
GET /audit/v1/receipts | GET | Yes (idempotent) | Idempotent read; this is the poll loop itself (exponential backoff + jitter until receipt or PollTimeout). |
POST /registry/v1/agents (Register) | POST | Only with a fresh challenge | Retryable only after a new challenge, like token. Registration is effectively idempotent server-side (same DID), but each attempt needs an unconsumed nonce. |
Retry budgets are bounded (max attempts + total deadline) and always respect context.Context cancellation and Retry-After. A 429 or 503 with Retry-After uses that hint instead of the computed backoff.
9. Key flows
9.1 Query() orchestration
Ordered so cheap local checks happen before network calls.
- Validate
QueryRequest(scope, payload, consent rules). - Discover (skip if
ProviderDIDset): Registry discover + rank + select. - Token: prove control of the Seeker DID, then mint a token. Fetch a one-time nonce from
GET /trust/v1/challenge?did=<seeker_did>, sign the JCS-canonical bound object (did,htm,htu,kid,nonce,body_sha256) viaConfig.Identity, andPOST /trust/v1/tokencarrying the proof in theX-UAI-NonceandX-UAI-Signatureheaders (same proof-of-control as registration, Trust Server LLD §7.1). Obtainaccess_tokenandtransaction_id. - Build & sign request envelope: wrap
payloadinto{context, message}, sign the canonical form to produceproof; serialize{context, message, proof}for the wire and keep the canonical{context, message}bytes for the digest. - P2P:
POST /uai/v1/querywith the signed{context, message, proof}envelope body + Bearer over TLS 1.3. - Parse & verify response envelope: read the
{context, message, proof}response, verify the Providerproof, then run the response binding checks (§8.3) —context.transaction_idequals the request’s,context.provider_didequals the selected Provider,proof.verification_methodresolves under thatdid:webvh, andtimestamp/ttlpass the clock-skew window — before canonicalizing{context, message}for verification. - Poll ledger:
GET /audit/v1/receipts?transaction_id=...with Seeker-scoped auth; exponential backoff until receipt orPollTimeout. - Verify: recompute hashes;
pkg/receipt.Verifyfor Provider signature and participants. - Return
QueryResponse. Hash mismatch →unverifiable(spec §3.4), surfaced clearly.
sequenceDiagram
autonumber
participant App as Adopter
participant SDK as Seeker SDK
participant Reg as Registry
participant TS as Trust Server
participant Prv as Provider
participant Aud as Audit Ledger
App->>SDK: Query(scope, payload, consentID?)
SDK->>Reg: GET /registry/v1/discover
Reg-->>SDK: Provider records
SDK->>TS: GET /trust/v1/challenge?did=seeker_did
TS-->>SDK: one-time nonce
SDK->>SDK: sign bound object (proof-of-control)
SDK->>TS: POST /trust/v1/token<br/>X-UAI-Nonce · X-UAI-Signature
TS-->>SDK: access_token, transaction_id
SDK->>SDK: build + sign {context, message, proof}
SDK->>Prv: POST /uai/v1/query<br/>{context, message, proof} · Bearer JWT
Prv-->>SDK: {context, message, proof} response
SDK->>SDK: verify response proof + binding<br/>(txn, provider_did, vm, timestamp/ttl)
Note over Prv,Aud: Provider submits receipt async
SDK->>Aud: GET /audit/v1/receipts?transaction_id=...
Aud-->>SDK: UAIReceipt
SDK->>SDK: verify hashes + Provider signature
SDK-->>App: QueryResponse{data, receipt}9.2 Optional Seeker registration
Same proof-of-control model as the Registry LLD §8.1:
GET /registry/v1/challenge?did=<seeker_did>→ one-time nonce (5-min TTL).- Sign the RFC 8785 (JCS) canonical form of the bound object (
did,htm,htu,kid,nonce,body_sha256) with the Seeker’s Ed25519 key (pkg/ed25519x). POST /registry/v1/agentswithrole: seeker, carrying the proof in theX-UAI-NonceandX-UAI-Signatureheaders (Registry LLD §8.1).
Tier 2+ Seekers must declare consent_endpoint per Registry schema. Steel-thread demos may use a pre-registered DID.
9.3 Receipt verification semantics
If the Seeker recomputes a hash that does not match the receipt, the SDK returns unverifiable, not a hard P2P failure (spec §3.4). The interaction may have succeeded; the audit trail cannot be cryptographically confirmed by this Seeker. Provider signature failure is also unverifiable.
Participant check: seeker_did and provider_did in the receipt must match the interaction.
10. Concurrency model
The SDK is I/O-bound. Query() runs sequentially: discover → token → P2P → poll → verify. We do not add goroutines to “speed up” a single happy-path call.
Concurrency-safe client. A seeker.Client (constructed by New()) is safe for concurrent Query() calls. It holds no per-call mutable state (§6) and reuses a single pkg/transport http.Client with keep-alives and per-host connection pooling across all goroutines. Adopters create one seeker.Client at startup and share it across every request handler: if ten callers hit the adopter’s server at once, ten Query() calls run on the same client concurrently, and that must be safe. Per-call state (envelopes, delegation JWT, poll state) lives only on the goroutine’s stack for the duration of that Query(), so calls never contend over shared fields.
Concurrency appears only where it removes a real wait:
- Context cancellation: every outbound call respects
context.Context. - Poll backoff: one goroutine per
Query()polls the ledger with exponential backoff and jitter underPollTimeout. - Multi-provider chains: adopter orchestration (multiple
Token+ P2P calls); the SDK does not ship a chain DSL in Phase 1.
11. Validation
Three layers, in order:
- Config (structural).
New()validates required URLs, Seeker DID format, identity key. Failure before any network I/O. - Request shaping (cross-field).
Token()requiresconsent_idfor Tier 2 scopes.Query()rejects emptyscopewhen required. - External (runtime). Typed clients +
pkg/schemavalidate responses. Response envelopeproofis verified against the Provider’sdid:webvhkey →ErrEnvelopeProofInvalidon failure. Response binding checks (§8.3) — matchingtransaction_id,provider_did,verification_methodorigin, andtimestamp/ttlskew — →ErrResponseBindingMismatchon failure. Receipt hash mismatch →ErrReceiptUnverifiable.
12. Security summary
- P2P auth (Phase 1):
Authorization: Beareron every Provider call (RFC 6750). Provider validates JWT offline (ZTAA). - Transport: TLS 1.3 only (
pkg/transport). - Envelope
proof(Phase 1): every P2P request carries a detached JWS (EdDSA)proofover canonical{context, message}, signed with the Seeker’sdid:webvhkey (pkg/ed25519x); the Provider verifies it, and the SDK verifies the Provider’s responseproof. This satisfies the spec §3.3 v1 signing requirement and adds end-to-end integrity and non-repudiation beyond JWT + TLS. - Envelopes: signed
{context, message, proof}on the wire;{context, message}canonicalized for hashing;proofexcluded from canonicalization (spec §3.4). - Response binding (anti-replay): a valid
proofsignature is not enough. The SDK also requires the responsecontext.transaction_idto equal the request’s,context.provider_didto equal the selected Provider,proof.verification_methodto resolve under that samedid:webvh, andtimestamp/ttlto pass a bounded clock-skew window (§8.3). This blocks replay of an old, correctly signed Provider response. - Receipt verification: independent hash recompute + Provider EdDSA signature; participant DIDs must match.
- Registration: Registry proof-of-control per Registry LLD §8.1 when
Register()is used. - SSRF defense (P2P): the Provider endpoint URL comes from a Registry record; the SDK requires
https, blocks loopback/link-local/metadata/RFC 1918 targets (re-checked after DNS resolution), disables redirects, caps the response body size, and sets bounded timeouts (§8.3). Unsafe endpoints fail withErrUnsafeEndpointbefore dialing. - Bounded response reads: every remote body — Provider P2P responses and Audit Ledger reads — is read through a hard size cap (
io.LimitReader), so a hostile or buggy peer cannot exhaust Seeker memory. - Unknown critical fields: after the
proofcheck, the SDK rejects a response that carries any envelope field marked critical which it does not recognize, rather than silently ignoring it (fail-closed). - Error hygiene: error messages and logs never echo request/response payloads; they carry only
transaction_id, DIDs, scope, and the verification outcome. - Logging hygiene: never log delegation JWTs or domain payloads; log
transaction_id, DIDs, scope, verification outcome. - Audit read auth: Seeker-scoped JWT (
sub= seeker DID); dev HS256 pattern in template; production profile TBD (§18).
13. Observability
- Logging: structured
log/slog; one summary line perQuery()withtransaction_id,provider_did,scope, latency, receipt status. - Metrics (optional Phase 1): discover/token/P2P/verify outcome counters; E2E
Query()duration histogram. - Tracing (optional Phase 1): OpenTelemetry spans on discover, token, P2P, ledger poll.
The SDK does not submit receipts; it only verifies them.
14. Configuration
Typed Config struct; environment loading lives in cmd/seeker-template/, not in the library.
| Field / option | Meaning |
|---|---|
SeekerDID | This agent’s DID |
RegistryURL / TrustURL / AuditURL | Control plane base URLs |
Identity | Ed25519 key for Register() and P2P envelope proof |
PollTimeout | Max wait for receipt (default ~2s budget) |
PollInitialInterval | First backoff interval |
DefaultCountry | Envelope context.country default |
HTTPClient | Test override |
AuditAuth | Token minting for ledger reads |
15. Error model
One sentinel-error set in internal/model/; public API wraps with %w for errors.Is.
| Error | When |
|---|---|
ErrConfigInvalid | Missing or malformed Config |
ErrNoProviders | Discover returned no matches |
ErrConsentRequired | Tier 2 scope without consent_id |
ErrTokenDenied | Trust Server rejected token request |
ErrP2PFailed | Provider HTTP or TLS failure |
ErrUnsafeEndpoint | Provider endpoint failed SSRF checks: non-https, internal/non-routable address, or redirect (§8.3) |
ErrEnvelopeProofInvalid | Response envelope proof failed signature verification |
ErrResponseBindingMismatch | Response transaction_id/provider_did/verification_method mismatch or failed timestamp/ttl skew check (§8.3) |
ErrCriticalFieldUnknown | Response carries an unrecognized envelope field marked critical (§12, fail-closed) |
ErrReceiptTimeout | Ledger poll exceeded PollTimeout |
ErrReceiptUnverifiable | Hash mismatch or bad Provider signature |
ErrReceiptNotFound | No receipt after poll |
16. Testing and conformance
- Unit: orchestration and ranking against fakes of §7 interfaces; no network.
- Integration: local control plane + toy Provider; full discover → token → P2P → verify.
- Conformance:
conformance/seeker/runs in CI on changes toseeker/or dependentpkg/. - Shared vectors: envelope hashes from #71; must match Provider Kit byte-for-byte.
did:webvhconformance:pkg/didcarries its own conformance vectors fordid:webvhresolution and verifiable-history validation, wired into CI. Use the officialdid:webvhtest suite/vectors where one exists rather than hand-rolling cases.- Wire assertion: P2P body is the signed
{context, message, proof}envelope; theproofverifies over canonical{context, message}. - Security checks: deliberate hash regression, missing Bearer header, tampered-body / invalid-
proof, SSRF endpoint rejection, and replayed / cross-txn responses all fail CI. - Contributor flow:
go test ./seeker/...;go test ./conformance/seeker/...; CI runs both plusdepguard.
| Test area | Asserts |
|---|---|
| Discovery | Scope/tier/residency filters; ranking |
| Token Tier 1 | Token without consent_id |
| Token Tier 2 | Fails without consent; succeeds with valid test consent_id |
| Bearer | Authorization: Bearer on P2P per RFC 6750 |
| Wire body | Signed {context, message, proof} envelope on P2P |
| Envelope proof | Request carries valid EdDSA JWS proof; tampered body or bad proof rejected |
| Response binding | Rejects a signed response with a mismatched transaction_id, wrong provider_did, foreign verification_method, or stale/future timestamp/ttl (replay defense) |
| SSRF defense | Rejects non-https, loopback/link-local/RFC 1918, and redirecting Provider endpoints (ErrUnsafeEndpoint); enforces response size cap |
did:webvh | Resolution + verifiable-history vectors (official suite where available) |
| Envelope hashes | Canonical {context, message} digest matches #71 |
| Receipt verify | Verified / Unverifiable distinction |
| E2E | seeker-template + Provider template full lifecycle |
17. Phase 2 hooks (deferred)
- Tier 3 DPoP:
tokenopts/dpop.go; attach DPoP proof header on token + P2P whencnf.jktpresent. - RFC 9421 HTTP Message Signatures: optional second sanctioned request-integrity mechanism alongside the Phase 1 JWS
proof(crypto-agility; post-quantum suite migration via the DID verification method). - NANDA federation: discover via
facts_ref; Registry epic #12. - Live Aadhaar OTP consent: adopter obtains consent; mock consent for Tier 2 demo in Phase 1.
- Write / transactional scopes:
confirm,track,cancel. - Semantic discovery ranking: non-authoritative suggestion in front of deterministic Registry match.
- Receipt push / webhook: poll remains default; optional notify seam.
- Multi-hop chains: Bhashini → Provider; adopter orchestration with SDK primitives.
18. Open items to confirm
messagenesting. Flat payload asmessagedirectly vsmessage.intent— must match Provider Kit and #71 vectors.- Audit Ledger Seeker auth in Phase 1. Dev HS256 in template; production JWT profile aligned with Audit LLD.
Query()poll timeout vs push. Poll with backoff in Phase 1; webhook seam later.- Default Provider selection. Highest registry score; explicit
ProviderDIDoverride always wins. - Wrap-rule alignment. Seeker and Provider Kit must both hash the wrapped
{context, message}envelope (not the flat body) 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 this versus RFC 9421 HTTP Message Signatures as the v1 baseline, and align the exactproofshape (type,verification_method) with the Provider Kit and shared vectors (#71). - Response freshness window. Fix the allowed clock-skew bound for the response
timestamp/ttlbinding check (§8.3) and align it with the Provider Kit and receiptttlsemantics. did:webvhtest vectors. Adopt the officialdid:webvhconformance suite/vectors forpkg/didif one is published; otherwise define and maintain in-repo vectors (§16).
19. Versioning
The Seeker SDK is a library other teams import, so version discipline is part of its contract.
- Module version (SemVer). The Go module follows Semantic Versioning:
MAJOR.MINOR.PATCH.PATCHis bug fixes only,MINORadds backward-compatible API, andMAJORsignals a breaking change. Per Go module rules, av2+release lives under a/v2import path so adopters upgrade deliberately. - Public API stability. The stable surface is exactly the §8 API (
New,Register,Discover,Token,Query,VerifyReceipt,ListReceipts) plus the exported types they use. Anything underseeker/internal/...is not covered by these guarantees and may change in any release. - Protocol version. The UAI protocol version the SDK speaks is carried in envelope
context.version(§8.1) and is independent of the SDK’s own module version; one SDK line targets one protocol major version. A protocol-breaking change is aMAJORSDK bump. - Spec and schema pinning. The SDK pins the frozen JSON Schemas and shared vectors (#71) it was built against; a schema/vector change that alters behaviour is released as a new SDK version rather than silently.
- Deprecation policy. A method or field is marked deprecated (Go
// Deprecated:doc comment) for at least oneMINORrelease before removal in the nextMAJOR, with the replacement named in the deprecation note. - Reporting. The SDK exposes its version (e.g.
seeker.Version) for logs and support, and includes it in theUser-Agenton outbound control-plane calls.