Reported symptoms
At 08:14 finance asks for svc-ledger as it stood on Tuesday. The repository on
rbdr-backup-02 refuses within a minute: Tuesday’s snapshot restores six of its
seven paths and exits 1.
Monday fails the same way. So does Sunday, and every snapshot back to 2026-08-07. The first one that comes back whole is 2026-08-06 — twenty-two days before the request.
The dashboard has read Backup health 100% every one of those mornings,
including this one. Nothing has alerted. The call opens on the theory that the
backups stopped weeks ago and the scrape is stale.
Evidence provided
The first useful move is to stop asking the dashboard and compute the ages from the repository.
$ rbdr-age-report --repo /srv/rbdr-repo-prodsystem recovery_point_age verification_age restore_proof_age
rbdr-db-01 9h 22d 118d
rbdr-ledger-01 9h 22d --
rbdr-app-01 11h 22d 84d
214 systems newest recovery point at most 27h old
verification age 21d or greater on 214 of 214
restore proof never taken on 61 of 214
dashboard Backup health 100% (214/214 backup jobs exited 0, last 24h)Illustrative output
The dashboard’s number is one expression, and the estate has never had another:
backup_health_percent =
100 * count(backup_job_last_exit_code == 0) / count(backup_job_last_exit_code)
over the last 24 hours, across all systems
The recovery points are present and are the expected shape. A listing proves it:
$ restic snapshotsID Time Host Tags Paths Size
-------------------------------------------------------------------------------
3fe43af4 2026-08-28 13:27:02 8211a08b55c3 daily /work/prod 60.000 MiB
3e349a12 2026-08-28 13:27:03 8211a08b55c3 daily /work/prod 60.000 MiB
-------------------------------------------------------------------------------
Timestamps shown in local time
2 snapshotsThe weekly verification job has been saying the rest of it since 2026-08-09,
into /var/log/rbdr/verify.log, in the same words each Sunday.
$ restic check --read-data[0:00] 100.00% 2 / 2 snapshots
read all data
pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f contains 2 errors: [blob 9a6d59cfa25fce43e433aff4b16bb04d240c3730b027ae677bcb15719e44a436: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed unexpected pack id 39e18fa9bf2f4acad3899cbe025e82aad203d9cfa1ee30017b1667a8d1e9bdc7]
[0:00] 100.00% 7 / 7 packs$ restic restore 3e349a12 --target /srv/rbdr-restore-checkignoring error for /work/prod/db/data.bin: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed
Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB) in 0:00
Fatal: There were 1 errorsSnapshots taken on or after 2026-08-07 fail on that path. Snapshots taken before it restore whole.
Work the evidence before reading on
- The dashboard says 100% and twenty-two days of recovery points are unusable. Which of those two statements is false?
- Write down what a backup job’s exit code can and cannot tell you. Then read the three ages again and say which of them it could produce.
- The cut falls on a date, not on a retention boundary. What does the repository do that would put a boundary there?
- The call’s theory is that backups stopped. Which single piece of evidence above ends that theory?
- The verification job failed on 2026-08-09. Where did that result go, and who was supposed to receive it?
Root cause
One unreadable pack, carried forward by deduplication into every later recovery point
A data pack written during the run that produced the 2026-08-07 snapshot does not
read back as the name it is filed under. Repository objects are stored under a
name derived from a hash of their contents, so unexpected pack id 39e18fa9… is
restic saying the bytes it read no longer hash to 2c3be6d1c7….
That alone would cost one night. What made it cost twenty-two is deduplication. The repository stores each chunk once; a later backup adds only the chunks that changed, and the capture measures the effect — a second backup of 60 MiB added 2.062 KiB. Every snapshot after 2026-08-07 therefore references the affected chunks rather than rewriting them, so one bad pack propagated forward through every recovery point taken since and stopped precisely at the boundary where it was created.
Whether the pack was damaged in flight or afterwards can no longer be established, and that is the second defect showing through the first: nothing read the repository’s data between the write and this morning except a job whose result nobody received.
A percentage of job exit statuses cannot express an age
The dashboard was not broken, stale or misconfigured. It reported that 214 of 214 backup jobs exited 0 in the last 24 hours, which was true every morning.
Resolution
Establish the cut line first. It is cheap to measure and every later decision depends on it.
export RESTIC_REPOSITORY=/srv/rbdr-repo-prod
RBDR_OUT=/srv/rbdr-restore-check
mkdir -p "$RBDR_OUT"
while IFS= read -r RBDR_SNAP; do
if restic restore "$RBDR_SNAP" --target "$RBDR_OUT/$RBDR_SNAP" > /dev/null 2>&1; then
printf '%s restored\n' "$RBDR_SNAP" >> "$RBDR_OUT/rbdr-cut-line.txt"
else
printf '%s FAILED\n' "$RBDR_SNAP" >> "$RBDR_OUT/rbdr-cut-line.txt"
fi
done < "$RBDR_OUT/rbdr-snapshot-ids.txt"
Copy the repository before repairing it. Do not run prune or forget first:
both rewrite repository state, and both make the damage permanent and
unattributable.
Recover from whichever source has the shortest gap — a second copy on
independent storage that passes restic check --read-data, the live source if it
still holds the data, or the 2026-08-06 point plus whatever the application’s own
logs replay forward. Then take a fresh backup so the affected chunks are written
again, and read that one back.
Report the exposure in the units the business uses: for twenty-two days this system had one usable recovery point, and the condition was visible in the repository throughout.
Verification
restic check --read-data on the repaired repository exits 0 and prints read all data with the full pack count, not merely the snapshot count.
The restore then exits 0 with the full file and byte counts and no ignoring error for lines, and the restored tree is compared against reference digests
taken from the source:
$ md5sum -c rbdr-reference.md5 ./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: OK
>>> md5sum -c exit code: 0Read the exit code and the digest result together, never one without the other, and repeat for every retained snapshot: packs are shared, so one passing restore proves only that one snapshot avoids the damaged pack.
Then verify the replacement instrument by breaking it on purpose. Stop the verification job on one system and confirm that system’s verification age climbs and alerts while its backup jobs keep exiting 0. A dashboard that stays green through that test has the defect this incident was about.
Prevention
Report three ages per system, not one percentage per estate. Recovery point age, verification age and restore-proof age answer three different questions, and no aggregate over exit statuses answers any of them.
Alert on the age of the last successful data-reading check. The weekly job failed three times and the estate learned nothing, because its result went to a log file rather than to an instrument.
Measure restore-proof age, and let “never” be a value it can hold. Sixty-one systems here had never had a recovery point restored and compared. That is the number the dashboard most needed to show.
A green job says the job ran. It does not say a recovery point is readable, and in a deduplicating repository it does not say the newest point is independent of the last one.
Test the boundary, not only the newest snapshot. Deduplication carries chunks forward, so one bad pack invalidates every later recovery point at once — and confirming that a snapshot exists will never find it.