[AVAILABLE].
packages/api/src/auth/CallerAuditSink.ts, SupabaseCallerAuditSink.ts,
packages/crypto/src/AuditEventCrypto.ts, CallerAuditChainVerifier.ts. See docs/CLAIMS.md
§2.32 and Objections and Evidence (Domain 3) for
the full evidence list this page draws from.What it is
Every event at the caller-authentication layer — a successful authentication, a rejection, a denied capability or principal, a non-human credential hitting a governance endpoint, or a structurally malformed request — produces aCallerAuditEvent, written through a
CallerAuditSink. This is a separate trail from Execution Trust
Records and Refusal Records:
those two answer “was this specific business action approved or rejected,” this one answers
“who tried to talk to this API at all, and what happened.” See
Authentication for the layer this audits.
Two implementations, one deliberate split
InMemoryCallerAuditSink—NODE_ENV=testonly. Process-local, unsigned, unchained.SupabaseCallerAuditSink— every other environment. Fails closed at startup (assertDatabaseUrlConfigured) ifDATABASE_URLisn’t configured — there is no silent fallback to the in-memory sink outside tests. Every event is signed (AuditEventCrypto, sameDEFAULT_KEY_IDsigning stack as every other artifact this system produces) before being written, and, since the per-caller chaining milestone below, chained per caller as well.
Per-caller tamper-evident chaining
A signature proves a surviving row wasn’t edited. It says nothing about a row that’s simply gone — andcaller.authenticated fires on every authenticated request to every route, making
caller_audit_events the highest-write-volume table in this system. A single global hash
chain (each row signing over the previous row’s hash) would need a lock serializing every
write through one predecessor lookup — a real bottleneck on the busiest table.
Instead, each caller gets an independent chain:
SupabaseCallerAuditSink.record(), when the event carries a callerId, opens a transaction
and takes a Postgres advisory lock scoped to hashtext(callerId) — this serializes only that
caller’s own concurrent writes, never a different caller’s. It reads that caller’s most recent
chain_hash/chain_position (ORDER BY id DESC LIMIT 1), folds previousChainHash/
chainPosition into the exact object AuditEventCrypto already signs — the existing
signature_json column covers the chain link too, no second signature column — and computes
chainHash via TrustRecordHasher, the same idiom RuntimeEngine uses for
policyContentHash/signalsHash.
Events with no callerId (the earliest possible rejection — malformed JSON or an oversized
body, rejected before caller-auth middleware or any route handler runs — and
caller.rejected, no caller identified) get NULL chain fields: there’s no per-caller chain
to link them into, the same “absent means not covered” discipline every other optional column
on this table follows.
Verifying a chain, standalone
ExecutionTrustRecord, extended here to the caller-audit trail.
Verifying one event over HTTP, without a caller credential
POST /audit/verify — takes { event, signature } directly (not a lookup by id) and
returns { valid }. Deliberately mounted ahead of caller-auth middleware
(packages/api/src/app.ts), mirroring POST /refusal/verify’s exact reasoning (Refusal
Records): no API key needed, nothing but the artifact and
Parmana’s public key. This is what makes a stored row independently third-party verifiable by
whoever received it, not only by Parmana. See Error catalog
for this route’s error shapes.
Related
- Refusal Records — the policy-REJECT-path evidence trail;
POST /audit/verifyandPOST /refusal/verifyshare the same open-verification design. - Execution Trust Records — the APPROVE-path counterpart,
with its own, longer-standing
previousChainHash/chainHashchaining (ExecutionChainCrypto) that the per-caller chain above follows the same idiom of. - Authentication — the layer this trail audits.
- Objections and Evidence — Domain 3, for the full objection-by-objection evidence this page’s claims are drawn from.