usertrust

Audit Trail

SHA-256 hash-chained, append-only audit log for every governed LLM call.

Every governed LLM call produces an audit event; a call whose event could not be written says so on its receipt (see Dead-Letter Queue). Events are appended to a JSONL file where each event's SHA-256 hash covers the previous event's hash, forming a tamper-evident chain. Tamper-evident is not tamper-proof: the file can be rewritten, and Verification says what checking the chain does and does not catch.

Hash Chain Mechanics

Each audit event contains a previousHash field pointing to the hash of the preceding event. The event's own hash is computed over the deterministic canonicalization of the entire event: id, timestamp, previousHash, kind, actor, data and sequence. This creates a linear chain: modifying an event changes its hash, so its stored hash or the next event's previousHash no longer matches and verification fails there. Hiding the edit means recomputing every later hash.

Event 0: hash = SHA-256(canonical({ id, timestamp, previousHash: GENESIS_HASH, kind, actor, data, sequence }))
Event 1: hash = SHA-256(canonical({ id, timestamp, previousHash: event0.hash, kind, actor, data, sequence }))
Event 2: hash = SHA-256(canonical({ id, timestamp, previousHash: event1.hash, kind, actor, data, sequence }))

The first event chains from GENESIS_HASH -- 64 zero characters ("0000...0000").

Deterministic Canonicalization

To ensure the same event always produces the same hash regardless of property insertion order or serialization quirks, usertrust applies deterministic canonicalization before hashing:

  • Object keys are sorted recursively
  • undefined values are stripped (keys with undefined are omitted)
  • The result is JSON-stringified with no whitespace
  • NaN, Infinity, functions and symbols are rejected — canonicalize throws rather than let JSON.stringify silently coerce them to null or drop them, so an event whose data carries one fails to append and is dead-lettered
  • Date values are serialized as ISO-8601 strings; array holes and in-array undefined become null (only object-valued undefined is dropped)

This means { b: 1, a: 2 } and { a: 2, b: 1 } produce identical hashes.

AuditEvent Structure

Each event in the chain follows this shape:

interface AuditEvent {
  id: string;
  timestamp: string;
  previousHash: string;
  hash: string;
  kind: string;
  actor: string;
  data: Record<string, unknown>;
}

The exported AuditEvent type stops there, but every line the writer persists also carries a sequence field — a 1-based global counter, continuous across rotated segments, that the audit-event.v1 schema lists as required. It is part of the hashed pre-image, and the verifier reports a sequence gap when an event or a whole segment has been deleted. If any line lacks sequence, the verifier falls back to file order and skips the continuity checks — an affordance for hand-built fixtures; the writer has always emitted the field.

Events are stored as JSONL (one JSON object per line) in .usertrust/audit/events.jsonl. A sidecar file (events.jsonl.meta) tracks the last hash and sequence number. It is written and fsync'd after every append, and the verifier checks it as the chain's head anchor — a log whose tail hash or event count no longer matches the sidecar is reported as truncated or deleted, and a sidecar that is present but unparseable fails verification rather than silently disabling the check. It is not what makes appends fast: on its first append a writer reads the whole log and parses the last line, and later appends use an in-memory cache; the sidecar is only the fallback when the log is empty or that last line is unparseable.

One Writer per Vault

The audit chain has one writer per vault:

  • Within one process, clients share it. Every trust() client and createGovernor() controller in a process that opens the same vault appends through ONE writer (one async mutex, one cached tail), so concurrent governed calls from any of them land on one linked chain.
  • Across processes, the advisory file lock keeps one. A second process that opens the same vault does not get to write: its audit writes fail with an error naming the lock holder, and its events go to the dead-letter queue (below) when that write succeeds, which usertrust verify then reports. Its calls still settle: only a trust() client with audit.failClosed refuses them instead; createGovernor() and usertrust-server never enforce audit.failClosed, and report auditDegraded. Give each process its own vault (vaultBase), or run usertrust-server and have every process call it.

Chain integrity holds for the one process that owns the vault; it is not shared across processes.

Dead-Letter Queue

