Reported symptoms
At 01:47 a maintenance mkfs landed on the wrong device and destroyed the volume
holding rbdr-repo. Every on-site recovery point for svc-billing went with it,
and the offsite copy in rbdr-offsite is now the only one.
svc-billing is tier 1, the catalogue publishes a four-hour RTO against it, and
the incident was declared at 02:03.
At 02:40 the restore is pointed at the offsite bucket and stops on its first read — not partway through, on the first object. Every key it asked for is present at the expected size and checksum, and last morning’s inventory agrees.
The bridge settles quickly: the retrieval request must have failed, so reissue it. That theory survives four minutes.
Evidence provided
$ aws s3api head-object --bucket rbdr-offsite --key rbdr-repo/data/4a/4a9f0c --query '[StorageClass,Restore]' --output textThe class comes back as DEEP_ARCHIVE: not what the objects were written in, and
not what the recovery plan assumed.
The documented retrieval time for it. The provider’s storage-class documentation lists two retrieval options for S3 Glacier Deep Archive: Standard, documented to complete within 12 hours, and Bulk, within 48. There is no expedited option for this class. Read that table on the day; the figures are the vendor’s to change. Tonight it is not close — the fastest documented window is three times the objective, before a byte moves.
The objective’s stated basis. The catalogue entry for svc-billing reads, in
full: tier 1, RTO four hours, RPO fifteen minutes, basis “agreed with the
business, 2025-11”. No recovery path, stage budget, storage class, architecture
or assumptions. The number is the entire document.
And the retrieval request did not fail.
RBDR_BUCKET=rbdr-offsite
RBDR_KEY=rbdr-repo/index/rbdr-latest.idx
aws s3api restore-object --bucket "$RBDR_BUCKET" --key "$RBDR_KEY" \
--restore-request '{"Days":5,"GlacierJobParameters":{"Tier":"Standard"}}'
aws s3api head-object --bucket "$RBDR_BUCKET" --key "$RBDR_KEY" \
--query 'Restore' --output text
The request returns success, and the Restore field then reads
ongoing-request="true" with no expiry-date.
The lifecycle document. One rule, empty filter, one transition at thirty days to the archive class. The change record dated 2026-05-29 titles it a storage spend reduction and names no service, tier, objective or recovery plan.
Work the evidence before reading on
- The snapshot being restored is six hours old. Why are the objects it needs older than thirty days, and what does that do to an age-based rule?
- Two numbers are on the table, twelve hours and four. Which is a decision somebody made, and which is a consequence nobody wrote down?
- The retrieval request was accepted. What does that eliminate, and what does it leave?
- A competent reviewer approved the transition. Read the catalogue entry again and say what they could have checked it against.
Root cause
A transition written against object age reached the newest recovery point
The offsite copy is a deduplicating repository: a snapshot is an index, and the data lives in pack objects written once and referenced by every snapshot after it. The snapshot being read is six hours old; the objects it needs are months old, because not changing is what deduplication produces.
A lifecycle transition selects on object age, and here object age has nothing to do with recovery point age — so a rule meant to archive old backups archived the backing store of the newest one.
An objective with no stated basis cannot be contradicted by a change
The reviewer was not careless. There was nothing written down for the change to contradict.
An RTO derived from an architecture says which path recovery takes, which stages it passes through, what each may consume, and what each figure assumes. Against an entry like that, “transition this bucket to an archive class” is a change to a stated assumption. Against a number and a date, it is a storage decision.
An objective published without its basis is not a weak control. It is not a control at all, because no change can be shown to violate it.
Resolution
Nothing below shortens the retrieval.
Say the arithmetic out loud, first, to whoever owns the commitment: the fastest documented window for this class exceeds the objective, so it will be missed and the live decision is whether to run degraded or wait. That is an escalation, not a status update.
Choose the retrieval option once, from the vendor’s table read on the day. Then retrieve the repository index first and use it to enumerate every pack object the restore needs, so the working set goes in one pass rather than one object at a time as the restore discovers them.
RBDR_BUCKET=rbdr-offsite
RBDR_MANIFEST=/srv/rbdr-stage/rbdr-needed-objects.txt
while IFS= read -r RBDR_OBJ; do
aws s3api restore-object --bucket "$RBDR_BUCKET" --key "$RBDR_OBJ" \
--restore-request '{"Days":5,"GlacierJobParameters":{"Tier":"Standard"}}'
done < "$RBDR_MANIFEST"
Ask for an availability period covering retrieval, transfer, restore and validation with room for one retry; a copy that expires mid-restore restarts the wait. While the queue drains, do every stage that does not depend on it, and look for a copy with a shorter read path — a second region, a host snapshot, the archived write-ahead log. If the archived bucket really is the only copy, that is a 3-2-1 finding older than the lifecycle rule.
Verification
head-object reports ongoing-request="false" with an expiry-date for every
object the restore needs — observed, never inferred from elapsed time — and one
retrieved object reads successfully before the full transfer starts.
The storage class is read from the objects themselves for every recovery-critical prefix and matches the class the plan now names, and the lifecycle configuration returned by the API is compared line by line against the file applied.
A timed drill retrieves and restores one recovery point end to end, and the retrieval interval goes into the restore budget as its own row.
The republished catalogue entry names the storage class of each recovery point and a stage budget that sums to the objective, and a reader new to the estate can point at the line a transition would have to change.
Prevention
Publish the objective with the architecture and assumptions that produce it. A bare number cannot be contradicted, so no change ever fails review against it.
Give retrieval time its own row in the restore budget. It is a serial stage nothing overlaps, and it is usually the largest one here.
Gate lifecycle transitions on the recovery objectives of the data they touch, remembering that object age is not recovery point age. Make the change name the services the bucket serves and the objectives they publish.
Probe the read path from outside, on a schedule. Read one byte of the newest and the oldest recovery point and record the class and the latency. A storage-class change moves nothing an inventory-based instrument measures.
Keep recent recovery points in a class with no retrieval stage, and archive only points whose stated recovery time already contains the retrieval window.