Audit log
Every mutation recorded with its actor, outcome, and duration — at the intent level.
zero-memory keeps an append-only audit log of every mutation: who changed
what, with what (sanitized) payload, whether it succeeded, and how long it
took. Reads are never audited. recall, build_context and searches leave
no entry; the log records intent to change state, not curiosity.
What accountability requires
A shared memory store needs accountability: when a memory appears, changes visibility, or disappears, there must be a durable answer to "who did that, when, and how it ended". The audit trail is also the foundation for memory hygiene — reviewing and rolling back bad writes requires knowing exactly which commands produced them. See Hygiene.
Where entries come from
One choke point, one decorator
Every mutation that reaches the server as a command flows through a single
command bus. The audit log exploits that: a decorator (AuditedCommandBus)
wraps the standard bus in the server's composition root, delegating execution
and recording the result. The generic CQRS package itself is untouched.
Auditing is an infrastructure concern layered on at assembly time, so no
individual write path carries audit code, and no new command can forget it.
The two paths that are not commands
Some state changes never travel as a command. Each of them records itself where it happens rather than being left out of the trail.
The hygiene paths write their own entries through the privileged client: conflict resolutions, retirements and restores, challenges, duplicate collapses and entity merges, kind corrections, re-verification verdicts, portability and reflection proposals, rule-incubator distillation and promotion, loop closures, and the stale-suspect and reinforcement rollups. Each entry names the pass or agent that acted, so an unattended night is reconstructible afterwards. See Hygiene and the rules incubator.
Where the change is applied by a privileged database routine (an owner approving or dismissing a portability proposal, an account erasure), the entry is written inside the same transaction as the change itself. The resolution and its record land together or not at all, even if the process making the call dies mid-flight.
Project moves (move_memories), writes, forgets, shares, links, imports and
exports all remain plain commands and are covered by the decorator.
The record
Each executed command becomes one row in public.audit_log:
| Column | Meaning |
|---|---|
occurred_at | When the command ran |
actor_id | The user behind the command; null for paths with no authenticated user |
author_kind | human or agent |
agent_name | The agent principal or the named background pass, when there is one |
command | What was done — a command class name such as RememberCommand, or the dotted name of a non-command path such as portability.approve |
payload | Sanitized command payload (see below) |
outcome | ok or error |
error | Error message (no stack trace) when outcome is error |
duration_ms | Wall-clock duration of the execution |
request_id | Correlation id shared with logs and usage events |
Actor identity comes from the authenticated command context, the same identity that stamps ownership on memories themselves, so the audit log and the data always agree about who acted. See Identity and Agent principals.
Like usage events, the table is indexed by
time and by (actor_id, occurred_at). Access is closed from both sides: end
users hold no privileges on the table, and row-level security carries an
explicit deny-all policy on top of that, so only the service role reads or
writes it. The service role itself may only select and insert. There is no
update or delete grant, which is what makes "append-only" a property of the
schema rather than a convention. The audit log is operator data and is never
exposed through the API.
An account erasure is the one thing that touches existing rows, and it does
not remove them: the actor reference is severed (actor_id becomes null), the
payload is replaced by an erasure marker, and the entry survives as the fact
that a command ran. The erasure itself is recorded as a content-free entry
carrying the counts of what was deleted. It is the same erasure whether the
person deleted the account in Settings or an operator deleted the user in
Supabase Auth.
Payload sanitization
The payload is a JSON snapshot of the command, sanitized before it is stored.
A field someone wrote — a memory's content or verbatim phrase, a
transcript chunk, a card's title, body, note or reason, an entity's name, a
rule's text, a key — is replaced by its length ([content, 42 chars]), at any
depth. Every other string, an identifier or a choice, is truncated to 500
characters; a clipped value keeps a marker of how much was dropped, so a
truncated entry never looks like a short one. The whole payload is capped at
8 KB. When a command exceeds it, the row stores
{truncated: true, command_keys: [...]} instead, so the shape of the intent
survives even when the body does not.
The words are never in the log, by construction rather than by trust: a write that the content guard refused for carrying a secret is still audited as a failed command, and what it carried is not. The audit log records intent, not content.
Failure semantics
A command that throws is recorded with outcome: 'error' and the error
message, and the exception is re-thrown to the caller unchanged. Auditing
never alters behavior. The audit write itself is fire-and-forget: it is not
awaited on the critical path, and a failed audit write is logged as a warning
while the command completes normally. Entries written by the hygiene paths
follow the same rule from the other side, where a failed audit write is a
warning and never a reason to abandon the work it was describing.
Entries written inside a database routine are the deliberate exception. They share the transaction with the change, so either both land or neither does. That guarantee is what the two owner-facing resolutions are worth paying an extra write for.
Querying the trail
Query the table with operator (service-role) access. Typical questions, and the columns that answer them:
Who deleted this? Filter by command (e.g. forget/supersede commands) and a
time window; actor_id, author_kind, and agent_name identify the actor
precisely.
What did this agent do last night? Filter by agent_name and occurred_at
for a complete, ordered mutation history. Background passes name themselves,
so unattended upkeep reads the same way as agent work.
Who approved this move? Owner decisions taken in the dashboard, approving or
dismissing a portability proposal, carry author_kind: 'human', the deciding
user, and the memory's before/after scopes in the payload, which is also what
makes the change reversible.
Why did a write fail? outcome = 'error' rows carry the message, and
request_id links to the full request in the logs — see
Observability.
Is something slow? duration_ms per command name gives a latency profile of
the write path for free.
Why the command bus, not triggers
Database triggers audit rows, not intentions: a share shows up as a bare
update, and the connection to the actor and the request is lost. Auditing at
the command bus captures what was meant, the command name and its
arguments, with actor and request attached.
Wrapping the bus in the composition root keeps the CQRS package generic and reusable, and makes auditing a property of the assembled application rather than of any individual handler.
Routing everything through the bus purely to keep one writer would mean forcing background upkeep and atomic database routines into a shape that does not fit them. A trail with three writers and no gaps is worth more than a tidy one that quietly omits the changes nobody watched happen.