The time machine
Every insert, update, and delete against your data lands in an append-only journal first, so a single mutation — or an entire agent session — can be inspected and, in most cases, undone.
Verified 2026-07-09 @ 80a91241
What this is
The time machine is the mutation journal at subscriber_sophia_mutations,
plus the two MCP tools that read and act on it: sophia.list_mutations and
sophia.revert_mutation. Every insert, update, and delete against a
user-owned subscriber_* table writes a journal row with the actor, the
table and row touched, and the full before/after JSON — before an agent or
a human can undo anything, the daemon has already recorded exactly what
changed.
Why it exists
An agent that mines documents and writes facts back into your graph will sometimes get something wrong. The journal is what makes that recoverable without reconstructing state from memory: instead of scrolling back through a chat transcript trying to remember what a fact used to say, you look up the mutation and revert it. The daemon’s own writer comment states the policy directly — “for v1 we journal everything user-facing,” with the journal table itself the only thing excluded (to avoid it recording writes to itself).
This is a different mechanism from fact-level supersession — the rule that a newer claim on the same predicate replaces an older one, and that a user correction structurally outranks a model’s version on every later read. That fact-level semantics is Truth’s territory. This page is about the row-level machinery underneath it: what gets recorded when any row changes, and how far “undo” actually reaches.
How it works
LocalBunSqliteWriter.journal() — in backend/local.ts — fires after every
successful insert, update, or delete on a subscriber table. Each row
carries actor_kind (human / agent / system), connection_id and
agent_id, table_name, row_id, operation, before_state /
after_state JSON, and — once reverted — reverted_at, revert_mutation_id,
and reverted_by. For MCP-driven writes, agent_id is stamped with the
calling connection’s id, not a separately snapshotted display name; tracing
a mutation back to a human-readable agent name means resolving that
connection id separately.
sophia.revert_mutation looks the original row up by id, computes the
inverse (insert → delete, delete → insert, update → update-back), and
applies it inside one transaction alongside a new journal row that points
back at the original via revert_mutation_id. Reverting a revert is legal —
it just grows the chain. Before applying the inverse, it checks that the
live row still matches what was recorded; if a later mutation already
changed it, the revert is refused with row_state_conflict rather than
silently clobbering newer data. It’s also an elevated write: the first call
without an approval_id returns status: 'pending_approval', and only a
second call carrying the approval that sophia.check_approval confirmed
actually performs the revert.
sophia.list_mutations is the read side — filterable by actor, table, row,
and date range, with already-reverted rows hidden by default. On a
scoped (non-full-profile) connection, visibility is further restricted to
mutations on rows belonging to in-scope entities, via a join across a fixed
list of entity-owned tables (entities, sophia_knowledge,
document_artifacts, and others); a few tables with composite or sparse
keys are structurally excluded from that join by source-code design, not
by omission.
flowchart LR W["insert / update / delete<br/>on a subscriber_* table"] --> J["journal() writes a row:<br/>actor + table + row_id +<br/>before/after JSON"] J --> M[(subscriber_sophia_mutations)] M --> L["sophia.list_mutations<br/>filter + entity-scope"] M --> R["sophia.revert_mutation<br/>compute inverse, apply in a tx"] R -->|"writes a NEW row<br/>pointing at the original"| M
What your agent does with it
// Real response from this daemon, captured 2026-07-09 (trimmed):
const recent = await sophia.list_mutations({ limit: 5 });
// → { items: [
// { id: '4f836e74-...', actor_kind: 'agent',
// connection_id: 'ceaed2e4-...', agent_id: 'ceaed2e4-...',
// table_name: 'document_artifacts', operation: 'insert',
// before_state: null, after_state: '{"id":"533c3bdf-...","title":"...",...}',
// reverted_at: null, chain_hash: null },
// { id: '017e97d4-...', actor_kind: 'human', agent_id: 'external-file-watcher',
// table_name: 'wiki_page_index', operation: 'insert', ... },
// ], total: 336504, limit: 5, offset: 0 }
// sophia.revert_mutation's response shape, per source (not called live —
// it is a write, out of scope for this page's read-only research):
// success: { ok: true, mutation_id, revert_mutation_id, table_name, row_id,
// inverse_operation: 'delete' | 'insert' | 'update', dry_run: false }
// blocked: { ok: false, error: 'not_revertible_operation',
// message: "Operation 'bulk_clear_inbox' has no single-row inverse
// (audit-logged bulk action). The mutation row remains
// as immutable history." } sophia.replay_session is adjacent but answers a different question. It
reads subscriber_agent_actions — the tool-call log — not the mutation
journal, and returns the sequence of MCP calls a connection made
(tool_name, args summary, result status, timestamp), for post-mortem or
resuming after a restart. A live call against this daemon (no session_id,
listing recent sessions) returned one session with 3,330 recorded actions
and the distinct tool names used across it. It replays what an agent did,
not a row-by-row diff of what changed — for that, list_mutations filtered
by connection_id is the right tool.
Boundaries
Only writes are journaled — reads never appear in subscriber_sophia_mutations,
so the journal cannot answer “who looked at this,” only “who changed this.”
A small, explicit allowlist of operations has no single-row inverse:
today it holds exactly one entry, bulk_clear_inbox (the audit-logged
bulk-mark-read write behind sophia.inbox_clear — see
Coordination). Calling revert_mutation on one of
those rows returns not_revertible_operation; the row stays as permanent,
unrevertible history rather than being hidden or deleted. The journal table
itself is on a separate forbidden list and can’t be reverted through this
surface at all, for the obvious reason that doing so would corrupt the
audit trail it’s supposed to protect.
Document bytes in the vault are a separate story — content-addressed
storage with tombstoning instead of row mutations, covered in
The State Layer. What a row’s claim_json actually
contains, and how supersession and corrections work at the fact level, is
Knowledge Graph and Truth.
The full tool surface these calls are drawn from is cataloged at
MCP Surface.