Owner: production operations duty officer (rota:prod-ops). Last reviewed:
2026-09-14. Version: 3.
The V1 stack runs on a single host. This runbook covers losing it: the box is unreachable, destroyed, or has to be abandoned, and the estate has to be rebuilt somewhere else from the off-box backup.
Every section below is written from scripts that exist in this repository, and
each step cites the file it runs. §8 is the one that cannot be written from the
repository, because a fence is not something a script can supply. It is marked
ABSENT rather than filled in, so that missingRunbookSections in
@oshun/workbench-kit/game-day reports it and nobody mistakes an unwritten
section for a satisfied one.
Related: database-backups.md covers the portable
pg_dump path for a cluster that is still reachable, and
object-storage-backups.md the object store. Neither
covers this: the V1 off-box path is restic to a remote repository, and the
restore is not finished when the restore finishes (§10). The nine narrower
scenarios in the same register have their own runbooks —
signing-service outage,
relational corruption,
signing-key compromise,
queue loss,
model-provider outage,
search-index loss,
blob-store loss,
database loss and
bridge-host loss. This one is the union of most of
them, which is what makes it the regional scenario.
1. Detection and declaration#
Detection signal. The off-box backup writes a receipt whose age is bounded
by OSHUN_OFFBOX_HEALTH_MAX_AGE_SECONDS
(infra/hetzner/backup/offbox-backup-healthcheck.sh). A receipt that stops
being refreshed is the first machine-observable sign that the box is gone.
The source now supplies two complementary paths. The profile-gated Prometheus
plane in infra/hetzner/docker-compose.yml detects service/process loss while
the host remains alive. A distinct external dead-man receiver must page when its
once-per-minute OshunDeadMansSwitch notification stops, so whole-host or
collector loss is distinguishable from health. See
v1-observability-game-day.md. The local plane
is still inside the regional blast radius; the receiver is deliberately not.
Until Task 13.7 admits a live dead-man receipt for the exact deployed candidate,
the backup receipt remains a corroborating detector and the external path is a
source-level control, not claimed operational evidence.
- Detection authority: production operations duty officer, contact
rota:prod-ops, as the S10.5 dependency register records for every dependency this scenario takes away. - Declaration authority: production operations duty officer, same contact. The two are the same role here and that is a property of the estate rather than an oversight: there is one operations rota.
- Gap, stated, and it is narrower and worse than "there is no on-call".
docs/operations/on-call.mdexists and describes a weekly primary/secondary rotation with an escalation ladder. It names no person, and it holds no rotation config — while instructing the reader to "open a PR against the rotation config in this doc", which is not in it. Its ownOwner:isplatform team, a collective. None of the register's fourrota:identifiers appears in it or indocs/operations/alert-routing.md, so the name above cannot be resolved to whoever is actually carrying the pager. - A local evaluator cannot announce its own disappearance. The Hetzner Prometheus and Alertmanager remain co-located with the services, so their ordinary incident route ends with the host. The separately provisioned dead-man receiver is responsible for detecting that silence from outside the platform domain. Production deploy validates that the incident and dead-man endpoints are distinct external HTTPS receivers; a supervised missing-signal test and fresh receipt are still required before relying on it operationally.
- Whoever declares must be reachable over something that does not run on the V1 host — the host is the thing that is gone. See §2.
- Record the detection instant.
DETECTED_AT_EPOCHis a real input toscripts/isis/run_runpod_disaster_recovery_gameday.shand the fieldDrillTiming.detectedAtMsin@oshun/workbench-kit/restore-consistencyexists for it. A recovery time measured from the moment somebody ran the restore command excludes detection and decision, which is most of a real recovery.
2. Communications#
Nothing in the repository sends these; they are the operator's to send, and they are listed with deadlines so that a missed one is a fact rather than an impression.
| Audience | Within | Content |
|---|---|---|
| Actors | 15 min | that work is stopped, and that in-flight edits are not lost beyond the recovery point (§6) |
| Operators | 5 min | that this runbook is running, and by whom |
| Providers | 60 min | only if a provider credential was on the lost host |
The channel must not be the estate, and the register now says so.
dep:notification-channel is a target of gd:regional-loss: the sender is the
BFF (apps/oshun/bff/src/auth/verification-email-sender.ts), which is on the
lost host. Messages routed over it during a regional loss do not arrive, and
runbookFaults refuses a runbook that tries.
Gap, stated. The estate has no out-of-band channel implemented. The three rows above travel over whatever the operator can reach from outside the box, and this repository does not provide it. That is the same class of gap as §8: a human control standing in for a mechanism.
3. Dependency assumptions#
The recovery assumes exactly these, and nothing else:
- The restic repository is reachable and is not on the V1 host.
infra/hetzner/backup/offbox-backup.shrefuses a local repository outright:RESTIC_REPOSITORY must not be local to the V1 host. - Docker is available on the recovery host (
docker command is unavailableis a hard failure inbox-loss-restore-drill.sh). find,jq,sha256sumandstatare onPATH— the drill checks each.
It assumes nothing about the lost host. If a step needs something only the lost host had, that step is wrong.
4. Recovery infrastructure#
A clean host with no running Oshun stack. Not a dependency in the S10.5 register and never will be: it is provisioned for the recovery.
box-loss-restore-drill.sh enforces both halves of "clean":
docker ps --format '{{.Names}}' | grep -Eq '^oshun-(staging|production)(-|$)'
# -> "an Oshun runtime is already running; this is not a clean-host drill"
find "${TARGET_DIR}" -mindepth 1 -maxdepth 1 -print -quit
# -> "target directory is not empty"
OSHUN_CLEAN_HOST_ID is required and is recorded in the proof as
targetHostId, so the report says which host the estate came back on.
5. Secrets#
Two, and both must be obtainable without the estate:
RESTIC_REPOSITORYand the restic password (RESTIC_PASSWORDorRESTIC_PASSWORD_FILE), supplied in the recovery env file. The drill fails withrestic recovery password is absentif neither is set.- The recovery env file itself must deny group and other access; the drill
refuses it otherwise
(
recovery env permissions must deny group/other access).
The drill's comment states the rule the rest of this runbook depends on: the recovery env "is operator-controlled and must not be fetched from the repository being recovered".
Closed on 2026-08-14 (S10.12.g, finding
B-2). This section used to read: "the restore proof assertskeyRecoveryVerified: trueand there is no step in the script that exercises a key beyond the restic password itself." There is now:infra/hetzner/backup/verify-restored-state.sh keyschecks the restoredstack.envagainst every${VAR:?}ininfra/hetzner/docker-compose.yml— the set the stack refuses to start without — and the drill writeskeyRecoveryVerifiedas the value that check produced instead of as a literal. The required set is derived from the compose rather than listed, so a new required secret cannot be forgotten here. Retested both ways (retest:key-recovery): a complete environment is accepted, and one missingMINIO_ROOT_PASSWORDis refused by name.
6. Restore order#
Run infra/hetzner/backup/box-loss-restore-drill.sh:
infra/hetzner/backup/box-loss-restore-drill.sh \
production /path/to/recovery.env /path/to/empty-target /path/to/proof.json
It performs, in order: restic check --read-data-subset →
restic restore latest --tag oshun-v1 --tag <stack> → manifest schema check
(oshun.v1-offbox-backup-manifest.v1) → per-dump SHA-256 verification →
PostgreSQL into a scratch container → Redis → MinIO mirror → stack.env
presence.
That order is safe only because nothing is serving traffic. The drill runs into an isolated docker network with no route in. When the estate is brought back for actors, the order is the other way round for the stores that reference each other:
| Bring back | Before | Because |
|---|---|---|
| blob store | primary store | artifact rows carry a blob key; a row exposed before its bytes is a dangling reference, and the bytes without a row are harmless garbage |
| primary store | search index | the index is projected from the rows and is rebuildable; the rows are not rebuildable from the index |
| signing service | primary store | release rows carry the key version their signature was made under |
This is the reverse of the CAPTURE order in
@oshun/workbench-kit/restore-consistency, and deliberately so: capture the
referring store first so inserts leave orphans, restore the referenced store
first so no row is exposed pointing at bytes that are not back yet.
restoreOrderFaults in @oshun/workbench-kit/game-day checks a proposed order
against the same reference graph.
The full order, and it is longer than the three stores above. A regional
loss takes ten registered dependencies, not four: docker/docker-compose.yml
declares the whole estate in one file with no placement constraint, so the
telemetry, the cache, the session authority and the notification sender go with
the data stores. Bring them back in this order:
dep:identity— nothing else is reachable by an actor until sessions are, and nothing in the list below is verifiable by anybody who cannot sign in.dep:telemetry-pipeline— before the stores, not after. It is the instrument the rest of this restore is watched through, and a restore observed only by the operator's terminal is one whose failures are noticed in the order somebody happens to look.dep:notification-channel— the sender, so §2's messages stop needing the out-of-band route the moment they can stop.dep:blob-store— before the rows that reference it.dep:primary-store— the rows.dep:read-cache— after its source, and empty. A cache restored from a snapshot serves the estate's state as of the snapshot, which is the one failure in this list that looks like success.dep:primary-store-scan— correct as soon as the rows are.dep:search-index— start Qdrant empty and reproject personalization vectors from PostgreSQL; it is last because no Qdrant snapshot is authoritative.dep:job-queue, then 10.dep:job-worker-pool— the pool rests on the queue, and a pool started first reports itself healthy while doing nothing.
dep:signing-service is not in the numbered list because a regional loss does
not take it: there is no signing service in the compose file, the signer is
in-process (see
v1-dr-signing-service-outage.md). If it is
ever restored alongside these, it goes before dep:primary-store, as the
third row of the table above says.
7. Validation#
The drill's own floors, which are real checks and not assertions:
- every
*.dumphas a.sha256beside it and the bytes hash to it; checksum_count > 0— a snapshot containing no PostgreSQL dumps fails;database_count == checksum_count— every dump has metadata and every metadata has a dump;- each restored database answers
SELECT count(*) >= 0 FROM pg_catalog.pg_class; - Redis holds what the capture recorded. The manifest carries
inventory.redisKeyCountandinventory.redisPersistentKeyCount, taken fromINFO keyspaceat the instant of the forcedBGSAVE, andverify-restored-state.sh redisrefuses a restore that answersPINGwith an empty keyspace, one short of the persistent keys (keys with no TTL cannot expire, so a shortfall is loss), or one holding more than was captured (which would mean this is not the snapshot that was restored). A snapshot whose manifest predates those counts is refused rather than accepted on thePING. - MinIO holds what was mirrored:
inventory.minioObjectCountagainst the objects on the restored volume. stack.envcarries every value the stack refuses to start without (§5).
Closed on 2026-08-14 (S10.12.g, finding
F-f2). This section used to read: "Redis, MinIO, the file state and the configuration are assertedtruein the report from steps that an EMPTY store also passes." S10.12.f measured what that cost:redis-server --appendonly yes— how this estate runs it — creates a fresh AOF on start and never readsdump.rdb, so a host handed the checkpoint the backup forces and verifies comes up holding 0 of 25 entries and answersPONG. The counts above are the repair, andretest:F-f2runs both arms: the pre-fix check accepts that empty restore, the repaired check refuses it by name, and the whole archived data directory is accepted at 25 of 25.Still open (
B-3a): the content-service and computer-use directories are checked for existence and nothing else. An empty one passes.
8. Fencing — ABSENT for a promoted primary#
Correction to an earlier version of this runbook, which said there is no
fencing anywhere in this estate. That was a claim about a grep — for
split-brain, fence, fencing and STONITH across docs/, infra/ and
scripts/ — and the estate's one real fencing token is in none of those
directories and uses none of those words.
It is live_media_pipeline_job_attempts.lease_token, in
libs/shared/live-media/src/sql-media-pipeline-job-store.ts. Every heartbeat,
lease expiry and completion carries AND lease_token = $4 AND status = 'leased'
in its UPDATE ... WHERE, so a worker whose lease was reassigned updates zero
rows instead of overwriting the worker that replaced it. That is textbook
lease fencing, it works, and it is the pattern the rest of this section is
missing rather than a pattern the estate has never used.
What is absent is a fence for a promoted primary, which is what this scenario needs: nothing rejects the writes of a host that comes back after its replacement was promoted. A job attempt is protected from a stale worker; a store is not protected from a stale host.
Consequence, stated so that it is a decision rather than an accident: if the
lost host returns to life while the recovery host is serving, there are two
primaries and nothing rejects the writes of the older one. Until a fencing token
covers the stores the way lease_token covers a job attempt, the recovery host
must not be brought up for actors while the original host might still be
reachable. That is a human control, and it is the weakest step in this runbook.
9. Failover — promoting the recovery host#
Failover here is not a switch; it is the decision to let actors reach the recovery host, and it is the step §8 makes dangerous.
- Confirm the reconciler (§10) has run and returned no blockers. A host that is serving before reconciliation can repeat a completed provider effect.
- Confirm the original host is unreachable and cannot come back on its own. With no fencing token, this is a human judgement and it is the one that causes split-brain when it is wrong.
- Point DNS or the load balancer at
OSHUN_CLEAN_HOST_ID. The recovery host is identified in the proof astargetHostId, so the record of where the estate went is the same artifact as the record that it came back. - Record the instant. Recovery time runs from the fault, not from this step.
infra/hetzner/backup/box-loss-restore-drill.sh deliberately does none of this:
it restores into an isolated docker network and refuses to run at all if an
Oshun runtime is present. Promotion is outside the drill and always has been.
10. Reconciliation — the restore is not finished when the restore finishes#
The proof says so itself:
{
"status": "restore-pass-reconciliation-required",
"safeToResumeAutonomy": false,
"nextRequiredControl": "scripts/operations/v1-restore-reconcile.mjs"
}
Run scripts/operations/v1-restore-reconcile.mjs before resuming any autonomous
or effectful work. It compares restored provider effects, publications, queues,
invoices, artifacts, placements and approvals against what the outside world
observed, and returns blockers.
Before starting the HTTP-serving BFF, start PostgreSQL, Redis, and an empty Qdrant, then run the bundled production reprojection entry point:
docker compose -p oshun-production \
--env-file /opt/oshun/production/.env \
-f /opt/oshun/bundle/docker-compose.yml \
run --rm bff node dist/reproject-personalization-vector-index.js
The command creates or validates the collection and its userId,
schemaVersion, and embeddingVersion payload indexes, removes stale points,
projects every non-tombstoned PostgreSQL vector, and fails unless readback
count, payload version, and dimensions match. Starting BFF repeats the same
idempotent reconciliation before constructing HTTP. Production boot also
requires Redis, the deletion-attestation signer, and the independent remote
recovery-deletion journal; it replays every verified receipt before HTTP exists.
A missing dependency, failed reprojection, failed receipt import, or failed
replay keeps the BFF down. Do not route traffic around this admission boundary.
Give it expected counts. Every collection it is handed can be empty, and empty reconciles clean — an empty restore produces the same verdict as a complete one unless the expectation is supplied. Record what the estate held at the recovery point, and compare.
And the counts have to come from somewhere. Nothing in this repository
writes that reconciler's input file;
scripts/operations/v1-replay-inventory.mjs reads the nine state classes out of
the database so there is an inventory to compare against, and refuses to report
a count for a store it could not read. The state-specific procedures for folding
each class back in — which work may be re-driven, which must never be, and how
the operator tells them apart — are in
v1-reconciliation-and-replay.md. That
document is the rest of this step.
11. Failback#
Returning to a rebuilt original host is the same procedure with the roles swapped, plus one step this runbook is explicit about: take a fresh backup of the recovered estate before failing back. The recovery host has been serving; the snapshot it was built from has not been current since §6, and until a new recovery point exists the estate's most recent backup predates the disaster.
12. What this runbook does not cover#
- Partial restores (a single table, a single bucket prefix). See
database-backups.md. - A cluster that is still reachable — use the
pg_dumppath there; it is faster and does not need a clean host. - Search and provenance bytes are not in the off-box backup set. The personalization Qdrant collection comes back only through the mandatory PostgreSQL reprojection above; other search recovery has its own runbook (v1-dr-search-index-loss.md) and is likewise a reprojection, which is why derived indexes are not in the backup.
- How any of this is rehearsed. This runbook is what an operator does during an incident; v1-game-day-plan.md is how the estate is deliberately broken on a Tuesday so the steps here are tested before an incident tests them. It carries the fault controls, the reading that confirms each injection landed, the observers, the timed instants, the safety boundaries and the abort — and it records which five of the ten registered scenarios cannot be rehearsed on this estate at all, with the reason for each.
- Whether the steps here actually work. v1-acceptance-matrix.md is the result of running them: seven aspects — split-brain prevention, degraded operation, queued work, user communication, monitoring and alerts, the backup of the recovered primary, and the failback in §11 above — each answered by a step of one executed drill on a disposable estate. Three of the seven currently fail, and the matrix says which and by how much. Read it before trusting §8, §9 or §11.
- What is being done about what those drills found.
v1-follow-up-register.md carries every finding the
game-day programme measured — thirty of them — with a severity, an owner, a
deadline and, for the ten that are closed, the run that closed them. Two of
the closures repaired this runbook's own restore path: §5
(
keyRecoveryVerifiedwas a literal) and §7 (aPINGaccepted an empty queue). The twenty still open are what an operator should expect to go wrong, ranked, with a date against each.
13. Machine-checkable statement#
The block below is the input runbookFaults in @oshun/workbench-kit/game-day
grades, and libs/oshun/workbench-kit/tools/runbook-scan.mjs runs it against
the scenario register. It is here rather than in a test file so that the
statement the checker reads and the statement the operator reads are one object.
promotesAStandby is true and fencing is null, so the contract reports
promotion-with-no-fencing against this runbook. That refusal is correct and
expected: it is §8, stated in a form a machine can count.
{
"id": "runbook:v1-regional-loss",
"scenarioId": "gd:regional-loss",
"version": "3",
"detectionAuthority": {
"role": "operations duty officer",
"name": "production operations duty officer",
"reachableOver": ["out-of-band: a route that does not run on the V1 host"]
},
"declarationAuthority": {
"role": "operations duty officer",
"name": "production operations duty officer",
"reachableOver": ["out-of-band: a route that does not run on the V1 host"]
},
"communications": [
{
"audience": "actors",
"channelDependencyId": "out-of-band: a channel not hosted on the V1 box",
"withinMs": 900000
},
{
"audience": "operators",
"channelDependencyId": "out-of-band: a channel not hosted on the V1 box",
"withinMs": 300000
},
{
"audience": "provider",
"channelDependencyId": "out-of-band: a channel not hosted on the V1 box",
"withinMs": 3600000
}
],
"assumes": [
"the off-box restic repository, which offbox-backup.sh refuses to place on the V1 host",
"docker on the recovery host",
"find, jq, sha256sum and stat on PATH"
],
"recoveryInfrastructureDependencyId": "a clean host with no running Oshun stack",
"secrets": [
{
"id": "RESTIC_REPOSITORY and the restic password",
"heldInDependencyId": "the operator-controlled recovery env file",
"recoverableOutOfBand": true
},
{
"id": "every other key on the lost host (keyRecoveryVerified is unproven, see section 5)",
"heldInDependencyId": "the operator-controlled recovery env file",
"recoverableOutOfBand": true
}
],
"restoreOrder": [
"dep:identity",
"dep:telemetry-pipeline",
"dep:notification-channel",
"dep:blob-store",
"dep:primary-store",
"dep:read-cache",
"dep:primary-store-scan",
"dep:search-index",
"dep:job-queue",
"dep:job-worker-pool"
],
"fencing": null,
"promotesAStandby": true,
"validation": [
"every *.dump has a .sha256 beside it and the bytes hash to it",
"checksum_count > 0: a snapshot containing no PostgreSQL dumps fails",
"database_count == checksum_count",
"each restored database answers SELECT count(*) >= 0 FROM pg_catalog.pg_class",
"the personalization Qdrant projection command reports matching authoritative, projected and verified counts",
"BFF boot completes independent recovery-deletion receipt import and replay before HTTP exists",
"v1-restore-reconcile.mjs returns no blockers against recorded expected counts"
],
"failback": "the same procedure with the roles swapped, plus a fresh backup of the recovered estate taken before failing back: until a new recovery point exists the estate's most recent backup predates the disaster"
}