Disciplines · Runbooks

V1 disaster-recovery acceptance matrix (S10.12.f)

the topology this runbook describes — one box carrying the relational store, the queue, the blob store and the user-facing surface; an off-box repository holding the bytes; a clean host the recovery runs on — kills the box with SIGKILL,

6sections9 minread

On this page
  • Owner: UNASSIGNED — no named person or rota owns V1 recovery acceptance. Stated rather than filled with a team noun, because "the platform team" is nobody.
  • Last reviewed: 2026-08-14
  • Scenario: gd:regional-loss
  • Runbook: runbook:v1-regional-loss (v1-disaster-recovery.md)

The seven aspects S10.12.f names, each answered by a step of one executed game day. The machine-readable matrix — outcomes, evidence, every measurement behind them, and the findings — is the newest acceptance-matrix.json under docs/releases/v1/verification/game-day/. This document and that file are graded against each other: libs/oshun/workbench-kit/tools/acceptance-scan.mjs fails if an outcome stated here differs from the one the drill measured, so a row cannot be improved by editing prose.

1. What fills it#

scripts/operations/v1-acceptance-drill.mjs stands up a disposable estate in the topology this runbook describes — one box carrying the relational store, the queue, the blob store and the user-facing surface; an off-box repository holding the bytes; a clean host the recovery runs on — kills the box with SIGKILL, watches two independent observers, publishes a notice, restores onto the clean host in the runbook's declared order, lets the old box come back, captures the recovered primary and fails back.

One incident, seven facets. The aspects are steps of a single timeline and not seven experiments. An aspect measured against its own private incident would be measuring a different estate each time.

What it does not claim. estate: "disposable" is a field in the report, not a caveat in prose. The containers are the drill's own, because the development box's postgres, redis and minio are shared with other worktrees. A run establishes that the procedure works and that the numbers are real. It does not establish the production estate's recovery time.

2. The matrix#

aspect outcome what decided it
split-brain-prevention failed the returning box and the estate that replaced it both accepted a write on one key
degraded-operation passed the recovering estate answered real questions correctly with part of its stores missing
queued-work failed the checkpoint the backup verifies restores an empty queue that answers PONG
user-communication passed the notice was read back off the box while the on-box channel did not answer
monitoring-and-alerts failed the rule fires, and no service that could run it is deployed
backup-of-the-recovered-primary passed a fresh capture was taken after the recovery, before the failback, and restored
failback passed the rebuilt box came back with everything the recovered estate held

3. Aspect by aspect#

3.1 split-brain-prevention — failed#

What passing would mean here. This runbook promotes: a clean host is restored from the off-box repository and becomes the estate. Passing means that when the original box returns — a partition heals, a host reboots, an operator brings it up to look at it — something stops it writing.

How it is exercised. After the clean host is serving, the drill starts the box's containers again and writes the same primary key on both sides.

Measured. Both accepted it, with different values. Nothing in the procedure sits between them: §8 of the runbook says Fencing — ABSENT and this is what that sentence costs. The same drill shows the estate's one real fence working one layer down — a stale lease_token matches zero rows while the current holder's write lands — so the hole is specifically in the promotion path, not everywhere.

3.2 degraded-operation — passed#

What passing would mean here. That the estate can answer something true while part of it is missing, and that it never answers an empty store as though it were a full one.

How it is exercised. At every step of the restore the drill asks each store a real question and classifies the answer: unreachable, error (there and refusing — the caller knows), empty-success (there, answering, and wrong) or correct. Whether a port is open is not the question; a staged restore always has a window where some ports are open, so that test could never fail.

Measured. The estate served correct answers from part of its dependency set on the middle steps of the restore, and no survey returned an empty store as a success. It is worth being exact about the scope: at the first survey after the fault, none of the four dependencies answered and no surface did — a single-region estate has no degraded mode for a regional loss, because every declared fallback is co-located with what it backs up (finding F-f4). What passes here is degradation DURING the recovery, not during the outage.

3.3 queued-work — failed#

What passing would mean here. That work already in the queue survives the disaster, that in-flight work is reclaimed rather than lost, and that draining it after recovery applies each unit once.

How it is exercised. The queue is a redis stream with a consumer group: 25 entries, five delivered to a worker, three acknowledged and two still in that worker's pending list when the box dies — the state a queue is actually in during an incident. Six more are enqueued after the off-box capture. The restore is run twice: once from the whole redis data directory the archive holds, and once from dump.rdb alone, which is the artifact infra/hetzner/backup/offbox-backup.sh forces with BGSAVE and refuses to finish without.

Measured. The reclaim and the drain are sound: the in-flight entries were reclaimed by a new consumer, deliberate redeliveries applied nothing twice, and no entry was left pending. Two things failed. Entries written after the capture are gone — the queue's recovery point is the checkpoint instant, and those entries are still sitting on the box that came back. And the checkpoint-only arm restores zero of 25 entries: redis-server --appendonly yes, as infra/hetzner/docker-compose.yml deploys it, creates a new empty AOF on start and never reads dump.rdb. It answers PONG — and infra/hetzner/backup/box-loss-restore-drill.sh writes redisRestoreVerified: true on the strength of exactly that PING (finding F-f2).

