# Running the operator air-gapped

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](/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.

| Situation | What happens |
|---|---|
| A line does not parse, does not chain, skips a number, or carries an unknown operation | Refused, 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` exists | Refused. 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 set | Refused. One operator keeps one record. |
| A Supabase store is configured but cannot be opened | Refused. It used to log a line and carry on in memory, forgetting everything at the next restart. |
| `MIRROR_REGISTRY` is set but unreadable | Refused, 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](/clock-discipline.md).

## 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.
