DocumentsRunning it

Running the operator air-gapped

5 min readSource air-gapped-operator.mdsha256 8798621cc2a83adb…

A bank, a group of banks or a supervisor can run the Mirror operator entirely inside its own perimeter: no cloud account, no database service, no outbound network. State lives in one hash-chained journal on local disk. The cryptographic guarantees are the ones the hosted operator gives, because it is the same code — server/core.js is the only routing in the product, and the Node host and the edge host both wrap it.

What you need#

  • A machine inside the perimeter with Node.js 20 or later. Nothing is downloaded at run time; the operator has no runtime dependency except the optional Supabase client, which an air-gapped operator never loads.
  • A copy of this release, checked against its provenance before it crosses into the perimeter.
  • An operator signing key (ES256), generated inside the perimeter:
node -e "require('./kernel/envelope.js').generateKeypair().then(k=>console.log(JSON.stringify(k.privateJwk)))"
  • A directory for the journal, on an encrypted volume.

Start it#

MIRROR_SIGNING_JWK='{"kty":"EC",...}' \
MIRROR_DATA_DIR=/srv/mirror/journal \
MIRROR_API_TOKEN=<pilot token> \
MIRROR_STORAGE_JURISDICTION=eu \
PORT=8787 node server/index.js

The boot line names the journal and its head:

Mirror operator on :8787 · state: file journal /srv/mirror/journal (1 entries, head 3f9c…) · token required

GET /residency then signs where the operator stores: local disk (hash-chained file journal), with the jurisdiction you declared and the evidence marked declared. A Node process can declare a jurisdiction; it cannot observe one. That is why a bank's residency check (meets()) does not accept a self-hosted operator's word alone, and should not.

The journal#

<dir>/journal.ndjson holds one line per write:

{"n":7,"prev":"<sha256 of line 6>","op":"addEvent","at":"2026-10-04T09:00:00.000Z","data":{…}}
  • Append-only. Nothing is rewritten in place. At start the operator replays the journal into memory and serves reads from there.
  • Chained. Each line carries the SHA-256 of the line before it, and line 1 names the format (mirror-journal/1).
  • Durable before acknowledged. A write appends its line, then fsyncs, before the call returns. A write the operator acknowledged is on the disk.
  • No private key. A member key carrying a private half is refused before it reaches the file.
  • One writer. <dir>/LOCK is created exclusively. A second operator on the same directory is refused.

It holds what the operator holds — governance events, opaque commitments, signed verdicts, webhook subscriptions and the delivery outbox. It never holds a raw value, a band, a salt or a corridor secret, because the operator never receives them.

Fail-closed start#

The operator does not start on state it cannot account for.

SituationWhat happens
A line does not parse, does not chain, skips a number, or carries an unknown operationRefused, naming the first line and byte offset that no longer agree. Restore the directory from a backup.
The last line has no newline (a crash mid-append)Refused by default. That write was never acknowledged. Start once with MIRROR_REPAIR_TORN_TAIL=1: the torn bytes are moved to journal.torn-<n>.bin (archived, never deleted), the journal is truncated to its last whole line, and a repair line records the bytes' length and hash.
LOCK existsRefused. Either another operator is running on the directory, or one crashed. Confirm nothing is running, then remove LOCK by hand. The operator never guesses that a lock is stale.
MIRROR_DATA_DIR and a Supabase URL are both setRefused. One operator keeps one record.
A Supabase store is configured but cannot be openedRefused. It used to log a line and carry on in memory, forgetting everything at the next restart.
MIRROR_REGISTRY is set but unreadableRefused, rather than starting with an empty registry.

With no store configured at all, the operator runs in memory and says so on its boot line: nothing survives a restart.

Check the journal#

Verification is read-only and takes no lock, so it is safe beside a running operator:

node server/store-file.js verify /srv/mirror/journal

It prints the entry count, the counts per operation and the head hash, and exits 0 only if every line chains and there is no torn tail.

Pin the head off the host. The chain proves the file is internally whole. It cannot prove nobody rewrote all of it: whoever can write the directory can rebuild a consistent chain, and an edit to the newest line has no successor to catch it. Copy the head hash somewhere the operator host cannot write — a counterparty, a witness, a supervisor, paper — on a schedule. A rewrite then shows as a head nobody else recorded.

Backups and restore#

Back up the directory while the operator is stopped, or copy the journal and verify the copy: a copy taken mid-append has, at worst, a torn tail, which the start-up check reports. A restore is a copy back, then a start. The operator replays and verifies every line before it serves a request.

What changes, and what does not#

  • Unchanged: every route, every refusal, every signature, and offline verification of every record. A verdict signed by an air-gapped operator verifies with its published key exactly like one from the hosted operator.
  • Webhooks: delivery needs a network path to the receiver. Inside a perimeter with no outbound route, subscriptions are kept and the outbox fills; members read verdicts from the routes instead. The ledger, not the push, is the record.
  • Time: the operator's clock is the host's. A perimeter without NTP drifts, and a signed time inherits that drift — see clock discipline.

Honest limits#

  • Tamper-evident, not tamper-proof. Pin the head off the host, as above.
  • fsync is as honest as the disk under it. A controller that acknowledges writes it is still caching can lose them in a power cut. That is a property of the hardware, which the operator cannot see.
  • Not encrypted. The journal is the whole book of what the operator holds. Put it on an encrypted volume.
  • No compaction. Every write adds a line (a signed verdict is one to two kilobytes), and the whole journal is replayed at start. A signed snapshot that starts a fresh journal is not built; until it is, archive copies by the institution's own procedure and let the journal grow.
  • One process on one machine. The lock is a file, not a distributed lock. A network share mounted by two hosts is not a supported directory.
  • Not a single binary yet. The bander ships as one self-contained executable; the operator still runs as node server/index.js over this release's files.

The journal, the refusals above and the boot behaviour are pinned by server/store-file.test.js, which restarts an operator on its journal, tampers with it nine ways, tears its tail and fills its disk.