Reported symptoms
A data-integrity defect introduced on 2026-08-07 is found on 2026-08-28. The
recovery request is narrow and specific: the nightly dump of db-prod-04 as it
stood three weeks ago, so the affected rows can be read out of it and compared.
The bucket is rbdr-backups. Versioning is enabled. The retention policy filed
against it says twelve months, and that is the figure quoted to the business on
the call.
The plain listing looks perfect. Every expected key is present, last night’s object is exactly where it should be at exactly the expected size, and the dashboard has been green every day for nine weeks: newest backup under twenty-four hours old, object count steady, zero failed jobs.
Then somebody lists the versions of the key, and there are eight. The oldest is seven days old.
The next morning the same listing still returns eight, and the oldest is still seven days old. The floor is moving.
The first theory on the call is credential misuse, because a CI identity with write access to this bucket was rotated during the same period.
Evidence provided
$ aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"{
"Rules": [
{
"ID": "rbdr-backups-tiering",
"Status": "Enabled",
"Filter": {},
"Transitions": [ { "Days": 30, "StorageClass": "STANDARD_IA" } ],
"NoncurrentVersionTransitions": [ { "NoncurrentDays": 7, "StorageClass": "STANDARD_IA" } ],
"NoncurrentVersionExpiration": { "NoncurrentDays": 7 }
}
]
}Illustrative output
$ aws s3api list-object-versions --bucket "$BUCKET" --prefix "$KEY" --query 'Versions[].[LastModified,IsLatest,VersionId]' --output text2026-08-28T01:14:52+00:00 True e0d9f1a4-8c37-4a11-9b02-6d5c7ae41f38
2026-08-27T01:12:07+00:00 False 1c8b4e6d-2f90-47aa-8c31-05be9d772a44
2026-08-26T01:13:41+00:00 False 7a45c0b2-91de-4c68-b7f0-3ce8241d9b57
2026-08-25T01:11:58+00:00 False f3620ad8-64bc-4e02-9a15-88d7c3f0e619
2026-08-24T01:12:33+00:00 False 5b91e274-0da6-49f3-8e47-2c60b1a8df05
2026-08-23T01:14:09+00:00 False c47d8f13-b5a0-4e91-83cc-79f2e604ab3d
2026-08-22T01:13:26+00:00 False 9e05a6c1-3d78-4b2f-96ad-41c8b7e250fa
2026-08-21T01:12:44+00:00 False 20f7bd39-ac41-4e85-b013-6a9d5cf82e71Illustrative output
$ aws s3api list-object-versions --bucket "$BUCKET" --prefix "$KEY" --output json | jq '(.DeleteMarkers // []) | length'0Illustrative output
That last number is the one that matters, because a delete issued by an identity does not look like this. Here is what it does look like, measured:
$ mc rm prod/rbdr-immutable/backup-0900.tar Created delete marker `prod/rbdr-immutable/backup-0900.tar` (versionId=4b3c593c-e8ad-444d-aa87-89e380a1fbae).
>>> exit code: 0
--- and with versions shown? ---
[2026-08-28 13:28:22 UTC] 0B STANDARD 4b3c593c-e8ad-444d-aa87-89e380a1fbae v2 DEL backup-0900.tar
[2026-08-28 13:28:20 UTC] 38B STANDARD 133fd99f-1f98-41c0-9d08-95e6e2944157 v1 PUT backup-0900.tar
>>> exit code: 0$ grep -oE 'S3\.EXPIRE\.OBJECT|REST\.DELETE\.OBJECT' "$LOG" | sort | uniq -c 59854 S3.EXPIRE.OBJECTIllustrative output
$ git show 9f4c1ab -- storage/rbdr-backups/lifecycle.jsonDate: Wed Jun 24 10:41:06 2026 +0000
storage: move rbdr-backups to a cheaper tier after the Q2 spend review
--- a/storage/rbdr-backups/lifecycle.json
+++ b/storage/rbdr-backups/lifecycle.json
@@ -3,7 +3,11 @@
"ID": "rbdr-backups-tiering",
"Status": "Enabled",
"Filter": {},
- "Transitions": [ { "Days": 30, "StorageClass": "STANDARD_IA" } ]
+ "Transitions": [ { "Days": 30, "StorageClass": "STANDARD_IA" } ],
+ "NoncurrentVersionTransitions": [ { "NoncurrentDays": 7, "StorageClass": "STANDARD_IA" } ],
+ "NoncurrentVersionExpiration": { "NoncurrentDays": 7 }
}Illustrative output
The monitoring configuration alerts on newest recovery point age and on job exit status. It has nothing referencing the oldest recovery point or the version count.
Work the evidence before reading on
- The backup writer uses one key and overwrites it nightly. Where, then, did the twelve months of history live, and which clause in that document reaches it?
- The captured MinIO run and the listing disagree about what a deletion leaves behind. What does that disagreement rule out?
- The oldest version was seven days old yesterday and is seven days old today. What shape of cause produces a floor that moves, and what shape produces one that does not?
- Nine weeks of green dashboards. What were all three of those metrics measuring, and what were none of them measuring?
Root cause
The rule was scoped to everything, and reached the only copy of the history
The backup writer overwrites rbdr-backups/db-prod-04/daily.tar.zst every
night. That means the twelve months of history existed entirely as non-current
versions — there was never a second key to hold it. NoncurrentVersionExpiration
is the one clause that reaches exactly that set, and the change set it to seven
days with an empty filter, which selects every object in the bucket.
The change was raised, reviewed and approved as a tiering change, and the diff shows why that survived review: the transition it was written for and the expiration that arrived with it sit in the same hunk of the same JSON object. Nothing in the vocabulary separates them by risk.
Nothing measured the number the policy was about
Newest recovery point age, job exit status and object count are all properties of the current object, and the current object was never touched. All three stayed correct while the recoverable depth fell from three hundred and sixty-five days to seven.
Resolution
Stop the loss first. Every hour the rule stays enabled consumes more of the seven days that remain.
BUCKET=rbdr-backups
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" > /tmp/rbdr-lifecycle-before.json
aws s3api delete-bucket-lifecycle --bucket "$BUCKET"
Removing the whole document is the fastest safe action, because a PUT of a
lifecycle configuration replaces the entire document rather than merging into
it. Keep the captured copy — it is the evidence.
Preserve what survives before anything else touches the bucket, and record the version identifiers you copied.
Then answer the recovery question honestly. The 2026-08-07 recovery point does not exist here, and an expired version does not come back. Look for it in the secondary repository, the off-site replica, an archive tier with its own retention, or a filesystem snapshot on the host that produced the dump. If this bucket held the only copy, that is a 3-2-1 failure that predates the lifecycle rule; the rule is what made it visible.
Verification
aws s3api get-bucket-lifecycle-configuration returns the intended document and
nothing else, compared against the file that was applied rather than skimmed.
The non-current version count for each protected key grows by one per night instead of holding constant. That check takes two consecutive days and is the only one that distinguishes a repaired retention from one still being trimmed.
Oldest recovery point age is emitted as a metric, appears beside the newest, and has a threshold that fires when it falls.
A restore is performed from the oldest surviving recovery point onto isolated infrastructure, and the dump is loaded and checked. Until that has happened, the remaining seven days are a claim.
Prevention
Report the oldest recovery point, not only the newest. Two numbers per protected key. The first says the backup ran; only the second says the retention window still exists. Nine weeks of green came from measuring one of them.
Alert on recoverable version count per key. A count that stops growing fires within a day of a rule taking effect.
Treat a lifecycle change as a retention change — same change class, same approval from the data owner, same review. Require the reviewer to state which of the four expiration clauses the diff touches.
Scope the filter, and re-scope it when the bucket changes. An empty prefix selects every object, including keys that did not exist when the rule was written.
Give history its own keys. A writer that produces a distinct key per night keeps its history in current objects, where a non-current expiration cannot reach it at all.
Consider object lock where a retention floor is required, remembering that it cannot be enabled on a bucket that already exists and must be chosen at creation, and that it bounds an expiration only for versions carrying a retention and only until the retain-until date passes.