Reported symptoms
On 2026-08-27 finance reports that a batch import corrupted the July ledger and asks
for /srv/rbdr-orders as it stood on 2026-07-21.
The restore runs. Eighteen minutes, 41 GiB, exit status zero. The nightly wrapper
rbdr-restore-check runs against the result and reports PASS on all four of its
assertions.
Finance opens the tree and cannot find ledger-2026-07.csv, created on 2026-07-15 and
never deleted on the source. orders.csv is there, opens cleanly, is a plausible size,
and is missing every row added between 19 and 21 July. app.conf carries its most
recent content, which makes the tree read as current and delays any suspicion.
Nothing anywhere reported an error.
Evidence provided
The team reproduced the mechanism in a scratch environment, using GNU tar’s listed-incremental format because the chain is explicit and one member can be removed by hand.
$ tar -xf L0.tar && tar -xf L2.tar--- correct restore: replay L0, then L1, then L2 ---
orders.csv:
ORDER-1001,4500.00
ORDER-1002,1250.00
app.conf : config v2
--- now L1 is unreadable: retention removed it, or its media failed ---
after L0 only:
orders.csv: ORDER-1001,4500.00
app.conf : config v1
skipping L1 (missing) and applying L2:
orders.csv: ORDER-1001,4500.00
app.conf : config v2Read the last two lines together. app.conf is at its newest content and orders.csv
has lost a row, which is the production symptom exactly.
$ cat /etc/rbdr-archive/retention.conf# levels are pruned independently, each against its own keep count
level0.schedule = monthly
level0.keep = 12
level1.schedule = weekly
level1.keep = 4
level2.schedule = nightly
level2.keep = 45
# prune log, 2026-08-23
# level1 rbdr-orders-2026-07-19-L1.tar removed (rank 5 of 5, keep=4)Illustrative output
$ sha256sum --check /srv/rbdr-archive/manifests/rbdr-orders-2026-07-21.sha256./app.conf: OK
./orders.csv: FAILED
sha256sum: ./ledger-2026-07.csv: No such file or directory
./ledger-2026-07.csv: FAILED open or read
sha256sum: WARNING: 1 listed file could not be read
sha256sum: WARNING: 1 computed checksum did NOT matchIllustrative output
The four assertions in rbdr-restore-check are: the restore exited zero, the target
directory is non-empty, seven named files are present, and the total size is within
20 percent of the previous run. The list of seven was written in March 2026.
Work the evidence before reading on
- Two archives were applied and the restore exited zero. What does a zero exit
status from
tar -xactually assert? app.confcame back current andorders.csvcame back short. What distinguishes the two files, and what does that tell you about which files a chain gap loses?- Each level’s keep count was chosen sensibly for its own tier. Compute the window each one delivers, and then compute the window the set delivers.
- All four assertions passed. What object was each one measuring, and what would each have had to compare against instead?
- The obvious alternative is that
ledger-2026-07.csvhad already been deleted on the source before the backup ran, and the restore is faithful. Which single artefact settles that, and in which direction?
Root cause
The alternative had to be eliminated first
If the source had not contained ledger-2026-07.csv on 2026-07-21, the restore would be
correct and this would be an application data-loss event with a different response. The
manifest recorded when the level-2 archive was written settles it: 8,412 files including
ledger-2026-07.csv, and orders.csv at 2,304 lines. The source had both. The backup
captured both. The restore did not produce them.
That is why the manifest is evidence and a file listing is not. A listing taken today describes today; a manifest written beside the archive describes the moment the archive was written, which is the only moment a restore can be judged against.
The retention policy expired a level nothing could replace
A level-2 archive is a delta against the level-1 before it, which is a delta against the
level 0. rbdr-orders-2026-07-19-L1.tar was removed on 2026-08-23 as rank 5 of 5
against a keep count of 4.
The three windows were each sized for the cost of their own tier and never compared. Twelve monthlies reach back a year, forty-five nightlies six weeks, four weeklies four. The set delivers the shortest, so every level-2 archive older than about 28 days had already lost its parent — seventeen nights of apparently healthy recovery points that no restore could use.
The restore check inspected the tree instead of the backup
All four assertions describe the restored directory: exit status, non-empty, seven names present, size within 20 percent. None refers to anything recorded when the backup was taken, so none can detect a difference between what was captured and what came back.
A short file is present. A file absent from a hard-coded list is invisible, and that list
could not have named ledger-2026-07.csv because the file did not exist when it was
written. The check had reported PASS for five months and had never been capable of
failing for this reason.
Resolution
A level-1 archive cannot be reconstructed from the archives on either side of it, so the work is to find a surviving chain rather than repair the broken one.
RBDR_REPO=/srv/rbdr-archive
ls -1 "$RBDR_REPO"/rbdr-orders-*.tar | sort
cat "$RBDR_REPO"/rbdr-orders-2026-07-21.chain
Every level-2 archive between 2026-07-14 and 2026-08-01 names the same absent parent, so none of them serves the request. The recoverable position is the level-0 archive of 2026-07-01 replayed alone: internally complete, earlier than the corrupted import, and three weeks short of what was asked for. That shortfall is reported to finance as a number and a date, not absorbed.
The restored tree is then checked against the manifest written with that archive before anyone is told the data is back:
RBDR_TARGET=/srv/rbdr-restore
RBDR_MANIFEST=/srv/rbdr-archive/manifests/rbdr-orders-2026-07-01.sha256
cd "$RBDR_TARGET" && sha256sum --check "$RBDR_MANIFEST"
Raise the level-1 keep count to cover the full level-2 window, and re-run the prune in dry-run mode to confirm nothing else is scheduled for removal.
Verification
The chain file for a recovery point names every member, and all of them are present in the repository before a restore starts. A missing member aborts the restore instead of producing a tree.
sha256sum --check against the backup-time manifest reports zero FAILED lines and zero
unreadable files, and the manifest’s file count equals the tree’s. A short file fails its
checksum; an absent file fails to open. Neither depends on anyone remembering its name.
The recorded content properties match: 2,304 lines in orders.csv, 8,412 files, and the
newest record identifier the manifest recorded.
rbdr-chain-audit reports zero orphaned level-2 archives across the repository — the
assertion that would have surfaced seventeen unrestorable nights before an incident
needed one of them.
Prevention
Express retention over chains, not over archives. A policy that prunes each level against its own count is always governed by its shortest window, and the archives it strands remain on disk and in the listing. Derive the parent windows from the child window, or make the prune refuse to remove any archive still named by a chain file.
Write a chain file at backup time. The chain is knowledge the backup job has and the restore does not.
set -euo pipefail
RBDR_REPO=/srv/rbdr-archive
RBDR_CHAIN="$RBDR_REPO/rbdr-orders-2026-07-21.chain"
missing=0
while read -r member; do
if [ ! -f "$RBDR_REPO/$member" ]; then
printf 'MISSING CHAIN MEMBER: %s\n' "$member" >&2
missing=$((missing + 1))
fi
done < "$RBDR_CHAIN"
[ "$missing" -eq 0 ] || exit 1
Record a manifest with every backup and verify against it. File count, per-file checksum, and one or two content properties. This is the single change that turns a plausibility inspection into a test.
Never verify a restore against a list of filenames somebody remembered. Such a list cannot contain a file created after it was written, which is the first class of file a chain gap loses.
Shorten the chain. Every additional level multiplies the number of objects that must survive retention, media and operator error together for one recovery point to remain usable.