Audit chain.
SHA-256 hash chain · GENESIS-rooted · rowid-ordered · tamper-evident by construction.
Every grant check, allow or deny, becomes a row in a
cryptographically chained log. Each row's hash depends on the
previous row's, so any edit — add, remove, reorder — breaks the
chain at exactly the edited position. /usage/verify
walks the chain and reports where it broke, if anywhere.
Row shape
{
row_id: u64, // monotonic, insert-ordered
timestamp: unix_ms,
consumer_id: string,
usage_type: "grants.check.allow" | "grants.check.deny" | ...,
data_type: "obsidian_vault" | "api_keys" | ...,
path: string | null,
permission: "read" | "write" | "list" | null,
grant_id: uuid | null, // matched grant (null on deny)
agent_id: uuid | null, // Option A or B
agent_name: string | null,
agent_verified: bool, // true = Ed25519 signed (Option B)
prev_hash: sha256_hex,
row_hash: sha256_hex, // sha256(prev_hash || canonical(row))
}
Chain invariants
- Genesis. Row 1's
prev_hashis a fixed 32-byte zero string: the GENESIS marker. - Canonical form. Each row serializes to a deterministic byte sequence (sorted keys, unambiguous nulls) before hashing. Same row → same hash everywhere.
- Same-timestamp tiebreaking. Rowid ordering is authoritative when two rows share a millisecond. Prevents chain reordering on restore.
- No in-place updates. Rows never mutate. Corrections are new rows; the history is the history.
Verification
th audit verify (or GET /usage/verify)
walks the chain row by row, recomputes each hash, and confirms
the chain head.
$ th audit verify
✓ chain valid rows_verified=90 pre_chain=0 head=fd8754cb3d28…
On tamper, you'd see something like:
$ th audit verify
✗ chain broken at row 57 expected_prev=a3b1… found=5f8e…
reason: hash mismatch, likely in-place edit
Agent verification levels
Two shipping levels + one roadmap:
- Option A — claim. Client passes
X-Agent-Id/X-Agent-Nameheaders. Stored on the row butagent_verified=false. Good for attribution, not for trust. - Option B — signed. Client signs each request with an Ed25519 key whose public half is registered in the wallet. Middleware verifies;
agent_verified=true. Compromising a token no longer lets you rewrite history. - Option C — handshake. PAKE-style session key (Dragonfly / OPAQUE). Deferred until Token Holder is exposed over the network. Out-of-scope for the local-first phase.
Compliance export
GET /usage/export?format=ndjson emits the full
chain plus its verification proof in one bundle. Every row
includes its row_hash and prev_hash, so
a downstream auditor can re-verify without talking to the wallet.
/my/usage/* without admin scope — customer sees what
vendor's agents did against customer's data. Same chain, filtered
by the caller's consumer token.
What verified doesn't prove
It proves: which agent identity requested this, that the request was authentic at submission time, that the row hasn't been tampered with since.
It does not prove: that the agent is the one you think it is at the socket layer (the private key could be exfiltrated — the wallet can't detect that). That's Option C's job. Today, per-device-per-agent keys at rest are the threat model.