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.

write → journal row → list / revert
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.