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 MutationType enum; UPDATE_ACTION is a stored mutation_type string emitted by update_action() — the enum itself is not extended. mutation_type is a free-text column, so verification covers the entry regardless.

An UPDATE_THOUGHT entry does not always mean update_thought() was called. A deduplicating create_thought(..., deduplicate=True) call that matches an existing thought by content hash bumps confirmation_count instead of inserting, and records UPDATE_THOUGHT for that update — not INSERT_THOUGHT. A terminal create_action() and an outcome-affecting update_action() each recompute the source thought’s action_outcome_score and record their own UPDATE_THOUGHT entry 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_THOUGHT entry (the thought’s lifecycle_status flips to ARCHIVED and expires_at is cleared; the delta carries the before/after).
  • delete strategy → a DELETE_THOUGHT entry (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 compares created_at >= since, and created_at is outside the hash preimage (as is entry_id). Rewriting an entry’s created_at across a window’s lower bound can therefore remove it from or add it to that window while verify_journal() still reports valid=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 timestamp get_entries returns to the caller and may change membership under other lower bounds; because the deciding column is unprotected, no since= result is evidence. Use since= 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_at is 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) and entry_id. Neither is in the hash preimage. Rewriting every created_at in the journal — backdating the whole trail — leaves a chain that verifies as valid=True. A rewrite that moves an entry across a get_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() and delete_edge() write a DELETE_* journal entry whose before delta 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 .db can edit an entry and recompute every subsequent hash, producing a fully self-consistent chain that passes store.verify_journal() (or the enabled writer’s store.journal.verify_integrity()) with valid=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 .db on 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 --clear empties journal_entry along with the data it wipes, so the chain does not outlive the data it described.
  • A restore without --clear merges 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 a UNIQUE constraint) 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-entries to 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 fresh edge_id that repeats an existing UNIQUE(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, and verify keeps reporting the chain as valid even though it no longer matches what the database holds. See CLI reference → restore for 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.