Audit
Hash-chained events, watermarked exports, and an integrity check that tells you exactly where a chain breaks.
Every state change writes an audit event. Events are hash-chained, so an event cannot be altered or removed without breaking the chain — and the break is detectable, at a specific position.
This is the difference between a log and an audit trail. A log is something you read. An audit trail is something you can prove.
#The chain
Each event's hash covers its own content and the hash of the event before it,
keyed with VEEFORE_AUDIT_CHAIN_SECRET.
textevent n-1 → hash(h(n-2) ‖ content(n-1)) → h(n-1)
event n → hash(h(n-1) ‖ content(n)) → h(n)
Change the content of event n-1 and h(n-1) no longer matches, so event n's
recorded previous-hash is wrong, and so is every event after it. Delete an event
and the same thing happens. There is no way to tamper with one event in
isolation.
Described in text
Each event hashes its own content together with the previous hash, keyed with
VEEFORE_AUDIT_CHAIN_SECRET. Altering event n−1 changes its hash, so event
n carries the wrong previous hash — and so does everything after it.
Verification reports the first position where the recomputed hash diverges,
which narrows an investigation to a point in time.
bashveefore audit verify
Exits non-zero if the chain is broken, and names the first position where the recomputed hash diverges. Over the API:
httpPOST /v1/audit-events/verify-integrity
Requires audit:verify_integrity, which SECURITY_ADMIN and AUDITOR hold.
Run it on a schedule, not only when you suspect something — a check you only run
after an incident tells you nothing about when the problem started.
What the chain does and does not prove
It proves integrity: that the stored sequence has not been altered since it was written.
It does not prove completeness against a world outside the database. An attacker with write access to PostgreSQL and the chain secret could in principle rewrite the whole chain consistently. Mitigate it the normal way: keep the chain secret out of the database, restrict database write access, and ship events to an append-only sink you control separately. The chain's job is to make quiet, surgical tampering impossible — which is the realistic threat.
#Reading events
bashveefore audit list --severity CRITICAL --from 2026-10-01T00:00:00Z
veefore audit show evt_01ABC
or:
httpGET /v1/audit-events?severity=CRITICAL&cursor=…&limit=50
GET /v1/audit-events/{eventId}
The detail view includes the event's position in the hash chain, so you can tie a specific event to a specific verified range.
Severities: INFO, NOTICE, WARNING, CRITICAL.
CRITICAL is reserved for things a security team must see: agent revocation,
emergency controls, break-glass access, reducing enforcement, changing audit
retention. If CRITICAL is noisy, something is miscategorised.
The full event type registry is in Event types — generated, so it lists what the system actually emits.
#Exporting
bashveefore audit export \
--format csv \
--reason "Quarterly SOC 2 evidence" \
--out audit-2026-q3.csv
Three things happen that are worth knowing about:
- A reason is required. It is recorded.
- The export is itself an audit event. Taking evidence out of the system is a thing worth knowing happened.
- The export is watermarked and hashed. You get a content hash back; an auditor can recompute it to prove the file they received is the file that was produced.
The file is written at mode 0600. audit:export is held by SECURITY_ADMIN
and AUDITOR; DEVELOPER has audit:read but not audit:export, because
reading the trail and removing a copy of it are different privileges.
#What gets recorded
For a protected action, the audit event and the action record together carry:
- The actor — agent, user, API key or system — and which one
- The resolved authority and the delegation chain
- Every policy consulted, with versions, and every rule that matched
- Every risk signal that fired, with its weight and explanation
- The approval, if any, and who decided it
- The credential lease by reference, never by value
- The downstream response status
- The final outcome
request_idandtrace_idfor correlation
Never the secret
Audit events carry credential ids, not credential material. Masked parameter
paths are masked in audit too, so a MASK decision redacts the value in the
trail as well as in the forwarded request. The redaction layer is applied on the
way in, not on the way out, so there is no code path where a secret is written
and later filtered.
#Retention
Configurable, and changing it requires audit:configure_retention, which is
step-up gated and writes a CRITICAL event.
Shortening retention destroys evidence. Treating it as a high-impact operation means a request to "clean up old audit data" leaves a mark explaining who asked and why.
#Correlation
Every API response carries x-request-id and x-trace-id. Both appear on the
action record, on the audit event, and in the structured logs.
Given a request_id from a user complaint you can find the action, its full
lineage, the audit event, and every log line the request produced. That is the
intended debugging path and it is why the ids are on the response rather than
only in the logs.