Reported symptoms
rbdr-site-a lost power and cooling at 02:58. The failover was declared at
03:10. By 03:39 every service in the recovery runbook had been started at
rbdr-site-b and reported healthy — except rbdr-orders-api, which is tier one.
It starts, binds its port and passes its own health check. Every request that touches the database fails.
The restore of rbdr-orders-db was the longest step of the night and finished
nine minutes before the application started, so the bridge’s first theory is that
the database is not really ready. A responder proposes restoring it again: fifty
minutes, and a second recovery of the only current copy of the orders data.
Two other services on that recovery site are already serving traffic against the same database instance.
Evidence provided
$ journalctl -u rbdr-orders-api.service -n 4 --no-pagerAug 28 03:41:07 rbdr-app-01 rbdr-orders-api[2214]: starting rbdr-orders-api (site rbdr-site-b)
Aug 28 03:41:08 rbdr-app-01 rbdr-orders-api[2214]: db pool: connecting to rbdr-orders-db.rbdr-site-b.example.net:5432
Aug 28 03:41:09 rbdr-app-01 rbdr-orders-api[2214]: db pool: FATAL: password authentication failed for user "rbdr_orders_svc"
Aug 28 03:41:31 rbdr-app-01 rbdr-orders-api[2214]: db pool: 8 consecutive failures, backing offIllustrative output
That is a rejection, not a failure to arrive.
$ tail -n 3 /var/log/rbdr-orders-db/postgresql.log2026-08-28 03:41:09 UTC [4471] FATAL: password authentication failed for user "rbdr_orders_svc"
2026-08-28 03:41:12 UTC [4478] LOG: connection authorized: user=rbdr_reporting_svc database=rbdr_orders
2026-08-28 03:41:19 UTC [4482] LOG: connection authorized: user=rbdr_ledger_svc database=rbdr_ordersIllustrative output
The primary’s secret manager is in a building with no power, but last night’s path inventory was shipped to the recovery site as part of the backup. The inventory is metadata: path names, no values.
$ diff -u /var/tmp/rbdr-secret-paths-site-a.txt /var/tmp/rbdr-secret-paths-site-b.txt--- /var/tmp/rbdr-secret-paths-site-a.txt
+++ /var/tmp/rbdr-secret-paths-site-b.txt
@@ -6,7 +6,6 @@
rbdr/prod/ledger-api/db
rbdr/prod/mail-relay/smtp
rbdr/prod/monitoring/scrape
-rbdr/prod/orders-api/db
rbdr/prod/registry/pull
rbdr/prod/reporting/db
rbdr/prod/sso/oidcIllustrative output
The declarative secrets repository the nightly job applies to both sites has no
definition for that path and no commit that ever mentioned it. The primary
store’s audit export shows one write to it — fourteen months ago, by a named
human token, during incident INC-4471 — and application reads ever since. Every
other path under rbdr/prod/ was written by the pipeline service account.
$ grep 'rbdr-site-a' /var/log/rbdr-dr-exercise-2026-04-18/egress-flows.log2026-04-18 09:12:44 10.62.14.31 -> 10.10.8.20:8443 rbdr-site-b app subnet -> rbdr-site-a secret manager
2026-04-18 09:12:44 10.62.14.32 -> 10.10.8.20:8443 rbdr-site-b app subnet -> rbdr-site-a secret managerIllustrative output
That report’s preconditions record the primary as in service; not isolated.
Nothing in it is marked as a finding: the application started, answered its
health check and served synthetic orders, which is what the exercise checked.
Work the evidence before reading on
- The service is refused, not unanswered. What must the far end already have done to refuse it, and what does that settle about the second restore?
- Two other services authenticate to that instance from this site. Which hypothesis does that kill, and which does it leave standing?
- The path is missing here and present in last night’s primary inventory. Is that a replication failure? What in the audit export decides?
- The exercise four months ago passed. Name the condition under which it could pass while this failure was already inevitable.
Root cause
Dispose of the database theory first, because it is the expensive one. An
authentication rejection comes from a server that is listening, that read the
connection request and that refused the credential inside it; a database still
recovering would refuse the connection or let it time out. And
rbdr_reporting_svc and rbdr_ledger_svc were authorised on that instance
within ten seconds of the rejection. The database is up. One account is not.
The credential existed in exactly one place, and that place is dark
The password for rbdr_orders_svc was set by hand at the primary during
INC-4471 and written straight into the primary’s store. It never entered the
declarative secrets repository, which is what the nightly job applies to both
sites. Every other credential reached the recovery site because it was defined
there; this one was not, so the recovery site has never held the path.
Nothing was lost in the failover. The recovery site never had it.
The exercise was answered by the site it was meant to do without
Resolution
Cancel the second restore. The database is up, answering and authenticating other accounts.
The credential cannot be retrieved: the only store that ever held it has no power. Reissue it instead, from the side that still exists.
$ psql -h rbdr-orders-db.rbdr-site-b.example.net -U rbdr_admin -d rbdr_ordersGenerate the value rather than choosing it, then write it into the recovery
site’s store at rbdr/prod/orders-api/db — the path the application already
reads — and restart rbdr-orders-api so its agent renders it at boot.
Add the path to the declarative repository in the same change. Until it is there, the recovery site holds the credential only until the store is next rebuilt.
Record the divergence for failback: the role’s password here is now different from whatever the primary store held, so the primary’s copy is stale and must be replaced from the repository rather than trusted when the site returns.
Verification
Four checks. None is satisfied by a green health endpoint.
The credential is accepted. psql "host=rbdr-orders-db.rbdr-site-b.example.net user=rbdr_orders_svc dbname=rbdr_orders" -c 'SELECT 1' returns one row at exit
0, and /var/log/rbdr-orders-db/postgresql.log now carries connection authorized: user=rbdr_orders_svc where it carried password authentication failed. journalctl -u rbdr-orders-api.service no longer says backing off.
The business transaction completes. An order is placed through the API and
read back by id: both calls exit 0 and the read returns the id the create
issued. A connected pool proves the credential; only a completed order proves
the service, and that is the claim the business owner is owed.
The two sites hold the same paths. diff -u /var/tmp/rbdr-secret-paths-site-a.txt /var/tmp/rbdr-secret-paths-site-b.txt prints nothing and exits 0, where it
printed -rbdr/prod/orders-api/db and exited 1. Produce it with the scheduled
script, not by hand at 04:00.
The repository can rebuild the path. A scratch store built from the
declarative repository alone holds rbdr/prod/orders-api/db: the reconciliation
script against it exits 0 instead of printing MISSING at recovery site.
Reading the commit is not the check; rebuilding from it is.
Run that last check as a principal with no route to the primary. It is the only way to show the dependency is local — the condition April never established.
Prevention
Run the exercise with the primary unreachable, not merely unused. Isolate the recovery site at the network boundary for the window, and treat any egress to primary addresses during it as a finding rather than as background traffic.
Reconcile secret inventories between sites on paths, not values. It needs no access to the material and is cheap enough to run hourly:
#!/bin/bash
# rbdr-secret-present is this estate's own one-line wrapper around whatever
# secret manager it runs: it exits 0 if the path exists at the named site and
# non-zero if it does not, and it never reads the value. Substitute your own.
set -euo pipefail
MANIFEST=/etc/rbdr/rbdr-recovery-secret-manifest.txt
missing=0
while read -r path; do
if ! rbdr-secret-present --site rbdr-site-b "$path" </dev/null; then
printf 'MISSING at recovery site: %s\n' "$path"
missing=$((missing + 1))
fi
done < "$MANIFEST"
exit $(( missing > 0 ? 1 : 0 ))
That comparison would have named rbdr/prod/orders-api/db on the morning after
INC-4471 rather than fourteen months later at 03:39.
Make the declarative repository the only route by which a production credential is created, and alert on writes arriving any other way. The audit export already distinguished the human token from the pipeline account; nothing was reading it.
Pre-stage a recovery-side credential instead of planning to fetch one. A second credential, held by the recovery side and never needed by production, converts a dependency on the primary into a local fact. This course measured the arrangement at the repository layer:
$ restic key list ID User Host Created
--------------------------------------------------
66c34166 root 17dffded9807 2026-08-28 14:04:55
*4bc6f61a root 17dffded9807 2026-08-28 14:04:52
--------------------------------------------------Give the bridge the discriminator in writing. A rejection means the service is there and refused you; a refusal or timeout at the transport layer means it is not. That one distinction is the difference between a nine-minute credential fix and a fifty-minute second restore.