UAI Registry: Low-Level Design (LLD)

How the Registry is built — proof-of-control, did:webvh resolution, discovery, storage, and security.

Scope. This is the how of the Registry service. The what (its role in the system, the seven-state lifecycle, the trust model) lives in the HLD. Where the HLD says a component exists, this page says how it is built. The UAI Technical Specifications and the five 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.

DID method. Per decision D15 (July 2026), the DID method is did:webvh for all agents and for UAI’s own service identities. It supersedes did:web throughout this LLD.


1. Purpose and scope

The Registry is India’s national directory of AI agents. It does two jobs: it lets an agent register itself with proof that it controls its own identity, and it lets a Seeker discover agents by what they can do. Everything else in UAI builds on the truthfulness of this directory.

This document covers the Registry’s module layout, data model, datastore, API surface, core flows, concurrency, security, validation, observability, errors, and testing. It does not cover the Trust Server or Audit Ledger except where they touch the Registry.

2. Responsibilities

The Registry does:

  • Issue one-time challenges (nonces) for proof-of-control.
  • Register and re-register agents after verifying proof-of-control.
  • Patch a small set of mutable fields, and soft-delete (deregister) a record.
  • Manage record status (active, suspended, deregistered).
  • Verify presented credentials at registration to the agreed depth (signature and expiry; see §11).
  • Serve discovery queries with cursor pagination.
  • Serve a single record by DID (used by the Trust Server’s one internal hop).

The Registry does not:

  • See, store, or forward any agent payload.
  • Issue delegation tokens or evaluate risk policy (Trust Server).
  • Validate consent (Trust Server). It only stores the consent_endpoint pointer; consent-artifact structure and withdrawal are defined by the Trust Server / consent spec.
  • Store receipts (Audit Ledger).
  • Perform live credential revocation in Phase 1 (deferred; see §11, §12, §18).

3. Module layout

One binary, compiler-walled internals, shared primitives imported from pkg/. Domain types and the port interfaces live in model/; each backend (Postgres, Redis, the resolver) is a separate implementation behind those interfaces. Redis is shared infrastructure, so its uses sit together in one adapter package rather than under any single feature.

cmd/registry/main.go            wiring + config + graceful shutdown; builds one Redis pool and injects all backends
services/registry/
  api/
    openapi.yaml    REST contract for the edge API; source of truth for docs and clients
  internal/
    model/          domain types (Agent, Query, Cursor, Patch) + sentinel errors
                    + the port interfaces: Store, NonceStore, RateLimiter, Resolver, IssuerTrust, VCVerifier
    httpapi/        edge (public) listener: routing, middleware, handlers, request/response types
    adminapi/       internal/admin listener: separate mux, mTLS + service-token auth, not gateway-exposed
    register/       registration + re-registration, proof-of-control orchestration
    discover/       query building, cursor encode/decode
    lifecycle/      status transitions (suspend, reinstate, deregister)
    proof/          challenge issuance + signature-over-nonce verification
    vc/             VCVerifier implementation (sig + expiry now; revocation seam for P2)
    audit/          append-only audit trail for state-changing and admin actions
    infra/          outbound infrastructure, one adapter per backend
      postgres/     Store implementation (pgx) + goose migrations
      redisx/       shared go-redis pool + NonceStore, RateLimiter, Cache implementations
    config/         typed config from environment
