Ledger and audit
This page explains what the audit ledger records for every request, why tampering with it can be detected, and how you check it yourself.
What is recorded
For every request the enforcement hook writes a row to the ledger before it answers. If the row cannot be written, the request is refused. Allowed and denied requests are both recorded.
The ledger never stores prompt or completion text. It stores a SHA-256 hash of each, and token counts. Heliast does not read your prompts or completions.
Row fields
| Field | Meaning |
|---|---|
seq | Position in the chain. |
event_id, request_id | Identifiers for the row and the request. |
occurred_at | Time, in UTC. |
subject_id, subject_groups, virtual_key_id | Who asked, their groups, and the key they used. |
action | inference, retrieval or policy_decision. |
model_id, model_digest | The model, and its digest from your policy config. |
model_class | admitted, assessed, registered, or null. |
policy_revision | The policy revision that decided the request. |
decision, deny_reason | allow or deny, and the reason for a denial. |
prompt_tokens, completion_tokens, latency_ms | Usage figures. |
prompt_hash, completion_hash | SHA-256 of the text, never the text. |
chain_payload, prev_hash, row_hash | The chain fields, below. |
The tool_call action is reserved and not written today.
Rows written before model_class existed have no class. A row with no class does not claim the model was admitted. Treat a null in a newer row as a stated fact: no record was resolved.
How the chain detects tampering
Each row stores the exact canonical JSON of its content (chain_payload). Its row_hash is the SHA-256 of that text joined with the previous row's hash, in hex. The first row's previous hash is 64 zeros.
Changing any row changes its hash, which no longer matches the next row's stored previous hash. The chain breaks at that row.
The database enforces the rest:
- the writer role can insert and read, and nothing else;
- updates, deletes and truncation raise an error;
- inserts are serialised so the chain cannot fork.
The external anchor
A chain alone does not stop someone with full database-owner rights from disabling these protections and recomputing every hash. So the current head, its sequence number and hash, is periodically signed and exported outside the ledger, to a destination you control. A database owner can rewrite the chain, but cannot rewrite a signed anchor held outside it.
A limit you should know about: an anchor vouches only for rows up to its own sequence number. Rows removed after the latest anchor, with that anchor and everything before it intact, are not detectable from that anchor alone. Shorter anchor intervals narrow the window.
Verify it yourself
The audit-verify tool reads the database directly, so you do not have to trust the console. It walks every row, checks each against the one before, checks that the stored columns match the stored payload, checks the chain head, and checks the latest anchor against a public key you pin. It prints a JSON result, names the first divergence, and exits non-zero if there is one, so it can run on a schedule and alert.
audit-verify -anchor-dir <dir> -require-anchor -anchor-public-key <hex>
It needs SOVEREIGN_DB_DSN (or SOVEREIGN_AUDIT_DSN) set to a database connection string. The anchor directory can also be set in AUDIT_ANCHOR_DIR, and the pinned key in SOVEREIGN_ANCHOR_PUBLIC_KEY. The tool never trusts the key embedded in an anchor file. It uses only the one you pinned. Get the public key you should pin from your Heliast contact.
The same checks are exposed as SQL functions in the ledger database: audit_verify_chain(), audit_verify_state(), audit_head() and audit_verify_anchor().
Find requests that were allowed but never answered
The ledger writes the decision before the model is called, and the response after. If the response never arrives, nothing else shows it: the enforcement hook or an adapter stopped after the allow, a stream was cut, or the report was refused. A scheduled job lists allowed requests older than a grace period that have no response row.
The reference deployment runs it every 15 minutes from the audit image, as auditd reconcile. It prints the count and a sample of the oldest, with request ids, model ids and subject ids, and never a prompt or an answer. It exits non-zero when the count is above a threshold you set (default 0), so the Kubernetes Job fails and the failed Job is what you alert on.
Some are legitimate: an upstream error, or a client that gave up. Set the threshold to a count you can explain, and look at any that stay above it. The settings are audit.reconcile.schedule, olderThan, max and sample in the chart values, and audit.reconcile.enabled turns it off.
Query it in the console
The console's audit view reads the ledger through the audit query interface.
- Filter by subject, decision (all, allow, deny), action, and row limit, then run the query. A filter does nothing until you run it.
- Rows show time, subject, action, decision, model, class, tokens and latency. Open a row for the request id, key, groups, digest, policy revision and hashes.
- Use Verify chain to check the whole chain and the latest anchor. It reports "Chain intact", or the first divergence.
The Budgets and Denials views read the same ledger.
Retention
The reference deployment sets ledger retention to 2555 days, seven years. You can configure this to your own policy. A hash-chained ledger cannot drop old rows silently, so ask your Heliast contact how pruning past your retention period is meant to work, and plan the anchors around it.
Limits and what this does not cover
- The row has no environment column. Whether the ledger shows which environment served a call is to confirm with your Heliast contact.
- The ledger shows that a request was decided and how. It does not show what was said.
- A fail-closed design means a ledger outage is a service outage.
- Which party signs the anchor, and how the anchor key reaches you, is to confirm with your Heliast contact.