UAI Audit Ledger: Low-Level Design (LLD)
How the Audit Ledger is built — append-only storage, hash chain, checkpointing, intake pipeline, and security.
Scope. This document explains how the Audit Ledger is built. What it is and why it exists (the notary model, receipts, the seven-state lifecycle) is in the HLD. The UAI Technical Specifications and the five frozen JSON Schemas remain the contract; this LLD implements them and must not contradict them.
Consistency. The Audit Ledger shares one repository, one set of
pkg/libraries, and one architectural style with the Registry and the Trust Server. Where a mechanism already exists in those LLDs (the module boundary rules, the two-listener split, the error shape, the resolver, logging hygiene), this document points to it and describes only what differs.Status. Built for decision: the DID method is
did:webvh, supersedingdid:web, for all agents and for the UAI services’ own identities. Reviewed against industry practice before writing: tamper-evident log design (hash chains, Merkle trees, Certificate Transparency), the retirement of Amazon QLDB in July 2025 and the market’s return to plain PostgreSQL for ledger workloads, and the append-only PostgreSQL pattern (role privileges plus guard triggers plus partition-based retention). Choices marked (proposal) are open to change.
Terms used throughout. A receipt (UAIReceipt) is the hash-only, provider-signed record of one interaction. Append-only means rows can be inserted but never changed or removed. A hash chain links each stored receipt to the one before it, so any deletion, insertion, or reorder in the history is detectable. A checkpoint is a small signed statement of the chain’s head (position and hash) at a moment in time. WORM is write once, read many: storage that cannot be overwritten. JCS (RFC 8785) is the canonicalization scheme that gives JSON one exact byte form so hashes are reproducible. Idempotency means submitting the same thing twice has the same effect as submitting it once. did:webvh is UAI’s DID method (decision D15): identity anchors to a domain as with did:web, but the DID document is published as a hash-chained, signed history log (did.jsonl), and the DID embeds a self-certifying identifier (SCID) that binds it to its genesis, so key history is verifiable and cannot be silently rewritten.
Contents
- 1. Purpose and scope
- 2. Responsibilities
- 3. Module layout and boundaries
- 4. State posture and infrastructure
- 5. Domain model
- 6. Datastore
- 7. Core interfaces
- 8. API surface
- 9. Key flows
- 10. Concurrency model
- 11. Validation
- 12. Security summary
- 13. Observability
- 14. Configuration
- 15. Error model
- 16. Testing and conformance
- 17. Phase 2 hooks (deferred, seams in place)
- 18. Open items to confirm
1. Purpose and scope
The Audit Ledger is the filing cabinet of the UAI notary model. After every interaction, the Provider files a signed, hash-only receipt here. The ledger’s job is to keep that record in a form that can later prove three things: the receipt was filed, it has not been altered since, and none of its neighbours in the history have been silently removed. It is the evidence base that IGM dispute resolution will draw on in Phase 2, and the record CERT-In and DPDP obligations point at.
Two properties define the design. First, the ledger never sees a payload. The request_hash and response_hash inside a receipt are opaque to it; the ledger could not recompute them even if it wanted to, because the data they commit to never touches the control plane. Second, the ledger does not judge the transaction. A receipt with outcome.status: "error" is filed with exactly the same care as a success. The ledger witnesses; it does not referee.
This document covers the ledger’s module layout, what it stores and how immutability is enforced, its domain model, interfaces, API, core flows, concurrency, validation, security, observability, configuration, errors, and testing. The Registry and the Trust Server appear only where they touch the ledger: the Trust Server mints the token a caller presents to query receipts, and the shared did:webvh resolver verifies the signer of each incoming receipt.
2. Responsibilities
The Audit Ledger does:
- Accept receipts at
POST /audit/v1/receipts: validate against the frozenUAIReceiptschema, verify the Provider’s EdDSA signature, deduplicate on(transaction_id, message_id), and append. - Assign each accepted receipt a dense ledger sequence number and link it into a hash chain.
- Produce a signed checkpoint of the chain head on a schedule, store it, publish it, and copy it outside the database’s trust domain.
- Verify its own chain on a schedule and alert on any mismatch.
- Serve participant-scoped receipt queries with cursor pagination.
- Keep an append-only access trail: who queried what, and every admin action.
- Enforce retention floors and Indian data residency for everything it holds.
The Audit Ledger does not:
- See, store, or verify any payload. The artifact hashes in a receipt are stored as opaque strings. Recomputing them is a dispute-time activity for parties who hold the payloads, per the spec’s mismatch rule (unverifiable, not invalid). The ledger takes no position on them.
- Validate the delegation token behind a receipt.
token_hashis stored as evidence, not checked. Token validation happened offline at the Provider before the interaction; re-litigating it here would add nothing. - Call the Registry or the Trust Server during intake. There is no internal hop. The signer’s identity comes from
did:webvhresolution alone, so a receipt from a Provider that was later suspended is still admissible evidence, and a Registry outage never blocks evidence collection. - Resolve disputes. IGM is Phase 2. The ledger collects the evidence base from day one so IGM has something to stand on when it arrives.
- Mutate or delete an accepted receipt, ever, through any code path. There is no update handler, no delete handler, and no admin action that touches a stored row. Retention actions, when a policy is ratified, will be archival and partition operations, not row deletes (see §17 and §18).
3. Module layout and boundaries
The layout follows the Registry and the Trust Server exactly: one binary, internal packages other services cannot import, shared primitives from pkg/. Domain types and the port interfaces live in model/; each backend is a separate implementation behind those interfaces.
cmd/auditledger/main.go wiring + config + graceful shutdown; builds one Redis pool, loads the checkpoint signing key, injects all backends
services/auditledger/
api/
openapi.yaml REST contract for the edge API; source of truth for docs and clients
internal/
model/ domain types (Receipt, Entry, Query, Cursor, Checkpoint) + sentinel errors
+ the port interfaces: Store, ReceiptVerifier, TokenVerifier, RateLimiter, Resolver, CheckpointSink, Clock
httpapi/ edge (public) listener: routing, middleware, handlers, request/response types
adminapi/ internal/admin listener: separate mux, mTLS + service-token auth, not gateway-exposed
ingest/ the intake pipeline: parse, validate, verify, dedupe, append (ordered, fail-fast)
query/ filter building, cursor encode/decode, participant scoping
verify/ receipt signature verification: resolve signer, EdDSA over the canonical body
chain/ sequence assignment, entry-hash computation, checkpoint job, chain verification walk
audit/ append-only access and admin trail (same pattern as the Registry's audit package)
infra/ outbound infrastructure, one adapter per backend
postgres/ Store implementation (pgx) + goose migrations
redisx/ shared go-redis pool + RateLimiter and resolver Cache implementations
config/ typed config from environment
pkg/ shared, byte-identical across services (reused, not re-implemented)
did/ did:webvh resolver (D15): fetches the DID log, verifies SCID and entry hashes; SSRF-hardened (satisfies model.Resolver; uses a Cache injected by main)
ed25519x/ Ed25519 sign/verify (used for receipt verification and checkpoint signing)
jcs/ RFC 8785 canonicalization (receipt hashing and the signed body)
jwtverify/ delegation-token verification for the query endpoint (same code Providers use)
schema/ frozen JSON Schemas + loader (UAIReceipt used here)
clients/ the sanctioned cross-service REST client (the ledger consumes none in Phase 1)
Boundary rule. Same as the Registry. Nothing in services/auditledger/internal/... may import another service’s internals. The depguard rules apply unchanged: cross-service calls only through pkg/clients, and infra/ importable only by cmd/ and infra/ itself, so no handler ever names Postgres or Redis.
Dependency direction. Same as the Registry. model/ defines types, sentinel errors, and ports, and imports nothing else internal. Everything depends on model/; nothing depends back. Only main knows about infra/.
What is deliberately absent. No jwtmint (the ledger issues no tokens). No pkg/issuers and no vc/ package (the ledger checks no credentials in Phase 1). No Registry client (no internal hop). Checkpoints are signed with pkg/ed25519x directly; they are line-based statements, not JWTs, so they need no token library.
Two listeners. As in the Registry: the public API (httpapi/) and the admin API (adminapi/) are separate packages on separate listeners. The admin listener sits behind mutual TLS plus a service token and is never gateway-exposed. The admin actions are: run a chain verification, force a checkpoint now, and purge a resolver cache entry. Note what is missing: there is no admin action that modifies or removes data, because none exists in the domain.
4. State posture and infrastructure
In plain terms: the ledger is the permanent memory of the system. The Registry’s records can be re-registered and the Trust Server holds nothing at all, but a lost receipt is lost evidence. This is the one service where durability outranks every other property.
PostgreSQL, append-only, enforced in three layers. The application role holds INSERT and SELECT on the receipts table and nothing else; UPDATE, DELETE, and TRUNCATE are revoked at the grant level. Guard triggers on the table reject UPDATE and DELETE regardless of role, as defence in depth against a misused elevated session. And the application code contains no mutation path to begin with. Migrations run under a separate role. This layered pattern (privileges, then triggers, then code) is the standard industry construction for append-only tables in PostgreSQL.
Why PostgreSQL and not a purpose-built ledger database. This was re-checked against the current market rather than assumed. Amazon QLDB, the managed product built for exactly this workload, reached end of support in July 2025, and AWS’s own guidance directs ledger workloads to plain Aurora PostgreSQL. immudb and Azure SQL Ledger exist, but each adds an unfamiliar engine or a single-vendor trust root against locked decision D5/D6 (one database engine across Registry and Ledger). Blockchain-backed logs add consensus latency and multi-node infrastructure that the threat model does not require: the ledger is a centralized notary by design. The industry lesson from the QLDB retirement is that the cryptographic properties should live in the schema and the application, where they survive any vendor, not in a product. That is what §6 and §9 do.
Why triggers alone are not enough. The spec mandates append-only storage and names triggers as the mechanism. Triggers prevent casual mutation, but they do not bind order, they do not detect a removed row after the fact, and a database administrator can drop them, edit, and recreate them. Industry practice for evidence-grade logs layers three further mechanisms on top, each cheap: a hash chain that makes any historical edit detectable (§9.2), signed checkpoints copied outside the database’s trust domain so even a full rewrite is detectable against the external copy (§9.4), and a scheduled verification job so detection actually happens rather than existing in theory (§9.5). Full Merkle trees with third-party inclusion proofs, Certificate Transparency style, are the step beyond that; they are deliberately deferred (§17), for the same reason the CT ecosystem itself declined to adopt the heavier RFC 9162: the incremental benefit does not justify the complexity at this stage.
Redis holds only replaceable data. Rate-limit counters and the did:webvh resolver cache, in the shared infra/redisx pattern. What is cached is the verified outcome: the current DID document state after the log has been fetched and its SCID and entry hashes checked, so a cache hit saves both the fetch and the log verification. This matters more under did:webvh than it did under did:web, because resolution now includes a log walk, not just one document fetch. Losing Redis never loses a receipt: the resolver falls back to direct resolution and verification (slower), and the rate limiter’s failure posture is fail-open with an alert on the intake path, because refusing evidence to protect a counter would be the wrong trade. Postgres unavailable is a hard 503; there is nothing useful the ledger can do without its store.
Dev port. :8001 by convention (decision D11: ports are a dev convention, not a protocol contract).
5. Domain model
A stored entry is the frozen UAIReceipt exactly as received, plus server-managed ledger fields. Clients never set the server-managed ones. Flattened columns exist for the fields the API filters on; the receipt body itself is the evidence and is stored verbatim.
| Field | Origin | Notes |
|---|---|---|
receipt_body | client | The full UAIReceipt JSON as received, stored verbatim. Unknown-to-v1 optional fields from the evolution profile (additional_signatures, trust_server_attestation) survive intact. |
transaction_id | client | From the receipt. Half of the idempotency key. ^txn_.+$. |
message_id | client | From the receipt. Other half of the idempotency key. ^msg_.+$. |
issued_at | client | The Provider’s claimed time. Used for the API date filters. Sanity-checked for future skew at intake, never trusted for ordering. |
seeker_did, provider_did, trust_server_did | client | Flattened from participants for query filters. |
token_hash, scope, tier | client | Flattened from authorization. Stored as evidence; token_hash is not reversed or checked. |
outcome_status | client | success | error | timeout. |
seq | server | Dense, gap-free ledger position, assigned under the chain lock. The chain’s ordering authority. |
receipt_id | server | Public identifier, rct_ + ULID (proposal). Returned in the 202. |
received_at | server | The ledger’s witnessed time. Authoritative for ordering and the chain. |
receipt_hash | server | sha256:BASE64URL over the JCS form of receipt_body. The chain’s content commitment. |
prev_entry_hash | server | The previous entry’s entry_hash; the genesis value for seq 1. |
entry_hash | server | The chain link for this entry (§9.2). |
verify_status | server | verified in Phase 1 (intake rejects anything else; the column exists so Phase 2 co-signature states have a home). |
A Checkpoint is {last_seq, entry_hash, receipt_count, as_of, kid, signature}.
6. Datastore
PostgreSQL, one engine across Registry and Ledger (decision D5/D6). Filterable fields are typed columns; the receipt itself is one JSONB column. No partitioning in Phase 1: at pilot volume a plain table is simpler and faster to build, and the migration to a partitioned table later is a short copy of a small table. What ships now is the schema shape that would be expensive to retrofit: the chain columns, the dense sequence, and the checkpoint table, because those are what make later pruning and archival safe (§17).
CREATE TABLE receipts (
seq BIGINT PRIMARY KEY, -- dense, assigned under the chain lock; no SEQUENCE (sequences leak gaps on rollback)
receipt_id TEXT NOT NULL UNIQUE,
transaction_id TEXT NOT NULL,
message_id TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL, -- claimed by the Provider
received_at TIMESTAMPTZ NOT NULL, -- witnessed by the ledger
seeker_did TEXT NOT NULL,
provider_did TEXT NOT NULL,
trust_server_did TEXT NOT NULL,
token_hash TEXT NOT NULL,
scope TEXT NOT NULL,
tier SMALLINT NOT NULL CHECK (tier BETWEEN 1 AND 4),
outcome_status TEXT NOT NULL CHECK (outcome_status IN ('success','error','timeout')),
receipt_body JSONB NOT NULL,
receipt_hash TEXT NOT NULL,
prev_entry_hash TEXT NOT NULL,
entry_hash TEXT NOT NULL UNIQUE,
verify_status TEXT NOT NULL DEFAULT 'verified' CHECK (verify_status IN ('verified')),
UNIQUE (transaction_id, message_id) -- idempotency key; its b-tree also serves transaction_id lookups
) WITH (fillfactor = 100); -- rows never change; pack pages fully
-- participant queries with a date range
CREATE INDEX idx_receipts_seeker ON receipts (seeker_did, issued_at);
CREATE INDEX idx_receipts_provider ON receipts (provider_did, issued_at);
-- single-row chain head; bookkeeping, not evidence, so it may be updated
CREATE TABLE chain_head (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
last_seq BIGINT NOT NULL,
last_hash TEXT NOT NULL
);
-- signed checkpoints; append-only like receipts
CREATE TABLE checkpoints (
last_seq BIGINT PRIMARY KEY,
as_of TIMESTAMPTZ NOT NULL,
entry_hash TEXT NOT NULL,
receipt_count BIGINT NOT NULL,
kid TEXT NOT NULL,
signature TEXT NOT NULL
);
Grants and guard triggers. The application role: INSERT, SELECT on receipts and checkpoints; SELECT, UPDATE on chain_head. BEFORE UPDATE OR DELETE triggers on receipts and checkpoints raise an exception unconditionally. TRUNCATE revoked everywhere. Retention within the table, when ratified, is a governance-controlled archival operation, never a DELETE (§18).
Migrations and driver. goose, plain SQL, run on deploy and in CI before tests. jackc/pgx with pgxpool. Both identical to the Registry.
7. Core interfaces
These are the seams. Handlers depend on these, never on Postgres or Redis directly.
// store: persistence boundary
type Store interface {
Append(ctx context.Context, e *Entry) error // full chain-append transaction (§9.2); ErrDuplicate on identical resubmit (carries existing receipt_id), ErrConflict on same key different content
GetByKey(ctx context.Context, txnID, msgID string) (*Entry, error) // ErrNotFound; intake precheck
Query(ctx context.Context, q Query, cur *Cursor, limit int) (items []Entry, next *Cursor, err error)
Head(ctx context.Context) (seq int64, hash string, err error)
Walk(ctx context.Context, fromSeq int64, fn func(*Entry) error) error // chain verification scan, ordered by seq
PutCheckpoint(ctx context.Context, c *Checkpoint) error
LatestCheckpoint(ctx context.Context) (*Checkpoint, error) // ErrNotFound before the first one
}
// verify: receipt signature verification at intake
type ReceiptVerifier interface {
Verify(ctx context.Context, receipt map[string]any) error // ErrSignerMismatch, ErrSignatureInvalid, ErrUnresolvable (fetch failed or the DID log failed verification)
}
// tokenverify: query authentication (wraps pkg/jwtverify)
type TokenVerifier interface {
Verify(ctx context.Context, bearer string) (callerDID string, scopes []string, err error) // enforces aud = the ledger's DID
}
// checkpoint sink: the copy outside the database's trust domain
type CheckpointSink interface {
Publish(ctx context.Context, c *Checkpoint) error
}
RateLimiter and Resolver are the same ports as in the Registry, satisfied by infra/redisx and pkg/did.
8. API surface
8.1 POST /audit/v1/receipts (edge)
Submit a receipt. The body is a UAIReceipt. Per the frozen contract the endpoint is asynchronous and returns 202 Accepted; in this implementation the receipt is durably appended before the 202 is written, which is stronger than the contract requires and costs nothing at Phase 1 volume. The contract stays 202 so a queued intake can be introduced later without an API change (§17).
Authentication: the signature is the authentication. The endpoint requires no bearer token. Every acceptable receipt already carries an EdDSA signature that intake verifies against the signer’s published did:webvh key, which is a stronger authenticity check than any transport credential; a token would prove only who delivered the envelope, not who signed the letter. Garbage is rejected by verification, replay of an authentic receipt is neutralized by idempotency, and volume abuse is handled by per-IP and per-DID rate limits. This mirrors how Certificate Transparency logs accept submissions. Flagged as an open item to confirm (§18).
Success:
202 { "receipt_id": "rct_01J...", "status": "accepted" }
An identical resubmit (same transaction_id + message_id, same receipt_hash) returns 202 with the original receipt_id. The same key with different content returns 409. Failure map in §15.
8.2 GET /audit/v1/receipts (edge)
Query receipts. Requires Authorization: Bearer with a delegation token whose audience is the ledger itself.
Authentication and scoping. The token is verified offline with pkg/jwtverify against the Trust Server’s published keys, exactly as a Provider verifies tokens, with two hard checks: aud must equal the ledger’s DID, and scope must be read:receipts. Accepting a token minted for some other audience would be the classic JWT audience-confusion mistake; the ledger refuses it. This means the ledger is itself a registered agent and read:receipts is a Tier 1 entry in the risk policy, so a caller obtains a ledger token through the same POST /trust/v1/token flow it already knows. No new authentication machinery is invented (§18, open item 1).
Participant scoping without existence leaks. Every query is filtered to rows where the token’s sub equals seeker_did or provider_did. A caller asking about a transaction it did not participate in receives an empty page, not a 403 or 404, because whether a given transaction_id exists is itself information the ledger should not leak. Auditor and ODR access (visibility beyond own participation) is a Phase 2 role (§17).
Query parameters: transaction_id, seeker_did, provider_did, date_from, date_to (against issued_at; see §18, open item 4), cursor, page_size (default 20, max 100). The response uses the same paged envelope as Registry discovery (items, next_cursor), ordered by seq descending. Each item is the stored receipt_body plus receipt_id, seq, and received_at.
8.3 GET /audit/v1/checkpoint (edge, public)
Returns the latest signed checkpoint, both as JSON and in the line-based signed form (§9.4). Deliberately public and unauthenticated: a checkpoint is useful precisely because anyone can fetch, archive, and later compare it. Publishing it is what turns it into a commitment.
8.4 Health
/healthz (process up) and /readyz (Postgres reachable; Redis degradation is reported but does not fail readiness, per §4).
8.5 Internal/admin listener
mTLS plus service token, same posture as the Registry’s admin listener. Actions: POST /internal/v1/verify-chain (optionally {from_seq}), POST /internal/v1/checkpoint (force one now), POST /internal/v1/resolver-cache/purge {did}. Every call lands in the audit trail with the operator identity. There is no admin action that alters stored data.
9. Key flows
9.1 Intake pipeline
Ordered, fail-fast, cheapest checks first. All-or-nothing: a receipt is either fully appended or not stored at all.
- Caps. Reject bodies over
MAX_BODY_BYTES(413). Receipts are small; the cap is generous headroom, not a squeeze. - Parse and schema. JSON parse (400), then validate against the frozen
UAIReceiptschema viapkg/schema(422). The reserved evolution fields are accepted if present and stored verbatim insidereceipt_body. - Semantic sanity (422).
signature.signermust beparticipants.provider_did, optionally with a key fragment (v1 receipts are provider-signed; a receipt signed by anyone else is not a v1 receipt).artifacts.canonicalizationmust be the v1 constant.issued_atmust not be more thanISSUED_AT_MAX_SKEWin the future (a clock-sanity guard); arbitrarily old is fine, because late submission after retry backoff is legitimate under state 7 of the lifecycle. - Dedupe precheck. Look up
(transaction_id, message_id). Identicalreceipt_hash: stop and return 202 with the originalreceipt_id. Different: 409. This precheck is an optimisation; the unique constraint inside the append transaction is the real guarantee under concurrency. - Signature verification. Compute the canonical signed body: the receipt minus
signature, and minusadditional_signaturesandtrust_server_attestationif present (the evolution profile keeps those outside the canonical body). JCS-canonicalize withpkg/jcs, resolve the signer through the shared SSRF-hardeneddid:webvhresolver (fetch the DID log, verify the SCID and entry hashes, take the current verified document; cached), and verify EdDSA withpkg/ed25519x. Invalid signature against a resolved key: 422, never stored; the ledger admits no receipt whose provenance it could not check. Signer DID unresolvable right now, or its DID log fails verification: 503, retryable, also never stored; fail-closed, matching the Trust Server’s posture on its Registry hop. A failed log is treated as retryable because the honest cause is a mid-update or partially written log on the agent’s server; the Provider’s mandated retry-with-backoff absorbs it, and nothing enters the ledger either way. - Chain append (§9.2), then 202.
What intake deliberately does not do: verify request_hash or response_hash (opaque by design), reverse or validate token_hash, or consult the Registry about the Provider’s status.
9.2 Chain append
Each accepted receipt becomes one chain entry. Within a single short transaction:
SELECT last_seq, last_hash FROM chain_head FOR UPDATE(this row lock is the single-writer gate).seq = last_seq + 1. Assigningseqhere, not from aSEQUENCE, keeps it dense and gap-free, so “row missing” and “gap in seq” are the same detectable condition.received_at = now (UTC, microsecond precision).- Compute
receipt_hash = sha256 over JCS(receipt_body), then the entry preimage, newline-joined ASCII with a domain-separation prefix:
uai-ledger-entry:v1
<seq>
<received_at, RFC 3339 UTC, microseconds>
<receipt_hash>
<prev_entry_hash>
entry_hash = sha256:BASE64URL over those bytes. The prefix prevents cross-context hash reuse; the fixed line layout removes JSON formatting ambiguity from the hot path. A published test vector pins the exact byte layout (§16), and the verifier formats the stored timestamp with the same fixed rule.
INSERTthe receipt row;UPDATE chain_head. Commit. Genesis:prev_entry_hashforseq1 issha256("uai-ledger-genesis:v1").
If the unique constraint fires inside the transaction (a concurrent identical submit won the race), re-read and return 202 with the winner’s receipt_id.
What the chain buys, precisely: after the fact, deleting a row, editing a row, reordering rows, or inserting a row into the past each break either a hash link or the dense-sequence rule, and the verification walk (§9.5) surfaces it. What the chain alone does not buy: protection against truncating the tail and rewriting chain_head wholesale. That is the checkpoint’s job.
9.3 Idempotency and duplicates
The idempotency key is (transaction_id, message_id), per the spec. Identical content resubmitted: 202, same receipt_id, no new row. Same key, different content: 409; the first filed receipt wins and the conflict is logged with both hashes, because a Provider re-signing different content under the same key is either a bug or something IGM will want to see.
9.4 Checkpointing
On a schedule (CHECKPOINT_INTERVAL, default 24h, plus the admin trigger), the checkpoint job reads a consistent head, builds the line-based statement:
uai-ledger-checkpoint:v1
<last_seq>
<entry_hash>
<receipt_count>
<as_of, RFC 3339 UTC>
signs it EdDSA with the ledger’s checkpoint key (identified by kid, published in the ledger’s own did:webvh DID log, resolvable because the ledger is a registered agent), inserts it into checkpoints, exposes it at GET /audit/v1/checkpoint, and pushes a copy through CheckpointSink to storage outside the database’s trust domain: a WORM-configured object bucket in Phase 1 (proposal). The did:webvh choice pays off here specifically: because the ledger’s own key history is itself a verifiable log, a checkpoint signed years ago can be verified against the key that was valid at that time, not just against today’s key. The line-based signed-statement form follows the pattern the transparency-log community converged on (the C2SP checkpoint format) without importing that ecosystem.
The external copy is the piece that makes tamper evidence hold against the strongest insider: a database administrator can rewrite the table, the head, and the internal checkpoints together, but cannot recall the copies that already left. It also makes future retention safe: once a checkpoint precedes a range, the range can be archived and verified from the checkpoint forward (§17).
Phase 1 checkpoints are honest about their limit: they prove what the operator committed to at each point in time, and any auditor with read access can walk the chain between two checkpoints. Cryptographic consistency proofs that an outsider can check without database access require the Merkle upgrade, which is a named Phase 2 deferral (§17), for the over-engineering reason given in §4.
9.5 Chain verification
Detection has to be routine, or the chain is decoration. A scheduled job (daily, plus the admin trigger) walks from the latest verified checkpoint (or genesis) to the head, recomputing entry_hash links and the dense-sequence rule, and confirms the head matches chain_head and the newest checkpoint. Success updates a metric and a “last verified” marker. Any mismatch fires the ledger’s loudest alert and opens an incident: a broken chain in the evidence store is a reportable event, and the CERT-In 6-hour clock starts at detection. The walk is a snapshot read and never blocks intake.
9.6 Query flow
Verify the bearer (aud, scope, expiry, signature) offline, extract sub, apply the caller’s filters, intersect with the participant scope, page by seq descending with the standard cursor envelope, and write one access-audit record (caller DID, filters, result count, request id). Auditing the readers of an evidence store is as standard as auditing its writers, and it is one insert.
10. Concurrency model
Appends are single-writer by construction. The chain_head row lock serializes the append transaction. The critical section is two hashes, one insert, one update: microseconds to low milliseconds. At Phase 1 corridor volume (receipts arrive after interactions complete, asynchronously) this is nowhere near a bottleneck, and it buys a strictly ordered, gap-free chain with no coordination logic. The known ceiling and its remedy (batch sequencing, the Certificate Transparency merge pattern) are documented in §17 rather than built now.
Reads never block. Queries and the verification walk are MVCC snapshot reads. The checkpoint job holds the head lock only long enough to read two values.
Crash safety. The append is one transaction: either the row, the chain link, and the head all commit, or none do. A crash between commit and the 202 means the Provider retries and lands in the idempotent path. chain_head is derived state; if it were ever lost it is rebuilt from MAX(seq).
11. Validation
Three layers, mirroring the Registry: schema (the frozen UAIReceipt via pkg/schema, carrying the D15 DID patterns, ^did:webvh:.+$; the ledger validates with the same files the conformance suite uses), semantic sanity (signer-is-provider, canonicalization constant, tier range, future-skew guard), and cryptographic (EdDSA over the JCS canonical body, resolved through the shared hardened did:webvh resolver). Anything that fails is never stored; there is no quarantine tier in Phase 1, because the Provider retry loop already covers transient failure, and permanently invalid receipts should be fixed at the Provider, not laundered into evidence.
12. Security summary
- Immutability in depth: grants, guard triggers, no mutation code path, hash chain, external checkpoints, scheduled verification (§4, §9).
- Intake: signature-is-authentication with per-IP and per-DID rate limits; strict schema; skew guard; fail-closed on unresolvable signers; body-size cap.
- Queries: offline JWT verification pinned to EdDSA with exact
audand scope checks (the JWT best-current-practice posture); participant scoping; empty results instead of existence leaks. - Transport: TLS 1.3 only, no 1.2 fallback, on both listeners; admin listener additionally behind mTLS plus service token.
- Logging hygiene: never log signatures, token material, or receipt bodies; log DIDs,
transaction_id,receipt_id, request ids. Receipts are hash-only, but keeping bodies out of logs avoids uncontrolled copies of evidence. - Least privilege in the database: the runtime role cannot alter history even if the application is fully compromised; schema changes ride a separate migration role.
- Backups are evidence too: backup and restore procedures live in PRE-DEPLOY, and a restore is not complete until the chain verification passes against the latest external checkpoint.
- Data residency: the database, backups, checkpoint copies, and logs all remain within Indian jurisdiction (CERT-In).
- DPDP position, stated explicitly: receipt rows contain hashes and the DIDs of registered agents (organizational identities), not personal data of Data Principals. Erasure requests therefore do not reach receipt rows. This reading resolves the erasure-versus-immutability tension the gap analysis flagged, and it is listed in §18 for formal ratification rather than assumed silently.
- Assurance mapping (descriptive): the ledger provides integrity and non-repudiation controls in the sense of ISO 27001 logging controls and NIST audit-trail guidance; the receipt’s own EdDSA signature carries the non-repudiation, the ledger preserves it.
13. Observability
- Health:
/healthz,/readyz(§8.4). - Metrics (Prometheus): intake count by result (accepted, duplicate, conflict, schema_invalid, signature_invalid, unresolvable), append duration histogram, chain-verification runs and result, checkpoint age gauge with an alert above 26 hours (a stale checkpoint means the commitment stream stopped), query latency, dedupe hits, resolver cache hit/miss, DB pool stats.
- The alert that matters: a chain-verification mismatch pages immediately. It is the single signal this service exists to be able to raise.
- Logging: structured
log/slog, one line per request: method, path, status, duration, request id, DIDs andtransaction_idwhere present. - Audit trail: append-only and separate from request logs, same pattern as the Registry: every accepted receipt (by id, not content), every query (caller, filters, count), every admin action with operator identity, every checkpoint, every verification run.
- Retention and residency: access and audit logs kept at least one year within Indian jurisdiction; the CERT-In 2022 floor is 180 days and the DPDP Rules 2025 floor for security logs is one year, and the stricter figure governs, matching the Trust Server LLD. The receipts themselves are not deleted in Phase 1 at all; their retention schedule is a governance decision, not a config default (§18, open item 3).
- Incident and breach reporting: both clocks in the ops runbook, as in the Trust Server LLD: CERT-In within 6 hours of detecting a reportable incident (a verification mismatch qualifies), and the DPDP 72-hour track, noting the ledger’s minimal personal-data surface (§12).
- Time sync: NTP to
time.nic.inortime.nplindia.org, soreceived_at, the chain, and checkpoint times are consistent and admissible. - Tracing: OpenTelemetry spans on handler, DB, resolver (optional in Phase 1).
14. Configuration
Typed config from environment in cmd/auditledger/main.go:
| Var | Meaning |
|---|---|
LEDGER_EDGE_ADDR | Public listener (dev :8001). |
LEDGER_INTERNAL_ADDR | Internal/admin listener. |
LEDGER_INTERNAL_MTLS_CA / _CERT / _KEY | Mutual-TLS material for the admin listener. |
LEDGER_DID | The ledger’s own DID: the required token audience and the checkpoint kid root. |
LEDGER_PUBLIC_BASE_URL | Externally visible base URL. |
PG_DSN | Postgres DSN (runtime role: INSERT/SELECT only on evidence tables). |
REDIS_ADDR | Redis (rate limits, resolver cache). |
TRUST_SERVER_DID | Whose published keys pkg/jwtverify trusts for query tokens. |
RESOLVER_CACHE_TTL / RESOLVER_ALLOWLIST | Shared resolver settings, as in the Registry. |
CHECKPOINT_KEY_REF / CHECKPOINT_KID | Checkpoint signing key (KMS/secret manager reference) and its key id. |
CHECKPOINT_INTERVAL | Default 24h. |
CHECKPOINT_SINK | External WORM destination for checkpoint copies. |
ISSUED_AT_MAX_SKEW | Future-skew guard on issued_at (default 5m). |
MAX_BODY_BYTES | Intake body cap (default 64 KiB). |
RATE_LIMIT_* | Per-IP and per-DID limits on intake and query. |
AUDIT_SINK | Destination for the access/admin audit trail. |
LOG_RETENTION_DAYS | Access/audit log retention (default 365; §13). |
15. Error model
One JSON shape everywhere, identical to the Registry: { "error": { "code", "message", "request_id" } }. Messages never echo signatures or token material.
| Status | When |
|---|---|
| 400 | Malformed JSON; missing or unusable Authorization header on query. |
| 401 | Query token invalid: bad signature, expired, wrong aud, missing read:receipts scope. |
| 409 | Same (transaction_id, message_id) with different content. |
| 413 | Body over MAX_BODY_BYTES. |
| 422 | Schema validation failed; signer is not the participant Provider; canonicalization value wrong; issued_at beyond the future-skew guard; signature cryptographically invalid. |
| 429 | Rate limit exceeded. |
| 503 | Postgres unavailable; signer DID unresolvable at intake (retryable; the Provider’s backoff loop is the designed consumer of this status). |
The query endpoint deliberately never returns 404 for “no such transaction” or 403 for “not yours”: both collapse into an empty result page (§8.2).
16. Testing and conformance
- Unit: handlers and pipeline stages against fakes of the §7 interfaces, no network. Entry-hash and checkpoint byte layouts are pinned by published test vectors so an independent verifier can be written from this document alone.
- Integration (
testcontainers-go): real Postgres covering the append transaction, dedupe under concurrency (parallel identical submits yield one row and two 202s), the guard triggers (anUPDATE/DELETEunder the app role must fail at both the grant and the trigger layer), chain continuity across restart, and checkpoint write/read. - Tamper tests: with superuser access in the test container, edit one row, delete one row, and truncate the tail; the verification walk must catch each, and the truncation case must be caught against the external checkpoint copy. These tests are the proof that §9.2, §9.4, and §9.5 are real.
- Conformance (spec §12.4): receipt immutability after 202; query correctness on
transaction_id, participant DIDs, and date range; participant-only access control; retention and residency settings; idempotency on the key; signature validation; and receipt acceptance on both success and failure outcomes. - Cross-language vector: a receipt built and signed by the retired Python
receipt_buildermust verify byte-for-byte in the Go verifier, pinning JCS and signing behaviour across implementations, the same pattern the Registry and Trust Server use for proofs and tokens. The Python fixture predates D15 and carries did:web identifiers; resolution is faked in this test, so the vector pins the canonicalization and signature bytes independent of the DID method and stays valid across the switch. - Security tests: signer-mismatch rejection, future-skew rejection, wrong-
audand wrong-scope token rejection, existence-leak check (a non-participant querying a realtransaction_idgets an empty page, indistinguishable from a nonexistent one), resolver SSRF suite reuse, rate-limit behaviour. - Contributor flow:
make test,make test-integration, CI runs both plusdepguardandgolangci-lint, unchanged from the other services.
17. Phase 2 hooks (deferred, seams in place)
- Merkle tree and third-party proofs. Add a Merkle layer over the existing entries (the leaf hashes already exist as
entry_hashinputs) to serve inclusion and consistency proofs an outsider can verify without database access, Certificate Transparency style. The chain and checkpoints ship now precisely so this is an additive layer, not a redesign. - Checkpoint witnessing. Have one or more independent parties (another government body, or an RFC 3161 timestamping authority) countersign or timestamp checkpoints, removing the last “operator rewrote everything including the bucket” doubt. The line-based checkpoint format was chosen to make this a transport problem, not a format change.
- Partitioning, archival, and a ratified retention schedule. Monthly range partitions on
received_at; before any partition leaves the hot store, export it (JSONL plus the covering checkpoint) to WORM object storage and verify the export, then detach. The idempotency uniqueness moves to a small unpartitioned index table at that point. None of this runs until governance ratifies a retention period (§18, open item 3). - Queued intake. If receipt volume ever pressures the single-writer append, put a durable queue behind the 202 and batch-sequence entries (the CT merge pattern). The API contract already says 202 and the Provider already retries, so this is invisible to clients.
- IGM and ODR. The grievance endpoints consume this evidence base; receipts, the access trail, and checkpoints are their inputs. Nothing in Phase 1 blocks them.
- Co-signed receipts. Accept and verify
additional_signaturesandtrust_server_attestationper the evolution profile;verify_statusand the verbatimreceipt_bodyare the waiting seams. - Auditor and ODR access roles. Visibility beyond own participation, granted by governance (VC-gated at Tier 3, or an interim allowlist), behind the same
TokenVerifierport. - HA/DR. Multi-AZ Postgres, tested RTO/RPO, and restore drills that end with a chain verification against the newest external checkpoint. Of the three services, this is where DR investment lands first, because this is the one whose data cannot be regenerated.
- What is deliberately never planned: an Elasticsearch or search backend. Ledger queries are exact-match filters on typed columns; unlike Registry discovery there is no fuzziness to grow into, so no datastore-swap seam is carried here.
18. Open items to confirm
- Query authorization model. The ledger is a registered agent,
read:receiptsis a Tier 1 risk-policy entry, and query tokens are ordinary delegation tokens withaud= the ledger’s DID (§8.2). This reuses the entire existing token machinery and keeps JWT audience discipline, but it touches the risk-policy file and Registry seeding, so it needs the same internal self-ratification as #26/#32/#38/#43. - Intake authentication posture. Signature-is-authentication with rate limits (§8.1), versus additionally requiring a bearer on
POST /audit/v1/receipts. Recommendation: signature only; a bearer adds a Trust Server dependency to evidence filing and verifies less than the signature already does. - Receipt retention schedule. Phase 1 deletes nothing. The floors (CERT-In 180 days, DPDP one year for security logs) govern the operational logs, not the receipts; the receipts are dispute evidence whose lifetime should be set by governance against the IGM dispute window, and only then implemented as the §17 archival flow. Needs ratification; the earlier “180-day rolling window” wording in the Phase 1 guides should be corrected to a floor, not a purge schedule, since it currently contradicts the same document’s “stores it permanently” language.
- Date-filter semantics. The contract’s
date_from/date_toare implemented againstissued_at(the Provider’s claimed time). Confirm, or switch toreceived_at(the ledger-witnessed time). Recommendation: keepissued_atfor the API filter, since that is the time disputes will reference, while ordering and the chain stay onreceived_at. - Checkpoint sink target. A WORM-configured object bucket plus the public endpoint (§9.4). Confirm the concrete bucket and its residency, and who besides ops pulls and archives the public checkpoint.
- Library picks marked (proposal): ULID for
receipt_id, goose, pgx, testcontainers-go, and the router, aligned with whatever #18 (Registry router decision) lands on. - DPDP erasure position. Ratify explicitly that receipt rows (hashes plus organizational agent DIDs) hold no Data Principal personal data, so erasure obligations do not reach them (§12). Stating this in a decision record closes the erasure-versus-immutability question the gap analysis raised, instead of leaving it implied.