pkg/                            shared, byte-identical across services
  did/            did:webvh resolver: log fetch + verification, SSRF-hardened (satisfies model.Resolver; uses a Cache injected by main)
  ed25519x/       Ed25519 sign/verify
  jcs/            RFC 8785 canonicalization (for proof body hashing)
  schema/         frozen JSON Schemas + loader
  issuers/        trusted-issuer list, governance-maintained (satisfies model.IssuerTrust)
  clients/        the only sanctioned cross-service REST client (generated from each service's api/openapi.yaml)

4. Boundary and wiring rules

Boundary rule. Nothing in services/registry/internal/... may import another service’s internals. Cross-service calls go through pkg/clients. This is a depguard rule in CI, so a violation fails the build rather than relying on review. A second depguard rule keeps infra/ out of the feature packages: only cmd/ and infra/ itself may import a backend, so no handler ever names Postgres or Redis.

Dependency direction. model/ defines the domain types, the sentinel errors, and the port interfaces, and imports nothing else internal. Every other internal package depends on model/, never the reverse. The implementations satisfy those interfaces from the edge: infra/postgres/, infra/redisx/, internal/vc/, and the shared pkg/did/ and pkg/issuers/. Nothing depends on infra/ except main, which builds the concrete backends and injects them. Keeping the interface away from its implementation means handlers never pull a database driver as a transitive dependency, and swapping Postgres for Elasticsearch in Phase 2 becomes adding infra/elasticsearch/ behind the same model.Store, with no handler change.

Shared Redis. Redis is not a nonce detail. In Phase 1 it backs three things: single-use nonces, per-IP and per-DID rate limiting, and the did:webvh resolver cache. So it lives in one infra/redisx package that owns a single go-redis pool and exposes each use as its own small adapter (NonceStore, RateLimiter, Cache), each satisfying a port in model/. main builds the pool once and injects it, and a fourth Redis use later is a new file in redisx, not a new home for the connection. Because pkg/ stays pure and cannot import the service’s model or infra, pkg/did declares its own tiny cache interface (Get, Set with TTL); the redisx Cache satisfies it structurally and main wires it in.

Why interfaces at the boundaries. Because Store, NonceStore, RateLimiter, Resolver, IssuerTrust, and VCVerifier are interfaces, handlers stay testable with fakes (no network in unit tests), backends are swappable without touching callers, and a contributor implementing a backend has a small, clear surface to satisfy.

Two listeners. The public edge API (httpapi/) and the internal/admin API (adminapi/) are separate packages on separate listeners. The admin listener is never exposed by the gateway and sits behind mutual TLS plus a shared service token, so the security boundary is visible in the layout, not only in routing config.

One contract, generated both ways. The edge REST surface is described once in services/registry/api/openapi.yaml. From it, oapi-codegen (proposal) generates the request/response types, the server interface the httpapi/ handlers implement, and the pkg/clients client the Trust Server uses for its one internal hop. Server and client cannot drift, and the same file is the published API documentation. CI fails if the generated code is stale.

5. Domain model

The stored record is the frozen RegistryAgentRecord plus a few server-managed fields. Clients never set the server-managed ones.

FieldOriginNotes
didclientPrimary key. ^did:webvh:.+$.
roleclientseeker | provider | both.
endpoints[]client{uri, transport:https, auth:bearer|dpop}, at least one.
capabilities[]client{id:^urn:capability:.+$, version}, at least one.
supported_scopes[]client^(read|write|admin):.+$, at least one.
risk_tier_maxclientInteger 1 to 4.
vc_refs[]clientRequired when risk_tier_max >= 3.
facts_refclientURL to NANDA AgentFacts. Patchable.
india_complianceclient{dpdp_category, data_residency}. Patchable.
consent_endpointclientRequired when role is seeker and risk_tier_max >= 2. Patchable.
statusserveractive | suspended | deregistered. Not client-settable.
versionserverMonotonic counter for optimistic locking.
etagserverVersion id, e.g. W/"<hash>". Returned in body and ETag header.
last_verifiedserverLast time VCs were checked.
registered_atserverFirst registration time.
updated_atserverLast write time.
registry_versionserverSchema/contract version the record conforms to.

6. Datastore

PostgreSQL. Scalar fields that we filter or sort on are typed columns; arrays are stored as TEXT[] or JSONB with GIN indexes for containment queries. The full record is reconstructed from columns on read.

CREATE TABLE agents (
  did               TEXT PRIMARY KEY,
  role              TEXT        NOT NULL CHECK (role IN ('seeker','provider','both')),
  risk_tier_max     SMALLINT    NOT NULL CHECK (risk_tier_max BETWEEN 1 AND 4),
  endpoints         JSONB       NOT NULL,
  capabilities      JSONB       NOT NULL,          -- array of {id, version}, kept for record retrieval
  supported_scopes  TEXT[]      NOT NULL,
  vc_refs           JSONB       NOT NULL DEFAULT '[]',
  facts_ref         TEXT,
  consent_endpoint  TEXT,
  dpdp_category     TEXT,
  data_residency    TEXT,                           -- e.g. 'IN'
  status            TEXT        NOT NULL DEFAULT 'active'
                       CHECK (status IN ('active','suspended','deregistered')),
  version           BIGINT      NOT NULL DEFAULT 1,
  etag              TEXT        NOT NULL,
  last_verified     TIMESTAMPTZ,
  registered_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- discovery filters
CREATE INDEX idx_agents_scopes    ON agents USING GIN (supported_scopes);
CREATE INDEX idx_agents_residency ON agents (data_residency);
CREATE INDEX idx_agents_tier      ON agents (risk_tier_max);

-- stable cursor sort, and discovery only ever reads active rows
CREATE INDEX idx_agents_cursor    ON agents (registered_at, did) WHERE status = 'active';

Capability matching does not use a column on this table. Because capabilities are hierarchical, they live in a child table (agent_capabilities) indexed for ancestor/descendant queries;. The capabilities JSONB above is retained only to return the full record.

Migrations. goose (proposal), plain SQL files in store/migrations/, run on deploy and in CI before tests. One engine across Registry and Ledger keeps ops simple.

Driver. jackc/pgx with pgxpool (proposal).

7. Core interfaces

These are the seams. Handlers depend on these, not on Postgres or Redis directly.

// store: persistence boundary (Postgres now; Elasticsearch possible in Phase 2)
type Store interface {
    Get(ctx context.Context, did string) (*Agent, error)              // ErrNotFound
    Upsert(ctx context.Context, a *Agent) (created bool, err error)   // register / re-register
    Patch(ctx context.Context, did string, p Patch, ifMatch string) (*Agent, error) // ErrConflict, ErrNotFound
    SetStatus(ctx context.Context, did, status string) error          // ErrNotFound
    Discover(ctx context.Context, q Query, cur *Cursor, limit int) (items []Agent, next *Cursor, err error)
}

// nonce: single-use challenge store (Redis), atomic consume
type NonceStore interface {
    Issue(ctx context.Context, did string) (nonce string, err error)
    Consume(ctx context.Context, nonce string) (did string, err error) // ErrNonceInvalid
}

// resolver: did:webvh -> current DID Document from a verified log, SSRF-hardened, cached
type Resolver interface {
    Resolve(ctx context.Context, did string) (*DIDDocument, error)    // ErrUnresolvable
    PurgeCache(did string)
}

// issuers: who counts as a trusted credential issuer, and for what
type IssuerTrust interface {
    Allowed(issuerDID, vcType string) bool
}

// vc: credential checks (signature + expiry in Phase 1)
type VCVerifier interface {
    Verify(ctx context.Context, vcRefs []string, subjectDID string, tier int) error // ErrVCInvalid
}

8. API surface

Edge listener (public, behind the gateway):

MethodPathAuthPurpose
GET/registry/v1/challenge?did={did}noneIssue a one-time nonce (5-min TTL).
POST/registry/v1/agentsproof-of-controlRegister or re-register (full record).
GET/registry/v1/agents/{did}noneFetch one record (any status).
PATCH/registry/v1/agents/{did}proof-of-control + If-MatchPatch facts_ref, india_compliance, consent_endpoint.
DELETE/registry/v1/agents/{did}proof-of-controlSoft-delete (set deregistered).
GET/registry/v1/discovernoneSearch active agents. Filters + cursor.
GET/registry/v1/capabilitiesnoneReturn the active capability taxonomy (for grounding Seeker queries).
GET/healthz, /readyznoneLiveness and readiness.
GET/metricsinternal onlyPrometheus.

Internal listener (separate port, not exposed by the gateway, mutual TLS + shared service token):

MethodPathPurpose
POST/admin/v1/agents/{did}/suspendCompliance hold / emergency kill-switch (§9.5).
POST/admin/v1/agents/{did}/reinstateLift hold.
POST/admin/v1/clear-did-cachePurge resolver cache.

8.1 Proof-of-control mechanism

To register, patch, or delete, the caller proves it holds the private key behind its DID. The body stays a pure RegistryAgentRecord (or patch); the proof rides in headers (proposal, see §18.2):

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

What is signed. The signature is over the RFC 8785 (JCS) canonical form of a small bound object, and nothing else. Ed25519 signs those bytes directly; the body is already reduced to a hash field, so the object stays small and fixed in size regardless of payload.

bound = {
  "did":         "<agent DID>",                     // subject of the write
  "htm":         "<HTTP method, uppercase>",         // POST | PATCH | DELETE
  "htu":         "<scheme>://<host><path>",          // request URI, query string dropped
  "kid":         "<verification-method fragment>",   // which key in the DID Document signed
  "nonce":       "<nonce from the challenge>",
  "body_sha256": "<base64url(sha256(JCS(body))), no padding>"
}
signature = Ed25519_sign( JCS(bound) )

htm and htu follow the same normalization the UAI DPoP verifier already uses on the Provider P2P path, so both proof styles share one rule. Binding them means a captured proof cannot be redirected to a different verb or a different record: changing the method or the path changes htm/htu and breaks the signature. Binding body_sha256 means a captured proof cannot be replayed against a modified payload. kid names the exact key used, so a DID Document that lists more than one verification method is unambiguous and key rotation is clean.

Why a canonical object, not a joined string. An earlier draft signed did + "." + nonce + "." + hash. That construction is fragile, because DIDs and URIs both contain the . separator, so two distinct field tuples can concatenate to the same string. Signing the JCS canonical form removes the ambiguity: canonicalization owns key ordering and escaping, and it reuses pkg/jcs, already byte-identical across services, so client and server reduce to exactly the same bytes.

Standards alignment and crypto-agility. This mechanism is a profile of the same properties as RFC 9421 (HTTP Message Signatures: signed method, target URI, content digest, and a key id) and it shares the replay defenses of RFC 9449 (DPoP: bound method and URI plus a single-use server nonce). The signature algorithm is not fixed in the wire format: kid resolves to a verification method in the DID Document whose type declares the algorithm (Ed25519 today). Migrating to a different suite, including a post-quantum or hybrid one later, is therefore additive (publish a new verification method, reference it by kid) with no change to the bound-object shape. Post-quantum support itself is deferred (§17); the seam is present now.

Normalization (MUST, or signatures fail spuriously).

  • htm is the exact HTTP method, uppercase.
  • htu is scheme://host + path, with the query string and fragment removed. Behind the gateway, the Registry MUST build htu from the externally visible URL (via X-Forwarded-Proto / X-Forwarded-Host, or a configured public base URL), not from the internal hop, or every proof fails.
  • The DID appears raw, not percent-encoded, in the {did} path segment, so htu for PATCH /registry/v1/agents/did:webvh:QmNmpDQCCGKZuZniWDui4JiZEyvfXhJUVWd6bLV3QaTpSd:acme.example.com ends in the literal DID. Client and server MUST agree on this.
  • kid is the fragment identifier of the verification method within the subject’s DID Document (for example key-1, resolving to <did>#key-1). The signer chooses the key; the verifier uses exactly that key and rejects the proof if kid is absent from the DID Document or is not a usable signing key.
  • Every field is a string, so JCS reduces to sorted keys, compact separators, UTF-8, and standard JSON string escaping. Keep the bound object all-strings; a numeric field would pull in full RFC 8785 number canonicalization.

No-body writes (DELETE). body_sha256 is the SHA-256 of the empty string, a fixed constant, so neither side has to guess:

body_sha256 (DELETE) = 47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU

Subject binding. For POST, the did field is the body’s did; for PATCH and DELETE it is the {did} in the path. Verification requires the did field, the path {did}, and the nonce’s bound DID to all match. Because did, htu, and kid are all inside the signed object, this equality (and the choice of key) is enforced cryptographically as well as checked in the handler: a proof minted for one record cannot be aimed at another.

Reference test vector. Pin the client and the server to these exact bytes. For a PATCH with body {"facts_ref":"https://acme.example.com/agent-facts.json"} signed with verification method key-1:

body_sha256:   jSmeB8RIAPfiEREd7zuQo_-sKxYUGxD0Ej9n6U2mfdk

signed input (JCS of the bound object; Ed25519 signs exactly these bytes):
{"body_sha256":"jSmeB8RIAPfiEREd7zuQo_-sKxYUGxD0Ej9n6U2mfdk","did":"did:webvh:QmNmpDQCCGKZuZniWDui4JiZEyvfXhJUVWd6bLV3QaTpSd:acme.example.com","htm":"PATCH","htu":"https://registry.uai.example/registry/v1/agents/did:webvh:QmNmpDQCCGKZuZniWDui4JiZEyvfXhJUVWd6bLV3QaTpSd:acme.example.com","kid":"key-1","nonce":"n_7Qx9fJ2kR4mZ0aB3cD5eF6gH8iJ1kL"}

sha256 of signed input:  BevHNdQvmAQbsHe0kJtClBXKNT1MrvRGatjY_afEBLY

JCS sorts the keys (body_sha256, did, htm, htu, kid, nonce); that ordering is produced by canonicalization, not chosen by either side.

8.2 Discovery

GET /registry/v1/discover filters (all AND-combined, all optional):

ParamMaps to
capabilitysubtree match on the capability hierarchy: agent has this capability or any descendant (§9.6)
scopesupported_scopes @> ARRAY[scope]
tier_maxrisk_tier_max <= tier_max (semantics: a ceiling; confirm, see §18)
data_residencydata_residency = value (hard filter; authoritative residency enforcement is a Trust Server policy at token time)
cursoropaque pagination token (§9.3)
page_sizedefault 20, max 100

How matching stays accurate (shared taxonomy, grounded queries, subtree matching, measurement) is specified in §9.6.

Only status = 'active' rows are ever returned. Response envelope (shipped from day one):

{
  "items": [ /* RegistryAgentRecord objects */ ],
  "next_cursor": "b64...",   // null when there are no more pages
  "page_size": 20
}

9. Key flows

9.1 Proof-of-control registration

Ordered so cheap checks and nonce burn happen before expensive network calls.

  1. Rate-limit the caller (§12).
  2. Parse the body; reject malformed JSON (400).
  3. Validate the body against the frozen JSON Schema (422 on failure).
  4. Consume the nonce atomically from Redis (401 if missing, expired, or already used). A failed attempt burns the nonce.
  5. Check the nonce’s bound DID equals the body’s did (401).
  6. Resolve did:webvh: fetch the DID log, verify it (hash chain, entry signatures, SCID), and take the current DID Document, SSRF-hardened (422 if unresolvable or the log fails verification).
  7. Verify X-UAI-Signature over the JCS bound object (§8.1) using the verification method named by kid; the signature covers method, URI, kid, nonce, and body hash (401).
  8. Check conditional rules: vc_refs present when tier >= 3; consent_endpoint present when seeker and tier >= 2 (422).
  9. Verify VCs: resolvable, signature, expiry, issuer on trusted list (422). Revocation is not checked here (deferred; §12, §18).
  10. Upsert. New DID returns 201; existing DID is a full re-registration and returns 200. Set status=active, bump version, recompute etag, stamp last_verified and updated_at. Write an audit record (§13).
sequenceDiagram
    autonumber
    participant A as Agent
    participant R as Registry
    participant DR as did:webvh Resolver
    participant DB as Postgres
    A->>R: GET /challenge?did=...
    R-->>A: nonce (5-min TTL, stored in Redis)
    A->>A: sign JCS bound object (did, htm, htu, kid, nonce, body_sha256)
    A->>R: POST /agents (body + X-UAI-Nonce + X-UAI-Signature)
    R->>R: schema validate
    R->>R: consume nonce (atomic, burns on use)
    R->>DR: resolve + verify DID log (SSRF-guarded, cached)
    DR-->>R: current DID Document (verification keys)
    R->>R: verify signature (method+URI+kid+nonce+body) + conditional rules + VCs (sig, expiry)
    R->>DB: upsert (status=active, version++, etag)
    DB-->>R: stored record
    R-->>A: 201 Created / 200 Updated (record + ETag)

9.2 did:webvh resolution (log verification and SSRF hardening)

did:webvh:{scid}:host:path maps to https://host/path/did.jsonl (or /.well-known/did.jsonl when there is no path). The file is a JSON Lines log: the full, hash-chained, signed history of the DID document, not one static snapshot. Resolution therefore has two halves: fetch safely, then verify the log.

Fetch (SSRF hardening). Because the host comes from caller input, the resolver:

  • Resolves DNS and rejects private, loopback, link-local, and unique-local ranges (and IPv4-mapped IPv6 forms of them).
  • Disables HTTP redirects.
  • Enforces a connect/read timeout and a maximum response size (DID logs grow over time, so the size cap is a real limit, not a formality).
  • Requires TLS 1.3.
  • Optionally restricts to an allowlist of domains/suffixes for high-assurance deployments.

Verify (the vh part). Per the did:webvh v1.0 spec, before trusting anything in the log the resolver checks:

  • The {scid} in the DID matches the log’s genesis entry, so the identifier is self-certifying and bound to its own history.
  • Every entry chains by hash to the previous one and carries a valid proof under the keys authorized at that point (cryptosuite eddsa-jcs-2022: Ed25519 plus JCS, which pkg/ed25519x and pkg/jcs already provide).
  • Log parameters are honored, including pre-rotation commitments where present.

The output is the current DID Document from the verified log. The verified document is cached in Redis with a TTL, and singleflight ensures many concurrent registrations for the same DID cause one upstream fetch, not many. PurgeCache (admin clear-did-cache) drops an entry when an immediate re-read is needed.

The log is served over plain HTTPS, so DNS and web PKI still control availability, but they no longer control history: the SCID and the hash-chained, signed log mean a domain takeover cannot silently rewrite key history or mint a fake past for an existing DID, and old proofs stay verifiable against the key that was valid when they were made. Two cheap operational controls still apply on government-operated domains: monitor Certificate Transparency logs (RFC 9162) for unexpected issuance, and publish CAA records to constrain which CAs may issue. D15 made did:webvh the Phase 1 method; optional witnesses and watchers on log updates are the Phase 2 extension (§17).

9.3 Cursor pagination

Sort key is (registered_at, did), which is stable and unique. The query fetches page_size + 1 rows to detect a next page:

SELECT ... FROM agents
WHERE status = 'active'
  AND (<filters>)
  AND (registered_at, did) > ($last_ts, $last_did)   -- omitted on first page
ORDER BY registered_at, did
LIMIT $page_size + 1;

The cursor is base64url of {registered_at, did}. It is opaque to clients. If a page_size + 1-th row exists, drop it and emit next_cursor from the last kept row; otherwise next_cursor is null.

9.4 Write concurrency (PATCH)

PATCH requires an If-Match: <etag> header. The update is conditional:

UPDATE agents
SET ..., version = version + 1, etag = $new_etag, updated_at = now()
WHERE did = $did AND etag = $if_match;

Zero rows updated means the record changed under the caller, returning 409 Conflict. This is optimistic locking; it needs no row locks and stays correct across replicas. Note this is a different mechanism from ETag/304 caching (deferred): here If-Match guards writes, not reads.

9.5 Status transitions

stateDiagram-v2
    [*] --> active: register (proof-of-control)
    active --> suspended: admin hold / auto on revocation (P2)
    suspended --> active: admin reinstate
    active --> deregistered: self DELETE (proof-of-control) or admin
    suspended --> deregistered: self DELETE or admin
    deregistered --> [*]: terminal (re-POST to return)
    note right of active
        Only active is returned by /discover.
        /agents/{did} returns any status, so the
        Trust Server can deny suspended/deregistered.
    end note

Emergency suspension (kill-switch). Because live credential revocation is deferred (§11, §18), suspension is the immediate control for a compromised or misbehaving agent. An admin suspend takes effect at once: /discover stops returning the agent immediately, and because GET /agents/{did} returns any status, the Trust Server denies token requests for a suspended agent on its next call. This bounds exposure without waiting for Phase 2 revocation. Coordinated revocation of already-issued (in-flight) tokens is a Trust Server concern and is a Phase 2 item (§17). Every suspend and reinstate is written to the audit trail (§13).

9.6 Discovery accuracy (taxonomy-driven matching)

This is the part of discovery most likely to silently fail, so it gets its own design. The goal: given a structured query (built by a Seeker-side model, outside the trust boundary), return the agents that can actually do the task, with few misses and little noise.

The accuracy contract. Accuracy is not a Registry feature on its own. It is a contract between three parties all mapping onto one shared artifact:

  • the Provider, declaring capabilities into a shared taxonomy at registration,
  • the Seeker’s model, mapping intent from that taxonomy into a query,
  • the Registry, matching deterministically over that taxonomy.

If all three reference the same versioned taxonomy, a query and a registration meet. If any references something else, they miss. So the taxonomy is the linchpin, and most of the accuracy work lives in its design and governance, not in the match. Two failure modes to engineer against:

  • Misses (false negatives): Seeker and Provider land on different-but-related ids, for example a leaf versus its parent, so a real match is not returned. Attacked with hierarchy (subtree matching) and aliases.
  • Noise (false positives): the taxonomy is too coarse, so providers match a task they cannot actually do. Attacked with sufficient granularity and most-specific-node registration.

Industry practice we are borrowing.

  • Hierarchical classification codes (UNSPSC for goods and services, SNOMED CT in healthcare): both sides classify against one shared tree and match at the right level. Identifiers are stable and never reused; terms are deprecated, not deleted.
  • Voice-assistant intent catalogs (Alexa skills, Google Actions): a provider registers the intents it serves, the assistant maps an utterance to a registered intent, then routes. Structurally identical to agent discovery.
  • Faceted search (e-commerce, Algolia, Elasticsearch): structured facets match deterministically; relevance and ranking are a separate layer, never mixed into the authoritative filter.
  • Synonym expansion (search-engine synonym dictionaries): queries expand through known aliases so different words for the same thing meet.
  • Golden-set precision and recall evaluation: relevance is measured against a labelled query set, not assumed.

The taxonomy (the linchpin). A hierarchical, namespaced, versioned vocabulary, machine-readable JSON in pkg/, governed by PR plus SIG review:

urn:capability:<domain>:<area>:<task>
e.g. urn:capability:agriculture:crop_health:disease_diagnosis

Each node carries:

FieldUse
idcanonical urn, stable forever, never reused
labelhuman name
descriptiongrounds the Seeker’s model, guides Providers
aliases[]synonyms (“plant disease”, “leaf spot”) for query expansion and model grounding
parenthierarchy link
examples[]example intents, for model grounding
statusactive / deprecated
versioncapability schema version

Rules: stable ids, deprecate rather than delete, one owner (SIG-Protocol), changes by PR. This is also exactly where the open ecosystem contributes new capabilities, which fits the open-source goal.

Registration enforcement (data quality at the source).

  • Reject any capability id not in the taxonomy. This forces every Provider onto the shared namespace, which is what guarantees Seekers and Providers share a vocabulary at all.
  • Reject deprecated ids after a grace window.
  • On write, materialise each capability’s hierarchical path for subtree matching (storage below).

Query construction (Seeker side, grounded). The single biggest query-side lever is that the Seeker’s model must be grounded on the taxonomy, not inventing ids. The Registry publishes the canonical set so clients and their models always map into the current vocabulary:

  • GET /registry/v1/capabilities returns the active taxonomy (id, label, description, aliases, parent, examples), cacheable, version-stamped.

The model is handed the candidate nodes (labels, descriptions, aliases, examples) and selects, rather than free-styling a urn. This turns intent resolution into a constrained pick, which is far more reliable than open generation.

Matching semantics (Registry, deterministic).

  1. Alias resolution: if a query’s capability is a known alias, resolve to its canonical id first (defensive, for clients that did not ground).
  2. Subtree match (recall): a query for capability N matches Providers registered at N or any descendant of N. A query for crop_health finds a Provider doing crop_health:disease_diagnosis. Implemented with Postgres ltree ancestor/descendant operators.
  3. Exact match (precision): a query for a leaf matches that leaf (and any descendants it has).
  4. Hard constraints never broaden: tier_max, data_residency, scope, status, role are strict filters. We never silently relax a safety or compliance constraint to manufacture a result.
  5. Zero-result handling: return an empty page plus a broaden_to hint (the parent id), so a client can choose to retry one level up. Explicit, never silent broadening.

Storage (refines §6). Capabilities live in a child table so the hierarchy indexes cleanly:

CREATE TABLE agent_capabilities (
  did           TEXT  NOT NULL REFERENCES agents(did) ON DELETE CASCADE,
  capability_id TEXT  NOT NULL,        -- the urn
  path          LTREE NOT NULL,        -- materialised hierarchy, e.g. agriculture.crop_health.disease_diagnosis
  version       TEXT,
  PRIMARY KEY (did, capability_id)
);
CREATE INDEX idx_agentcaps_path ON agent_capabilities USING GIST (path);

Discovery match (subtree plus hard filters, cursor-paginated):

SELECT a.* FROM agents a
JOIN agent_capabilities c ON c.did = a.did
WHERE a.status = 'active'
  AND c.path <@ $query_path                                  -- N or any descendant
  AND ($scope     IS NULL OR a.supported_scopes @> ARRAY[$scope])
  AND ($tier_max  IS NULL OR a.risk_tier_max <= $tier_max)
  AND ($residency IS NULL OR a.data_residency = $residency)
  AND (a.registered_at, a.did) > ($cursor_ts, $cursor_did)   -- pagination
ORDER BY a.registered_at, a.did
LIMIT $page_size + 1;

urn:capability:agriculture:crop_health:disease_diagnosis maps to the ltree label path agriculture.crop_health.disease_diagnosis. The full capabilities JSONB stays on agents for record retrieval.

Measuring accuracy (the loop that keeps it honest).

  • Golden query set in the repo: (intent text -> expected capability id -> expected provider DIDs). CI computes precision and recall on both the mapping (model side) and the match (Registry side). A regression fails the build.
  • Runtime telemetry: zero-result rate, queried-but-unknown capability ids (a direct taxonomy-gap signal), alias-hit rate, result-count distribution. These drive taxonomy and grounding improvements.
flowchart LR
    subgraph EDGE["Seeker side (outside trust boundary)"]
        NL["intent text"] --> G["model grounded on<br/>GET /capabilities"]
        G --> CAP["canonical capability id"]
    end
    subgraph REG["Registry (deterministic, auditable)"]
        CAP --> AL["alias resolve"]
        AL --> SUB["subtree match (ltree)<br/>+ hard filters (tier, residency, scope, status)"]
        SUB --> OUT["paginated results<br/>or empty + broaden_to hint"]
    end
    OUT -.telemetry.-> EVAL["golden set + zero-result / gap metrics"]

Phase split.

  • Phase 1: taxonomy file, registration enforcement, GET /capabilities, alias resolution, exact and subtree matching, golden set, basic telemetry. No ranking, no semantic search.
  • Phase 2: a non-authoritative semantic suggestion service in front (embeddings or hybrid, multilingual via Bhashini) that proposes candidate ids from messy free text; provider gap reports (“queries wanted you but missed you”); optional ranking.

9.7 Challenge issuance safety

GET /challenge?did= is unauthenticated by necessity, since the caller cannot yet prove control. Two properties keep that safe. First, it is enumeration-safe: a nonce is issued for any well-formed DID whether or not that DID is registered, so the endpoint never reveals registration status. Second, it is rate-limited per-IP and per-DID (§12) to blunt flooding and nonce-farming. Nonces are single-use and burned on a verification attempt, which stops online guessing against a captured nonce; the trade-off is that a raced nonce forces a fresh challenge, acceptable on an infrequent write path. Redis backs the nonce store, and running it highly available (§17) removes it as a single point of failure for writes.

10. Concurrency model

The Registry is request/response and I/O-bound. The HTTP server already runs each request on its own goroutine, and Postgres and Redis manage concurrent access. We do not add goroutines to “speed up” a single simple request. Concurrency is used only where it removes a real wait or a real duplicate:

  • Parallel VC resolution at registration. When a record lists several vc_refs, fetch and verify them concurrently with a bounded errgroup (small limit, per-VC timeout). This trims registration latency without unbounded fan-out.
  • Resolver singleflight. Collapses duplicate concurrent did:webvh log fetches for the same DID into one upstream call.
  • Graceful shutdown. On signal, stop accepting connections, let in-flight requests finish under a deadline, then close the DB and Redis pools.
  • Background re-verification (Phase 2 only). A single worker on a ticker re-checks VCs and flips status on revocation, with bounded concurrency. The seam exists now; the worker is out of Phase 1 scope.

What we avoid: goroutines that hold a DB connection while waiting, unbounded fan-out, and “concurrency for its own sake.”

11. Validation

Three layers, in order:

  1. Schema (structural). The body is validated against the frozen RegistryAgentRecord JSON Schema (draft 2020-12) using santhosh-tekuri/jsonschema (proposal). The same schema file feeds the conformance suite, so the Registry and the contract cannot drift. Failure is 422.
  2. Conditional (cross-field). risk_tier_max >= 3 requires at least one vc_ref and, of those, a UAIInsuranceCredential. role = seeker with risk_tier_max >= 2 requires consent_endpoint. Every declared capability must exist (and not be deprecated) in the controlled taxonomy (§9.6). Failure is 422.
  3. Business (external truth). did:webvh resolves and its log verifies (§9.2); the request signature verifies against the kid key in the DID Document (over the §8.1 bound object, so method, URI, kid, nonce, and body hash are all covered); each VC is resolvable, correctly signed, unexpired, and from a trusted issuer (revocation is deferred; see §12 and §18). Failure is 401 (proof) or 422 (credentials), per §15.

12. Security summary

  • Proof-of-control on every write, bound to kid, nonce, HTTP method, request URI, and body hash (§8.1). Profiled on RFC 9421 and sharing DPoP (RFC 9449) replay defenses.
  • Nonces: 32 bytes from crypto/rand, 5-min TTL, single-use via atomic Redis GETDEL. Burned on any failed attempt. Challenge issuance is enumeration-safe and rate-limited (§9.7).
  • Revocation deferral (ratified, compensated). Live VC revocation is not checked in Phase 1 (§18). The residual risk is a window between a credential being revoked and the agent ceasing to be trusted. It is bounded and compensated by: only vetted issuers on the trusted-issuer list; VC expiry checked at registration, so issuers can cap the window with short-lived credentials for high tiers; the admin kill-switch (§9.5) for known-bad agents; and the Phase 2 status-list check at the Trust Server at token time (§17), which is the eventual backstop. The re-verification worker seam exists now (§10).
  • Crypto-agility. The signature algorithm is carried by the kid-referenced verification method, not hard-coded in the wire format, so an algorithm migration is additive. Post-quantum / hybrid suites are deferred (§17); the seam is present.
  • SSRF: did:webvh resolution restricted as in §9.2, with Certificate Transparency monitoring and CAA records on government domains.
  • DID history integrity: the did:webvh SCID plus the hash-chained, signed log mean a domain takeover cannot rewrite key history or mint a fake past for an existing DID (§9.2).
  • Rate limiting: per-IP on all edge routes, plus a tighter per-DID limit on challenge and agents writes. Redis-backed so limits hold across replicas (proposal: sliding window via Lua).
  • Admin identity: the internal/admin listener runs over mutual TLS; each admin call carries an operator identity recorded in the audit trail (§13) for attribution. The shared service token remains a coarse gate but is not the identity of record. Four-eyes / separation of duties on destructive actions is a Phase 2 item (§17).
  • Secrets: the internal service token and any resolver-allowlist secrets are held in a secret manager / KMS and rotated; never baked into images or logged.
  • Trust bootstrapping: the trusted-issuer list is seeded by a governed genesis step (issuer keys generated offline, approved m-of-n, fingerprints published, chaining to India’s CCA PKI where an issuer has one). Changes are by PR plus SIG review, so the root of the list is auditable rather than implicit.
  • Transport: TLS 1.3 only, no 1.2 fallback.
  • Logging hygiene: never log signatures, raw bodies, or tokens; log DIDs, kid, and request ids.
  • Input caps: max body size, max array lengths, max vc_refs count.
  • Assurance mapping (descriptive, not a certification claim): risk tiers map roughly onto NIST SP 800-63 assurance, tiers 1 and 2 to lower authenticator assurance and tiers 3 and 4 (VCs plus DPoP-bound tokens at the Trust Server) toward AAL2/AAL3 with federation (FAL). Offered as a shared yardstick for reviewers.

13. Observability

  • Health: /healthz (process up) and /readyz (Postgres and Redis reachable). The gateway routes on /readyz.
  • Metrics (Prometheus): registration count by result, discovery latency histogram, nonce issue/consume and hit/miss, resolver cache hit/miss and upstream latency, DB pool in-use/idle, per-endpoint request duration and status codes.
  • Logging: structured log/slog, one line per request with method, path, status, duration, request id, and DID where present.
  • Audit trail: separate from operational request logs, an append-only audit record is written for every state-changing and admin action (register, re-register, patch, deregister, suspend, reinstate, clear-did-cache). Each record carries the actor (agent DID or operator identity), kid where a proof was involved, request id, HTTP method and URI, outcome, and timestamp. It never contains signatures, raw bodies, or tokens. This is the record used for dispute and forensic reconstruction.
  • Retention and residency: audit and access logs are retained for at least 180 days within Indian jurisdiction, per the CERT-In 2022 Directions.
  • Time sync: all nodes synchronise to time.nic.in (NIC) or time.nplindia.org (NPL), so log and nonce timestamps are consistent and admissible.
  • Tracing: OpenTelemetry spans on handler, DB, Redis, and resolver calls (optional in Phase 1).

14. Configuration

Typed config loaded from environment in cmd/registry/main.go:

VarMeaning
REGISTRY_EDGE_ADDRPublic listener address (dev :8000).
REGISTRY_INTERNAL_ADDRInternal/admin listener address.
REGISTRY_INTERNAL_MTLS_CA / _CERT / _KEYMutual-TLS material for the internal/admin listener.
REGISTRY_PUBLIC_BASE_URLExternally visible base URL used to reconstruct htu for proof verification (§8.1).
PG_DSNPostgres DSN.
REDIS_ADDRRedis address.
NONCE_TTLChallenge lifetime (default 5m).
RESOLVER_CACHE_TTLVerified DID document cache lifetime.
RESOLVER_ALLOWLISTOptional domain allowlist for resolution.
INTERNAL_SERVICE_TOKENShared token for the internal listener (from KMS/secret manager).
RATE_LIMIT_*Per-IP and per-DID limits.
MAX_BODY_BYTESRequest body cap.
AUDIT_SINKDestination for the append-only audit trail.
LOG_RETENTION_DAYSAudit/access log retention (default 180, CERT-In).
NTP_SERVERSTime sources (default time.nic.in, time.nplindia.org).

15. Error model

One JSON shape everywhere: { "error": { "code": "...", "message": "...", "request_id": "..." } }. Messages never echo secrets.

StatusWhen
400Malformed JSON; invalid DID format; missing nonce header.
401Invalid or replayed nonce; bad signature; DID resolution failed for proof; kid absent from the DID Document or not a valid signing key; htm/htu in the signed proof does not match the request.
404Agent not found (GET/PATCH/DELETE on unknown DID).
409If-Match mismatch on PATCH (concurrent update).
422Schema validation failed; conditional rule failed; field not patchable; VC invalid.
429Rate limit exceeded.
503Postgres or Redis unavailable (also reflected by /readyz).

16. Testing and conformance

  • Unit: handlers and logic tested against fakes of the §7 interfaces. No network.
  • Integration: real Postgres and Redis via testcontainers-go (proposal); covers migrations, upsert, optimistic locking, cursor paging, nonce single-use, and subtree capability matching (ltree).
  • Conformance: the frozen schema suite runs in CI as a gate; the Registry validates with the same schema files, so passing tests means contract-conformant.
  • Discovery accuracy: the golden query set (§9.6) runs in CI to compute precision and recall on matching; a regression fails the build.
  • Security checks: SSRF resolver tests (private ranges, redirects, oversize, timeout), did:webvh log verification tests (broken hash chain, bad entry signature, SCID mismatch are all rejected), nonce replay, proof-body-mismatch, proof method/URI-mismatch (a proof for one verb or path is rejected on another), and a wrong/absent kid rejection. The §8.1 reference test vector is asserted as a cross-language unit test so client and server canonicalization cannot drift.
  • Contributor flow: make test runs unit; make test-integration spins containers; CI runs both plus depguard and golangci-lint.

17. Phase 2 hooks (deferred)

  • Revocation: Bitstring Status List check at the Trust Server at token time; the Registry already stores issuer and credential index, and the re-verification worker seam exists (§10).
  • did:webvh witnesses and watchers: optional independent parties that co-sign or monitor DID log updates, usable as an extra gate on Tier 3/4 changes; the base did:webvh method itself is already Phase 1 (D15).
  • Federated issuer trust: replace the static trusted-issuer list with OpenID Federation entity statements / trust chains, for dynamic, revocable, multi-level issuer trust as the ecosystem grows.
  • Separation of duties: four-eyes approval on suspend/deregister and per-admin HSM-backed certificates on the internal listener.
  • Post-quantum / hybrid signatures: migrate the signature suite via the kid/verification-method seam already in §8.1.
  • In-flight token revocation: on suspension, coordinate revocation of already-issued tokens with the Trust Server (kill-switch completeness).
  • High availability / DR: multi-region Postgres, clustered Redis, tested RTO/RPO, so neither datastore is a single point of failure.
  • Trusted-issuer list growth: from a small static list to a governed, signed source (subsumed by federated issuer trust above).
  • ETag/304: add If-None-Match and 304 on GET; the etag field already exists.
  • Semantic capability suggestion: a non-authoritative service in front of discovery (embeddings or hybrid, multilingual via Bhashini) that proposes candidate capability ids from messy free text; the Registry still matches deterministically on top (§9.6).
  • Datastore swap: Elasticsearch behind the same Store interface if capability search grows fuzzy or agent counts reach five figures.
  • NANDA federation: resolve facts_ref outward, verifying the AgentFacts VC signature and issuer at ingestion (not merely dereferencing the URL); additive, must not block UAI-local operation. Pin a specific AgentFacts schema version.

18. Open items to confirm

  1. tier_max semantics. Treated here as a ceiling (risk_tier_max <= tier_max). Confirm this is the intended reading, versus “at least this tier.”
  2. Proof transport. Headers (X-UAI-Nonce, X-UAI-Signature) keep the body a pure record. Confirm, or prefer a {record, proof} body envelope. Either way, the signed input is the JCS bound object of §8.1; the transport choice only affects where the nonce and signature travel, not what is signed.
  3. Router and library picks marked (proposal): pgx, go-redis, goose, santhosh-tekuri/jsonschema, testcontainers-go, and the router (stdlib net/http ServeMux vs chi).
  4. status in the frozen schema. It is a server-managed addition; SIG-Protocol should ratify it into the freeze as server-assigned.
  5. Re-registration semantics. Confirm a re-POST preserves registered_at and only resets the mutable parts, rather than treating the agent as brand new.
  6. Controlled capability taxonomy (§9.6). This commits us to a governed hierarchical vocabulary that registrations are validated against, rather than the open ^urn:capability:.+$ pattern. It is the foundation of discovery accuracy and, like the VC depth decision, deserves its own ratified decision record.
  7. Revocation deferral (ratified). Live VC revocation is out of Phase 1 by decision, with the compensating controls in §12 and the status-list backstop moving to the Trust Server in Phase 2 (§17). This is a risk-accepted decision for the initial closed rollout; external onboarding of regulated data should revisit it before go-live.
  8. Go implementation of the did:webvh verifier (D15). No mature Go library is known as of the decision, so the plan is to implement log verification in pkg/did against the v1.0 spec and its test suite, with the Rust implementation as a reference. Confirm this before the build starts.