The file
audit.file is a SQLite database (Node’s built-in SQLite, WAL, full sync):
an event is on disk before the gateway goes on.
- Nothing runs unrecorded. If
ATTEMPTEDorDECIDEDcan’t be written (a full or read-only disk), the statement doesn’t run; the agent is told the audit log can’t be written. The gateway also refuses to start on a file it can’t write. - No results. The record proves what ran, never what it returned. It does hold statements as written, which can contain values a person typed.
- A hash chain. Each event carries the hash of the one before it, so an event removed or changed breaks the chain from there on.
- Several local processes, one file. Processes may share a file (two
Claude Code sessions, each starting
midplane local --stdio): each event takes its sequence and the hash before it from the file as it is written, so they keep one chain, and share the taint it holds. - One linked gateway per file. Each file has a random instance id, which a linked gateway pushes it under; replicas each keep their own. A file whose events part from what Midplane Cloud holds under its id (restored from a backup, or copied to a second replica) gets a new id the first time the cloud answers one of its batches with a conflict, an event it holds under another hash: the cloud stores and acks the batch only up to there, and the file sends the rest as the new instance (see operations).
What goes to Midplane Cloud (linked mode)
The gateway sends each event once it is on disk, on a loop of its own: pushing never holds up a statement, and a cloud that is down or refuses the push changes nothing on the gateway. Events wait in the file until the cloud has them; each sync reports how many wait, and the Gateways page shows it.- Redaction is checked twice, and fails closed. The redacted text must
parse back to the same statement, then pass an allowlist read token by
token: keywords, names,
$nmarkers, punctuation and operators, integers up to 1000 (positions and type modifiers are small), and no quoted name but one the catalog has, a built-in collation or a keyword. Anything else fails it: strings of every spelling, bit and hex strings, floats, comments. A statement that can’t be vouched for goes with no text, only why: it doesn’t parse, it is a kind Midplane doesn’t evaluate (COPY,ALTER ROLE … PASSWORD), it is over 16 KiB, or it failed a check. Midplane Cloud applies the same allowlist, reading text that holds an integer as a tree too, and stores any statement that fails without text; text cut to fit that holds an integer is stored without text too, since a position can’t be told from a value there. - Hashes are keyed. The cloud holds every field of an event but its
text, and the hash before it, so the chain’s own hash would let it test a
guess at a literal offline (a four-digit one falls in milliseconds). It
gets each event’s hash as HMAC-SHA256 under a random key made with the file
instead; the key leaves the file only in its exports, so the cloud has
nothing to test a guess against. The file’s own chain is plain SHA-256, and
verifychecks it without the key. - One known gap: an unquoted lowercase name in a position the walker
doesn’t rename (a function or type name, say
jane(id)) goes as written. Values written as names need quoting almost always ("jane@example.com","4111-1111"), and those are caught. - The switch. “Send full statements and intents” is per database, off by default: when you add a database, and on its policy page. It travels in the signed bundle; the gateway reads it from there, never from an unsigned answer. It applies when an event is sent, not when it was recorded: turning it on also sends as written the events from while it was off that haven’t gone yet (a backlog from a cloud that was down, say). Turning it off stops full text from the next batch, and deletes the full text Midplane Cloud kept for that database. Held writes always send their statement when filed, switch or not: an approver has to read it.
- Text is capped at 16 KiB per statement in what’s sent (the file keeps all of it).
- Only what was recorded while linked. Events recorded in local mode stay in the file and are never sent. So do the events in a file from a gateway before 0.21: linked, it sends only what it records from then on. Opening a file in local mode stops sending the events a linked gateway left waiting; the gateway logs how many, and keeps them the full window from then.
Retention
- On the gateway:
audit.retention_days(default 30;0keeps everything). Events are kept that many days after Midplane Cloud has them (in local mode, after they are recorded), so a backlog sent after an outage still stays the full window, and an event the cloud doesn’t have yet stays however old. Events a linked gateway left unsent count from when the file is opened in local mode, and a file from before 0.21 from when 0.21 first opens it. Whatever the window, an event younger than 26 hours stays: a held write’s request may wait 24 hours and then run within 2, and its approval reads its events back all that time. Hourly, and at start, the gateway deletes the oldest events past that, and keeps the last one’s hash as the anchor the chain is verified from. Only the oldest run is ever deleted, so what remains still verifies. - In Midplane Cloud: the query log keeps 30 days by default; its page says how long.
Export and verify
- Both read only
audit.filefrom the config, so they need none of its secrets, and they open the file read-only beside a running gateway. Each reads one snapshot of the file, so a prune meanwhile changes nothing in it.--audit <file>names the file directly. - An export is JSON lines: a header with the file’s instance, the anchor and the checkpoint key, then each event with its sequence, the hash before it and its own hash. Keep it as private as the file: it holds the statements as written, and the key.
verifyrecomputes the chain from the anchor and names the first event that doesn’t follow. With--checkpoints(the query log’s Export from the dashboard), every hash Midplane Cloud recorded for this file must match, and the file must be one the cloud knows: its instance appears in the export, under one gateway, at least one of the cloud’s hashes falls within it, and its anchor, if the cloud saw that event, has the cloud’s hash. The cloud’s hashes are keyed, so checking them takes the key: the file’s, or the one in the export’s header. An export without one fails with--checkpoints, saying so; without them, its chain still verifies alone. A live file the cloud saw past is reported as cut short. Only the hashes of the file’s current instance count: after a restore made it a new instance, the events pushed under the old one are checked by the chain alone.verifyexits0when the record verifies. It exits1when it doesn’t (a broken chain, a checkpoint that doesn’t match), and also when it can’t check at all: an input or config it can’t read, a config with noaudit.file, an unknown option. It exits2, printing its usage, when given nothing to read. Treat only0as verified.- What it proves: the record is internally consistent from its anchor, and nothing Midplane Cloud saw was changed afterwards. What it can’t: the chain is a plain SHA-256 chain, so events the cloud never received can be rewritten undetectably by whoever controls the file; take exports, and keep the cloud’s, on a schedule.
apps/gateway/test/audit.test.ts (the chain, two processes on
one file, retention from the ack and the anchor and around live approvals,
files from before 0.21 and a switch to local mode, a new instance, export and
verify beside a prune, checkpoints and their key, the projection and its
keyed hash), apps/gateway/test/audit.e2e.test.ts (the push, the switch on
and off, a cloud that refuses or keeps answering 429, acks that don’t verify
or cover part of a batch, an answer cut off mid-body, a conflict, a forged
copy stored first, a restored file, and a scan of everything sent for row
values, the DSN, the salt, the checkpoint key, Postgres messages, literals and
intents), and
apps/gateway/test/gateway.e2e.test.ts (a read-only audit file runs nothing).