3.4 user-communication — passed#

What passing would mean here. That the people who need to be told can be told over a route the incident did not remove, inside the window this runbook promises them.

How it is exercised. The notice is published during the outage to a channel that is not on the box, and read back over HTTP. The negative control is the same page served from the box, fetched at the address the box had before it died.

Measured. The off-box channel answered with the notice; the on-box channel did not answer at all; the notice was published well inside the 900000ms this runbook promises actors. What is measured is the out-of-band channel the runbook declares — the estate's automated sender is the BFF, which is why dep:notification-channel is in the gd:regional-loss target set. An automated notice from inside the failure domain is not a channel; nothing here claims it is.

3.5 monitoring-and-alerts — failed#

What passing would mean here. That the estate's own monitoring sees the incident and an alert fires.

How it is exercised. The drill stands up a real Prometheus and a real blackbox exporter, scraping real probes of the box, evaluating a real rule, and polls /api/v1/alerts until it is firing. Then it freezes the same store with docker pause and reads the probe again.

Measured on 2026-08-14. The rule fired, while the deployed estate contained no service that could run it, so nothing would have fired there (finding F-f3). The latency in this retained report is therefore a property of the disposable drill, and the row remains failed; current source-level remediation cannot retroactively turn it into live evidence. The negative control added the second half: with the store FROZEN rather than dead, the TCP-connect probe still reported probe_success=1 and no alert fired, while a probe that required an ANSWER timed out (finding F-f6). Task 13.7's successor uses answer-capable probes and an external dead-man receiver, and must still pass its own live admission before either historical gap closes.

3.6 backup-of-the-recovered-primary — passed#

What passing would mean here. That the rebuilt estate has a recovery point of its own. Until it does, the estate's most recent backup predates the disaster — the runbook says so in its failback step, and it is the reason this aspect exists at all.

How it is exercised. After the recovery, a fresh capture is taken off the clean host: a dump, a forced redis checkpoint, the object bytes, and the position of each store in its own history — a transaction LSN, an inventory version, a checkpoint sequence. It is then restored into a throwaway host and counted.

Measured. The capture was taken after the restore finished and before the failback began, and restoring it returned exactly the census the primary held. The capture is published in the report as a RecoveryPoint and graded by S10.11's recoveryPointFaults, so a marker with no position or a wall clock standing in for an LSN fails the scan rather than passing as a backup.

3.7 failback — passed#

What passing would mean here. That the estate returns to the topology it started in, with everything the recovered estate accumulated while it was the primary.

How it is exercised. "The same procedure with the roles swapped": the recovered estate is stopped, the box is rebuilt from nothing, and the fresh capture is restored into it.

Measured. The rebuilt box came back with the same row count, the same drained jobs, the queue, the objects and a surface answering. Two things are worth reading off it. The recovered estate is stopped FIRST, by hand, because no fence would make any other order safe — the failback is correct only because an operator remembered a step nothing enforces. And the row the unfenced box accepted during the split-brain window is gone: the failback restores the capture, and everything the old box wrote after the disaster goes with it. That is what the missing fence costs, in rows.

4. Findings for the follow-up register (S10.12.g)#

id severity summary
F-f1 critical a returning box writes into the key space of the estate that replaced it
F-f2 critical the backup verifies a redis checkpoint the restore never reads
F-f3 major at exercise time the deployed estate ran no telemetry service, so no alert had a producer
F-f4 major no degraded mode exists during a regional loss; every fallback is co-located
F-f5 minor work written after the capture is lost, and still sits on the returned box
F-f6 minor a connect-only probe is blind to a frozen dependency

Each was emitted by the drill with the measurement that produced it. Current source now includes the Task 13.7 monitoring profile and answer probes, but that does not rewrite the retained exercise. F-f3 stays open until the exact source candidate produces admitted live incident and dead-man receipts; only then can a new measurement close it rather than carrying either the finding or the fix forward as received wisdom.

5. Re-running it#

bash
pnpm run run:operations:v1-acceptance-drill      # fills the matrix, ~2 minutes
pnpm run verify:operations:v1-acceptance-matrix  # grades it, with 29 negative controls
pnpm run verify:operations:v1-disaster-recovery  # the artifact read back, no docker needed

The drill exits 3 when docker is unreachable — loudly, because a run that could not happen is not a run that passed. It leaves nothing behind: every container it creates carries the oshun.game-day.disposable label and is removed in a finally, and node scripts/operations/v1-fault-control.mjs --reverse-all drains anything a killed process left applied.

6. What this matrix cannot say#

  • It cannot say what the production estate's recovery time is. It measures a disposable estate with a few hundred rows.
  • It cannot say that the aspects it passes hold for other scenarios. It rehearses gd:regional-loss; the other nine registered scenarios have plans and runbooks but no acceptance matrix of their own.
  • It cannot say that anybody would do these steps under pressure. Every step here was performed by a program that already knew the order.