If an audit write fails, usertrust does not throw by default. Only a trust() client with audit.failClosed: true refuses the call instead (the default is false); createGovernor() and usertrust-server never enforce audit.failClosed. Otherwise:

  1. The failed event is written to .usertrust/dlq/dead-letters.jsonl (fsync'd, with a checksum), unless that write fails too
  2. The response is returned with an auditDegraded flag on the governance receipt (among chain appends, a failed warn-mode injection_detected append is the one exception: it is dead-lettered but leaves the receipt unmarked; a failed write of a rotated receipt under .usertrust/audit/, with audit.rotation on, is not a chain append: it is ignored, never sets the flag and does not degrade the writer, even under failClosed), and the process gets a USERTRUST_AUDIT_DEGRADED warning
  3. usertrust verify reports the vault INCOMPLETE (complete: false, exit 1) while a dead-lettered event is missing from the chain, and usertrust health reports it as critical. Both read the dead-letter file, so if the dead-letter write failed too (a full disk, say) neither sees the gap (a partial line left by a short write is read as an unreadable entry, so verify reports INCOMPLETE; only a failure that leaves no bytes is invisible); usertrust verify --receipts, given your own receipts, sees a missing primary spend record (the receipt's own llm_call or action event) only; a lost auxiliary record (a settlement shortfall, a detection, an implausible-usage or ambiguous-settlement record) is invisible to it, as to verify and health when its dead letter was not written. No command yet puts dead letters back on the chain: keep the file

With trust() and audit.failClosed: true the call is refused with AuditDegradedError instead, and whether it was charged depends on when the client's audit writer degraded. If the writer was already degraded when the call started, the call is refused at its start, before any policy check, hold, provider call or action runs (after the check that the client was not destroyed, and for an action after its cost input is validated): no hold, no spend, and nothing is charged (the error's message says so). If the writer degrades during a call, the outcome depends on how the call ran. For a non-stream call or action, if its own audit record fails, the call is aborted before any money moves: the hold is released by a void request and nothing is charged. The void is best-effort: if the ledger refuses it, the failure is swallowed and the hold stays reserved (in a shared cost-center envelope too) until it expires, up to 300 s. If its record is written and the spend settles, and a later audit write for the call fails, the call is refused after its charge posts: this includes a failed settlement_shortfall append after a capped post, even when only the .meta sidecar write failed. A retry does not undo that charge (the posted transfer is never voided); it is a new call with a new transfer id, and on the same client it is refused at its start until the client is recreated. A streamed call always settles before its record is written, so it may have been charged. Wherever the settlement POST failed, the outcome is unknown. Treat an AuditDegradedError as possibly charged unless its message says nothing was charged. A client's writer stays degraded from its first failed audit write until the client is destroyed, so every later call on it is refused at its start in this way, with nothing charged, until the client is destroyed and recreated (recreate it after fixing the audit directory). The mark is per client, so another client on the same vault is not affected. createGovernor() and usertrust-server do not enforce audit.failClosed: their receipts report auditDegraded and the call is not refused.

The dead-letter queue is a safety net, not normal operation. If events accumulate in the DLQ, investigate the underlying write failure (disk full, permissions, corrupt chain).

Rotation

Alongside the hash chain, a settled call also writes a standalone receipt file into a date-partitioned directory, and a bounded index.json points at the most recent ones; receipt files themselves are never pruned. (Streaming calls through a wrapped client are the exception — they settle without a receipt file.) audit.rotation controls whether those receipts are written at all: none disables them entirely (the hash chain itself is unaffected). weekly is accepted by the config schema but not implemented — receipts are always partitioned by UTC calendar day. Configuration options:

OptionValuesDefault
audit.rotationdaily, weekly, nonedaily
audit.indexLimitnumber10000

Rotated receipts are stored in kind-specific subdirectories under .usertrust/audit/:

.usertrust/audit/
  events.jsonl           # main hash chain (never rotated)
  events.jsonl.meta      # last hash + sequence sidecar
  index.json             # bounded receipt index
  .audit-writer.lock     # advisory lock, present while a writer is live
  llm_call/2025-01-15/   # daily-partitioned receipts: <kind>/<YYYY-MM-DD>/rcpt_<ts>_<hex>.json
  llm_call/2025-01-16/
  tool_use/2025-01-16/   # governed non-LLM actions are filed under their ActionKind

The bounded index (index.json) keeps the most recent entries up to indexLimit, evicting oldest-first.

Verification

The chain can be verified independently using either the built-in CLI or the standalone zero-dependency verifier:

# Built-in CLI
npx usertrust verify

# Standalone verifier (zero dependencies)
npx usertrust-verify .usertrust

Both tools re-compute every hash from GENESIS_HASH forward and confirm each event's hash matches the expected value. Editing an event, or deleting one from the middle of the chain, breaks it. Events are checked in sequence order, so moving lines around in the file without changing any event is not reported. Deleting the most recent events is the exception on a vault with no external anchor: if the events.jsonl.meta sidecar is deleted or rewritten to match, the vault passes (exit 0, UNANCHORED), and if it is left alone the deletion is reported only until a freshly started process next appends. (If events.jsonl is emptied entirely and the sidecar is unchanged, the next writer continues from the sidecar's old head and verification still reports the missing history.) Only an external anchor (--anchors <file> with --pubkey <pem>) or receipts you kept (usertrust verify --receipts <file> fails with INCOMPLETE) catch it (see the known issue in LIMITATIONS.md section 8).

The standalone usertrust-verify package intentionally duplicates all canonicalization and hashing logic from usertrust. It has zero dependencies — only Node built-ins (node:crypto, node:fs, node:path, plus node:https loaded lazily by the CLI for the opt-in --anchor-url fetch). This ensures the verifier cannot be compromised by a supply-chain attack on usertrust itself.