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