Audit Trail (hash-chain journal)
Engrava can record changes to your thought-graph in an append-only,
hash-linked journal — a tamper-evident thought/edge journal (not a
whole-database audit). Each entry captures one mutation — insert / update /
delete of a thought or edge, or an action status/verification_status
transition — as a before/after delta, cryptographically chained to the previous
entry with SHA-256. See What gets recorded for the exact
scope (embeddings and action creation are not covered).
Read the Security model before relying on this for compliance. The chain detects covered-field changes and broken linkage among retained rows, including an unrecomputed middle deletion or reordering — not deletion of a self-consistent suffix or of every entry — and it is a keyless chain stored in the same database file — see the boundary below.
Enabling the journal
Journaling is off by default (zero overhead when disabled — the
journal_entry table exists but is never written to). Turn it on either via
configuration or the constructor.
In engrava.yaml:
database:
path: "./engrava.db"
journal:
enabled: true
from engrava import SqliteEngravaCore
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
assert store.journal is not None # journaling is active
Or when constructing the store directly:
import aiosqlite
from engrava import SqliteEngravaCore
# Abbreviated for brevity: a bare `async with` here can let a close failure
# mask a real error — see the manual-connection example in Deployment.
async with aiosqlite.connect("engrava.db") as conn:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn, journal_enabled=True)
await store.ensure_schema()
store.journal returns the JournalWriter when journaling is enabled, or
None when it is off — so a quick if store.journal is not None: guards any
journal-specific code.
What gets recorded
When journaling is enabled, the store records journal entries for thought and
edge mutations and for action state-transitions, without you calling the
journal yourself. The recorded mutation_type values are:
mutation_type |
When |
|---|---|
INSERT_THOUGHT |
create_thought() |
UPDATE_THOUGHT |
update_thought() |
DELETE_THOUGHT |
delete_thought() (only when a row was actually deleted) |
INSERT_EDGE |
create_edge() |
UPDATE_EDGE |
update_edge() |
DELETE_EDGE |
delete_edge() (only when a row was actually deleted) |
UPDATE_ACTION |
update_action() — only when an action’s status / verification_status actually changes |
The first six values are members of the
MutationTypeenum;UPDATE_ACTIONis a storedmutation_typestring emitted byupdate_action()— the enum itself is not extended.mutation_typeis a free-text column, so verification covers the entry regardless.
An
UPDATE_THOUGHTentry does not always meanupdate_thought()was called. A deduplicatingcreate_thought(..., deduplicate=True)call that matches an existing thought by content hash bumpsconfirmation_countinstead of inserting, and recordsUPDATE_THOUGHTfor that update — notINSERT_THOUGHT. A terminalcreate_action()and an outcome-affectingupdate_action()each recompute the source thought’saction_outcome_scoreand record their ownUPDATE_THOUGHTentry for that thought, alongside whatever entry the action call itself produces.
Each entry’s delta is a {"before": ..., "after": ...} dictionary: inserts
have before: null, deletes have after: null, and updates carry both sides.
Recorded precisely — what the chain does and does not cover. The journal covers thought and edge mutations (insert / update / delete) and action state-transitions (
update_action). It does not record: embeddings (store_embedding), action creation (create_action— only an action’s later transitions are journaled, not its initial insert), or read-derived access telemetry (access_count/last_accessed_at, which is regenerable and deliberately outside the chain). So this is a tamper-evident thought/edge/action-transition journal, not a whole-database audit — verify the parts it covers, and do not assume coverage of the embedding or action-creation tables. This also matters for backups — see Backup note.
TTL expiry is recorded. cleanup_expired() (and the auto-cleanup it
triggers) goes through the same journaled paths, so expiry of a thought is
captured according to the configured TTL strategy:
- archive strategy → an
UPDATE_THOUGHTentry (the thought’slifecycle_statusflips toARCHIVEDandexpires_atis cleared; the delta carries the before/after). - delete strategy → a
DELETE_THOUGHTentry (after: null).
(The separate engrava gc CLI command, which physically purges already-archived
rows, builds its own connection. It appends a DELETE_THOUGHT entry for each row
it deletes, in the same transaction as the delete, when --config names an
engrava.yaml with journal.enabled: true. Without --config, or with
journaling disabled there, it journals nothing. The TTL pass of gc --expired
is journaled the same way, through the store it builds for that pass.)
Deleting a thought also removes its incident edges, embeddings, and actions
and, when the store is using the sqlite-vec backend, purges the matching
vector-index row. When deletion goes through a journaled store
path, the only entry for that operation is DELETE_THOUGHT: the removed edges
do not receive DELETE_EDGE entries, and the removed embeddings, actions, and
vector-index rows receive no separate journal entry. Embedding/vector mutations
and action creation/deletion are outside journal coverage generally; only
action status and verification transitions are covered. Call delete_edge()
explicitly when an individually journaled edge deletion is required.
Below core schema 12 there is no database cascade. The ON DELETE CASCADE on
edge, embedding and action arrives with the core-12 migration.
delete_thought does not rely on it: it issues its own deletes for the thought’s
rows in those three tables, in the same savepoint as the parent delete, which runs
first. See
Deletion on a database that has not been migrated
for what engrava migrate cleans up on a database that already holds dangling
embedding rows.
Querying history
Use store.journal.get_entries(...) to read the trail. All filters are
optional; results are ordered by sequence_number ascending.
# Everything that ever happened to one thought:
history = await store.journal.get_entries(target_id="thought-001")
for entry in history:
print(entry.sequence_number, entry.mutation_type, entry.created_at)
# Only deletions, since a timestamp, capped:
deletions = await store.journal.get_entries(
mutation_type="DELETE_THOUGHT",
since="2026-01-01T00:00:00+00:00",
limit=500,
)
| Parameter | Default | Meaning |
|---|---|---|
target_id |
None |
Filter by the affected entity ID |
mutation_type |
None |
Filter by mutation type string |
since |
None |
ISO-8601 lower bound on created_at (inclusive), compared by instant |
limit |
100 |
Maximum entries returned |
since is converted to the same canonical UTC form created_at is stored in
before the comparison, so an equivalent instant written with a different
offset or suffix compares the same way. A value without an offset is read as
UTC; a value that cannot be read as an ISO-8601 instant raises ValueError.
since=is a convenience filter, not an audit boundary. It comparescreated_at >= since, andcreated_atis outside the hash preimage (as isentry_id). Rewriting an entry’screated_atacross a window’s lower bound can therefore remove it from or add it to that window whileverify_journal()still reportsvalid=True— the two effects compound instead of one catching the other. A rewrite that stays inside one particular window leaves membership in that window unchanged, but it still changes the timestampget_entriesreturns to the caller and may change membership under other lower bounds; because the deciding column is unprotected, nosince=result is evidence. Usesince=to narrow a report over a trusted journal; do not treat “nothing since T” as proof that nothing happened since T, nor an entry’s presence in the window as proof that it happened after T. Chain-covered fields (sequence_number,parent_hash) are what a time-bounded claim has to be anchored on: record the(sequence_number, entry_hash)you saw at T and audit forward from that sequence number.
Verifying integrity
Verification walks the whole chain in order, recomputes every hash, and checks
the parent-hash linkage, returning a JournalIntegrityResult. There are three
ways to run it, all backed by the same walk:
| Entry point | Use it when |
|---|---|
store.verify_journal() |
You have a store and want a one-call check. Verifies the on-disk chain even when journaling is currently disabled (see below). |
store.journal.verify_integrity() |
You are already holding the JournalWriter (only available while journaling is enabled). |
engrava verify |
From the shell / CI / a pre-backup hook — exit 0 = intact, 1 = broken or missing database. |
A delta cell that fails to parse as JSON is the one case none of these three
call sites turn into a JournalIntegrityResult: verification decodes each
delta with json.loads, unguarded, so a malformed value raises a JSON
decode error instead of a result you can inspect.
result = await store.verify_journal()
if result.valid:
print(f"Chain OK — {result.entries_checked} entries verified.")
else:
print(
f"Tampering or corruption detected at sequence "
f"{result.first_invalid_sequence}: {result.error_message}"
)
| Field | Type | Meaning |
|---|---|---|
valid |
bool |
True if every hash and link checks out |
entries_checked |
int |
Number of entries verified |
first_invalid_sequence |
int | None |
Sequence of the first broken entry, or None |
error_message |
str | None |
Description of the first error, or None |
An empty journal verifies as valid=True with entries_checked=0.
What this check proves. Verification proves that every journal row still on disk has self-consistent hashed content and links to the preceding row that was walked. It detects an in-row journal mutation, a changed parent link, or a mid-chain deletion/reordering when the affected hashes have not been recomputed. It does not provide any of these separate guarantees:
- Chain length / tail completeness. Removing a self-consistent suffix leaves
a valid prefix, so
verify_journal()cannot know that newer entries once existed. Detect this with an externally retained high-water mark and tail anchor, such as the expected(sequence_number, entry_hash)at a checkpoint. - Entry timestamps.
created_atis outside the hash preimage, so a chain whose timestamps have all been rewritten verifies exactly like an untouched one. What the walk proves is the order and the content of the entries it read, not when they were written. - Live-table reconciliation. The verifier reads
journal_entry; it does not compare the current thought, edge, embedding, or action tables with the latest journal deltas. A direct edit to a live thought or edge row is outside this check unless the edit also changes a journal row or breaks the SQLite file.
Verification is independent of the current journal.enabled state. Entries
are recorded only while journaling is on, but once written they stay in the
journal_entry table. store.verify_journal() (and engrava verify) audit
whatever chain is on disk — so a database that had journaling enabled in an
earlier session and reopened with it off (store.journal is None) is still
fully auditable. (store.journal.verify_integrity() is unavailable in that case
because store.journal is None; use store.verify_journal().)
Run verification on a schedule (e.g. before each backup, during incident response, or as a periodic monitoring check) rather than only ad hoc — that is what turns the chain from a passive structure into an active control.
Verifying automatically on open
Set journal.verify_on_open: true to run the walk once, when the store is
opened through SqliteEngravaCore.from_config(...). The check runs after the
schema is ensured and raises JournalIntegrityError (a subclass of
EngravaError, carrying first_invalid_sequence and error_message) instead of
returning a store when the chain does not verify — a fail-closed startup gate.
That translation covers a verified-invalid chain; a delta that is not valid
JSON is not translated — it surfaces as the same JSON decode error noted
above, not JournalIntegrityError, so a caller catching only
JournalIntegrityError at startup should also expect that case.
journal:
enabled: true
verify_on_open: true # refuse to open on a broken chain
from engrava import JournalIntegrityError, SqliteEngravaCore
try:
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
...
except JournalIntegrityError as exc:
# Startup aborted: the on-disk journal did not verify.
print(f"broken at sequence {exc.first_invalid_sequence}: {exc.error_message}")
verify_on_open is independent of enabled (a recorded chain is checked
even if journaling is now off) and defaults to false. Leave it off unless
you want the gate: the walk is O(entries), so on a large journal it adds a
one-time cost to every open. For periodic rather than on-open checking, call
store.verify_journal() / engrava verify on a schedule instead.
Security model & guarantees
The journal is a keyless SHA-256 integrity chain stored in the same
SQLite file it protects. verify_integrity() recomputes each entry’s hash
from that entry’s own stored data — there is no secret key, HMAC, signature, or
external anchor.
What it protects against (in scope): the ordering and content of the entries still on disk — both are in the hash preimage, so neither can be changed without breaking the chain unless the affected hashes are recomputed.
That preimage is the five fields joined by | with no escaping, an absent field
written as the empty string:
"{sequence_number}|{mutation_type}|{target_id}|{json.dumps(delta, sort_keys=True)}|{parent_hash}".
Nothing in the encoding marks where one field ends and the next begins, so the
fields stay distinguishable because of what they can hold, not because the format
separates them. For the entries the store emits those grammars settle it:
sequence_number is decimal digits, mutation_type is one of seven fixed
literals, parent_hash is a SHA-256 hex digest (empty on the first entry), and
delta is always a JSON object dump — whose quoting rules a caller-chosen
target_id cannot imitate, so no identifier written through the store can move a
field boundary.
That guarantee does not extend to arbitrary JournalWriter.append() calls.
JournalWriter is exported, and append() does not enforce the mutation_type
vocabulary before hashing, so a caller driving it directly can write two distinct
entries with one preimage and therefore one hash — mutation_type="INSERT_THOUGHT"
with target_id="x|" collides with mutation_type="INSERT_THOUGHT|x" and no
target_id. Exploiting it takes in-process code execution against your own store,
which is already past the boundary this chain defends; treat the binding as a
property of the store’s own field grammars, not of the encoding.
A direct append() call also sits outside the store’s own write lock — a
separate, non-security concern worth knowing here too. Every mutation the
store journals on your behalf runs the append under the same lock as the
write it describes, but JournalWriter.append() does not take that lock
itself. Calling it directly can still land inside another task’s open
suspend_auto_commit() window and be rolled back with it. Let the store
journal its own mutations rather than calling append() yourself.
Verification also re-serialises the stored delta before hashing it
(json.loads in, json.dumps(..., sort_keys=True) out), so what the chain binds
is the delta’s decoded value, not its stored bytes: rewriting the blob’s key
order, whitespace, or escaping without changing the value it decodes to verifies
clean. Nothing the delta says can change that way — but do not read a matching
hash as proof that the stored bytes are the ones originally written.
- Accidental corruption inside the retained journal rows — changed hashed content or parent linkage makes verification fail.
- Naive journal tampering — someone who edits, deletes from the middle, or reorders a journal row without recomputing the affected chain: the break is detected at the first inconsistent entry.
What it does NOT protect against (out of scope):
- Self-consistent tail removal. The retained prefix still verifies. An external high-water mark or tail anchor is required to prove expected length.
- Entry timestamps (
created_at) andentry_id. Neither is in the hash preimage. Rewriting everycreated_atin the journal — backdating the whole trail — leaves a chain that verifies asvalid=True. A rewrite that moves an entry across aget_entries(since=...)lower bound also changes that window’s contents, in either direction: downward hides the entry from the window, upward plants it inside one it did not belong to. The filter reads the same unprotected column, so a timestamp-based claim about the journal (“no deletions in the last 24 hours”, or “this deletion happened during the incident”) rests on data the chain does not protect. Anchor time claims on the chain-covered(sequence_number, entry_hash)pair recorded at a known point instead. - Direct edits to live records. Verification does not replay the journal or reconcile thought/edge/action state against it.
- Content removal via deletion.
delete_thought()anddelete_edge()write aDELETE_*journal entry whosebeforedelta holds the record’s full content, captured immediately before the row is removed from the live table. Deleting a thought or edge therefore does not scrub its content from the audit trail — the journal is an append-only, permanent record, so an erasure request needs a separate retention/redaction decision for journaled rows, not just a delete on the live table. - A chain-aware actor with write access to the database file. Because the
chain is keyless and self-contained, anyone who can write to the
.dbcan edit an entry and recompute every subsequent hash, producing a fully self-consistent chain that passesstore.verify_journal()(or the enabled writer’sstore.journal.verify_integrity()) withvalid=True. The journal is not forgery-proof against an adversary (including the agent process itself) who controls the file.
If you need genuine, multi-party tamper-evidence, treat the in-file chain as one layer and add at least one of:
- Restrict write access — store the
.dbon a volume only the trusted writer process can modify (OS file permissions / ownership). - Anchor the chain externally — periodically export the latest
(sequence_number, entry_hash)to an append-only / WORM store, a signed log, or another system out of the writer’s control. A later integrity walk plus a match against that checkpoint adds the length/tail expectation the in-file walk does not possess. - Verify on a schedule — run
store.verify_journal()from a separate monitored process so a detected mismatch raises an alert. This store-level entry point also works when journaling is currently disabled.
State this boundary plainly to stakeholders: Engrava’s journal gives you integrity detection for covered-field changes and broken linkage among retained rows — including an unrecomputed middle deletion or reordering, but not deletion of a self-consistent suffix or of every entry — not cryptographic non-repudiation against a file-level adversary.
Backup & retention note
The logical snapshot/restore path (engrava snapshot / engrava restore)
covers the thought / edge / embedding / action tables — it does not
include the journal_entry table. A snapshot is therefore not a backup
of the audit trail. What restoring one leaves the target’s journal holding
depends on how it was restored:
- A fresh target starts with no journal at all.
restore --clearemptiesjournal_entryalong with the data it wipes, so the chain does not outlive the data it described.- A restore without
--clearmerges into the target instead. If that target’s journal is non-empty, an incoming record that collides with an existing row (a primary key or aUNIQUEconstraint) is refused outright — the whole restore rolls back, not just the colliding line — rather than letting the merge silently replace a row the journal still describes. Pass--orphan-journal-entriesto allow that merge anyway, accepting that the journal may end up describing data the merge just discarded: the gate is conservative, not precise, and only ever fires on a target you already chose to journal (the CLI never turns journaling on for you). Even without any ID collision, a merge can still orphan a journalled row — a freshedge_idthat repeats an existingUNIQUE(from_thought_id, to_thought_id, edge_type)triple replaces that edge, and replacing a colliding thought also removes its own edges, embeddings, and actions. In every such case the journal entries describing the earlier row stay behind unchanged, andverifykeeps reporting the chain as valid even though it no longer matches what the database holds. See CLI reference →restorefor the exact refusal message and flags.
To preserve the journal itself, back up the database file (see the
upgrade/backup guidance), and note that hard-deleting an audited thought
still leaves its content in the journal’s before/after delta — relevant
when handling erasure requests.
For the exhaustive package reference — including the JournalEntry schema, a
worked example, and related guides — see the OSS
audit-trail.md docs.