Operator asked, 2026-08-07: "check if the kb backup gets KEPT this time and not REPLACED right away, as it happened seven times already."
Direct answer: nothing was deleted. Retention is keepMinimum: 3, maxDays: 30 (configBase.mjs:1744-1746), 10 bundles exist, and the good ones are days inside the age bound. But the question behind the question is real, and the measurement is worse than a deletion would be.
Observed — every bundle in the real backup root
/Users/tobiasuhlig/.neo-ai/backups, wc -l on each bundle's kb/*.jsonl:
bundle
kb bytes
kb rows
bundle-meta.json
2026-07-31T04-57
0
0
yes
2026-08-01T05-01
0
0
yes
2026-08-01T12-13
3,368,227,090
61,206
yes
2026-08-03T08-09
107,196,660
2,001
NO
2026-08-03T21-37
3,287,574,636
59,754
yes
2026-08-04T08-38
0
0
yes
2026-08-04T08-55
0
0
yes
2026-08-05T09-39
0
0
yes
2026-08-05T19-05
0
0
yes
2026-08-06T13-09 (NEWEST)
900,214,445
16,550
yes
Six of ten kept bundles carry zero KB rows. Each of those six carries a valid bundle-meta.json. Two bundles hold a real corpus. The newest holds 16,550 — the degraded canonical, not the 59,754 the corpus should be.
#16563 owns "the KB export reports Export complete on zero rows, and the bundle looks successful", and it already names this near-miss: had the newest bundle been chosen for a --mode replace, an empty collection would have been written over a populated one. That ticket is about the receipt. This one is about the policy that consumes it.
Retention counts artifacts, not recoverability.keepMinimum: 3 is satisfied by any three directories. Given the table above, the three newest bundles are 08-05T19-05 (0 rows), 08-06T13-09 (16,550 degraded), and 08-05T09-39 (0 rows) — a "kept minimum" containing no recoverable KB corpus at all. The 59,754-row bundle survives only because it is 4 days old against a 30-day bound; it is protected by age, not by policy, and nothing in retention knows it is the only real one.
That is what "gets replaced right away" looks like in practice. No file is deleted. The good bundle is simply buried behind newer bundles that pass every structural check and contain nothing, and it will age out on a clock that has no idea what it is holding. Restore-the-newest — the obvious operator move, and the one the tooling nudges toward — yields 16,550 degraded rows.
The 2,001-row bundle is the tell that this is systemic, not a one-off empty export: 08-03T08-09 has no bundle-meta.json at all yet still sits in the retention set as a countable bundle. Retention discovery is bundle-shaped-directory, and a partial capture is bundle-shaped.
The Fix
Retention must rank on recoverability, not recency. Concretely:
keepMinimum must be satisfied by RESTORABLE bundles. A bundle with nothing to restore cannot count toward the floor. The floor exists to guarantee a recovery path; counting empty bundles toward it makes it decorative.
Never prune the newest restorable bundle per substrate, independent of maxDays. Age is the wrong axis for the last known-good artifact — a 31-day-old 59,754-row bundle beats a 1-day-old empty one, and today's set makes that concrete rather than hypothetical.
Surface per-substrate payload size in the retention decision and its log, so a sweep states what it is keeping and dropping rather than counting directories.
A bundle missing bundle-meta.json cannot fill the floor and cannot be last-known-good — a partial capture is not a verified recovery source, and 08-03T08-09 shows it currently reads as a peer of a complete capture.
Truth-fold — three ACs as originally written were wrong, corrected in place
Written before implementation, and implementation falsified them. Recording the divergence rather than silently editing, because a reviewer checks the shipped behaviour against these lines.
Rows → bytes. ACs 1/3 said "non-zero KB row count". wc -l over a 3.3 GB JSONL on every sweep makes retention cost scale with corpus size and buys nothing: retention cannot judge degraded, only empty. It does not know what a complete corpus is — the newest bundle here is 900 MB / 16,550 rows against an expected ~60,000, non-empty by either measure. Bytes answer the question retention can actually ask.
Bytes alone are not sufficient either. Non-zero bytes established non-empty and were read as restorable, which certified a bundle recording kb: fail (sourceCount 2, bundleCount 1) and deleted an older kb: pass one. Bytes establish non-empty; the receipt's integrity[].status establishes parity. Certification requires both, per substrate.
And "not certifiable" turned out not to mean "deletable".skipped — emitted whenever the integrity verifier gets a non-numeric source count — plus any status this reader does not recognize, are neither certifiable nor reclaimable: their contents were never verified in either direction. Three dispositions, not two. RestorationRunbook.md already documented both halves of this (a third restorable: null state, and the exact-string-match hazard for unrecognized tokens); it was in the corpus and not swept before implementation.
Meta-less: excluded-from-candidacy → age-deletable. AC 4 said a meta-less bundle "is not a retention candidate". Shipped narrower on purpose: it cannot fill the floor and cannot be last-known-good, but it stays deletable. Excluding it from deletion too would let residue accumulate forever, trading one unbounded-growth bug for another. The asymmetry that does hold: absent meta is a known-incomplete capture (deletable), while unreadable payload or malformed meta is a bundle whose state cannot be determined at all (hard keep). Unknown is not empty.
Contract Ledger Matrix
Target Surface
Source of Authority
Proposed Behavior
Fallback
Docs
Evidence
retention sweep in ai/scripts/maintenance/backup.mjs
this ticket; configBase.mjs:1744
rank + floor on bundles certified restorable per substrate — non-zero payload bytes ANDintegrity[].status: pass; never prune newest-restorable per substrate
hard-keep on unreadable payload, malformed receipt, or indeterminate status — a bundle that could not be evaluated is never reclaimed
script JSDoc
6/10 kept bundles at 0 KB rows
retention.keepMinimum semantics
configBase.mjs:1745
redefined as certified-restorable bundles per substrate, unioned — not directories, and not "any restorable bundle": a floor filled by three bundles that all lack kb guarantees nothing about kb
three-way, replacing the original binary "residue is not a candidate": meta-less cannot fill the floor or become newest-restorable but stays age-deletable; malformed receipt, unreadable payload, or skipped/unrecognized status hard-keeps. Discovery deliberately does NOT require bundle-meta.json — it accepts any backup-* directory and the receipt is judged in classification, so an unevaluable bundle is still seen rather than silently skipped
—
inline
08-03T08-09 counted with no meta
Acceptance Criteria
keepMinimum counts only bundles certified restorable for that substrate — non-zero payload bytes ANDintegrity[].status: pass for it. A fixture with three empty bundles plus one populated one keeps the populated one. Per substrate and unioned: a floor filled by three bundles that all lack kb guarantees nothing about kb.
The newest restorable bundle per substrate is never pruned, including past maxDays — fixture: one 40-day-old populated bundle and several fresh empty ones.
A bundle without bundle-meta.jsoncannot fill the floor and cannot be newest-restorable, but stays age-deletable, asserted against the real 08-03T08-09 shape (present, meta-less, 2,001 rows).
An unreadable payload or malformed receipt is hard-kept — never floor-filling, never age-deletable. Unknown is not empty, and a bundle that could not be evaluated must not be reclaimed.
A skipped or unrecognizedintegrity[].status is hard-kept too: not certifiable, and not reclaimable either. Asserted for the live skipped value and for a synthetic unrecognized token, so a status added by the producer later fails toward keeping.
The retention sweep logs per-substrate byte counts for every bundle it keeps and drops, including age-held keeps. A sweep that reports only names cannot be audited afterwards, which is how six empty bundles accumulated unnoticed.
Negative control: with several populated bundles and none empty, retention prunes exactly as it does today — the change must not become "never prune anything".
Verified against the live set: a dry-run over the ten bundles above names 08-01T12-13 (61,206) and 08-03T21-37 (59,754) as the protected restorable pair.
Out of Scope
The zero-row export itself — #16563. This ticket assumes empty bundles will keep happening and refuses to let them satisfy the recovery floor. Both halves are needed: a correct receipt stops producing them, a correct policy stops them from displacing real ones.
Why the KB half was empty on those six runs — belongs to #16563 / #16561 (backup starvation).
Deleting the empty bundles. They are evidence for #16563 and cost little; disposition is a follow-up, not this fix.
Changing maxDays. The bound is not the defect; what it is allowed to reach is.
Avoided Traps
Reading "nothing was deleted" as "the backups are fine." That was my first answer and it was the wrong subject. The operator's phrase was "KEPT and not REPLACED", and displacement — a real bundle buried behind valid-looking empty ones — is functionally replacement without a deletion event. A retention audit that only checks for missing directories confirms exactly the wrong property.
Measuring the wrong root. I first inventoried <checkout>/.neo-ai-data/backups and reported "newest bundle is 8 days old". That directory is stale local data; the real root is the bind-mounted /Users/tobiasuhlig/.neo-ai/backups, and configBase.mjs:259 states the reason in its own planeMemberReason: "a plane-anchored default resolves the backup root inside the checkout, where ordinary repository operations delete it". The config had already predicted the trap I walked into.
Trusting bundle size as a proxy for content.08-06T13-09 is 900 MB and looks substantial next to a 0-byte bundle — it holds 16,550 degraded rows against an expected ~60,000.
(Corrected 2026-08-07, same truth-fold as above — this paragraph originally ended "only the row count discriminates, which is why it is the AC's unit", which is wrong twice over and contradicted the section four paragraphs up. Neither unit discriminates degraded. A row count of 16,550 is no more self-evidently wrong than 900 MB is; both are non-empty, and calling either one "degraded" requires knowing what complete means — which retention does not and should not. That is the whole reason the shipped guard claims only "nothing to restore" and explicitly disclaims judging completeness. What actually discriminates here is the receipt's per-substrate integrity[].status, and this line survived two earlier passes over the same body because it argued for the right conclusion — distrust size — from the wrong premise.)
Related
#16563 — the lying receipt; this is its policy-side twin, and the near-miss it records is the same one.
#16561 — backup priority-0 starvation; explains gaps in the cadence.
#16549 — the corpus-loss incident these bundles were the recovery path for.
#16599 / PR #16607 — merge identity: restoring the wrong bundle into a populated collection is the case that PR now refuses.
configBase.mjs:259 — the backup-root escape hatch and its stated reason.
Live latest-open sweep: checked the latest open issues at 2026-08-07T04:3xZ; #16563 read in full to separate scope (it owns the receipt, not retention); no equivalent found.
Context
Operator asked, 2026-08-07: "check if the kb backup gets KEPT this time and not REPLACED right away, as it happened seven times already."
Direct answer: nothing was deleted. Retention is
keepMinimum: 3,maxDays: 30(configBase.mjs:1744-1746), 10 bundles exist, and the good ones are days inside the age bound. But the question behind the question is real, and the measurement is worse than a deletion would be.Observed — every bundle in the real backup root
/Users/tobiasuhlig/.neo-ai/backups,wc -lon each bundle'skb/*.jsonl:bundle-meta.jsonSix of ten kept bundles carry zero KB rows. Each of those six carries a valid
bundle-meta.json. Two bundles hold a real corpus. The newest holds 16,550 — the degraded canonical, not the 59,754 the corpus should be.The Problem — distinct from #16563
#16563 owns "the KB export reports Export complete on zero rows, and the bundle looks successful", and it already names this near-miss: had the newest bundle been chosen for a
--mode replace, an empty collection would have been written over a populated one. That ticket is about the receipt. This one is about the policy that consumes it.Retention counts artifacts, not recoverability.
keepMinimum: 3is satisfied by any three directories. Given the table above, the three newest bundles are08-05T19-05(0 rows),08-06T13-09(16,550 degraded), and08-05T09-39(0 rows) — a "kept minimum" containing no recoverable KB corpus at all. The 59,754-row bundle survives only because it is 4 days old against a 30-day bound; it is protected by age, not by policy, and nothing in retention knows it is the only real one.That is what "gets replaced right away" looks like in practice. No file is deleted. The good bundle is simply buried behind newer bundles that pass every structural check and contain nothing, and it will age out on a clock that has no idea what it is holding. Restore-the-newest — the obvious operator move, and the one the tooling nudges toward — yields 16,550 degraded rows.
The 2,001-row bundle is the tell that this is systemic, not a one-off empty export:
08-03T08-09has nobundle-meta.jsonat all yet still sits in the retention set as a countable bundle. Retention discovery isbundle-shaped-directory, and a partial capture is bundle-shaped.The Fix
Retention must rank on recoverability, not recency. Concretely:
keepMinimummust be satisfied by RESTORABLE bundles. A bundle with nothing to restore cannot count toward the floor. The floor exists to guarantee a recovery path; counting empty bundles toward it makes it decorative.maxDays. Age is the wrong axis for the last known-good artifact — a 31-day-old 59,754-row bundle beats a 1-day-old empty one, and today's set makes that concrete rather than hypothetical.bundle-meta.jsoncannot fill the floor and cannot be last-known-good — a partial capture is not a verified recovery source, and08-03T08-09shows it currently reads as a peer of a complete capture.Truth-fold — three ACs as originally written were wrong, corrected in place
Written before implementation, and implementation falsified them. Recording the divergence rather than silently editing, because a reviewer checks the shipped behaviour against these lines.
Rows → bytes. ACs 1/3 said "non-zero KB row count".
wc -lover a 3.3 GB JSONL on every sweep makes retention cost scale with corpus size and buys nothing: retention cannot judge degraded, only empty. It does not know what a complete corpus is — the newest bundle here is 900 MB / 16,550 rows against an expected ~60,000, non-empty by either measure. Bytes answer the question retention can actually ask.Bytes alone are not sufficient either. Non-zero bytes established non-empty and were read as restorable, which certified a bundle recording
kb: fail (sourceCount 2, bundleCount 1)and deleted an olderkb: passone. Bytes establish non-empty; the receipt'sintegrity[].statusestablishes parity. Certification requires both, per substrate.And "not certifiable" turned out not to mean "deletable".
skipped— emitted whenever the integrity verifier gets a non-numeric source count — plus any status this reader does not recognize, are neither certifiable nor reclaimable: their contents were never verified in either direction. Three dispositions, not two.RestorationRunbook.mdalready documented both halves of this (a thirdrestorable: nullstate, and the exact-string-match hazard for unrecognized tokens); it was in the corpus and not swept before implementation.Meta-less: excluded-from-candidacy → age-deletable. AC 4 said a meta-less bundle "is not a retention candidate". Shipped narrower on purpose: it cannot fill the floor and cannot be last-known-good, but it stays deletable. Excluding it from deletion too would let residue accumulate forever, trading one unbounded-growth bug for another. The asymmetry that does hold: absent meta is a known-incomplete capture (deletable), while unreadable payload or malformed meta is a bundle whose state cannot be determined at all (hard keep). Unknown is not empty.
Contract Ledger Matrix
ai/scripts/maintenance/backup.mjsconfigBase.mjs:1744integrity[].status: pass; never prune newest-restorable per substrateretention.keepMinimumsemanticsconfigBase.mjs:1745kbguarantees nothing aboutkbbackup.mjs:981(discovery),classifyBundleRecoverabilityskipped/unrecognized status hard-keeps. Discovery deliberately does NOT requirebundle-meta.json— it accepts anybackup-*directory and the receipt is judged in classification, so an unevaluable bundle is still seen rather than silently skipped08-03T08-09counted with no metaAcceptance Criteria
keepMinimumcounts only bundles certified restorable for that substrate — non-zero payload bytes ANDintegrity[].status: passfor it. A fixture with three empty bundles plus one populated one keeps the populated one. Per substrate and unioned: a floor filled by three bundles that all lackkbguarantees nothing aboutkb.maxDays— fixture: one 40-day-old populated bundle and several fresh empty ones.bundle-meta.jsoncannot fill the floor and cannot be newest-restorable, but stays age-deletable, asserted against the real08-03T08-09shape (present, meta-less, 2,001 rows).skippedor unrecognizedintegrity[].statusis hard-kept too: not certifiable, and not reclaimable either. Asserted for the liveskippedvalue and for a synthetic unrecognized token, so a status added by the producer later fails toward keeping.08-01T12-13(61,206) and08-03T21-37(59,754) as the protected restorable pair.Out of Scope
--mode replacerestore safety. #16591 covers refusal below the agent-facing surface; #16599 covers merge identity.maxDays. The bound is not the defect; what it is allowed to reach is.Avoided Traps
Reading "nothing was deleted" as "the backups are fine." That was my first answer and it was the wrong subject. The operator's phrase was "KEPT and not REPLACED", and displacement — a real bundle buried behind valid-looking empty ones — is functionally replacement without a deletion event. A retention audit that only checks for missing directories confirms exactly the wrong property.
Measuring the wrong root. I first inventoried
<checkout>/.neo-ai-data/backupsand reported "newest bundle is 8 days old". That directory is stale local data; the real root is the bind-mounted/Users/tobiasuhlig/.neo-ai/backups, andconfigBase.mjs:259states the reason in its ownplaneMemberReason: "a plane-anchored default resolves the backup root inside the checkout, where ordinary repository operations delete it". The config had already predicted the trap I walked into.Trusting bundle size as a proxy for content.
08-06T13-09is 900 MB and looks substantial next to a 0-byte bundle — it holds 16,550 degraded rows against an expected ~60,000.(Corrected 2026-08-07, same truth-fold as above — this paragraph originally ended "only the row count discriminates, which is why it is the AC's unit", which is wrong twice over and contradicted the section four paragraphs up. Neither unit discriminates degraded. A row count of 16,550 is no more self-evidently wrong than 900 MB is; both are non-empty, and calling either one "degraded" requires knowing what complete means — which retention does not and should not. That is the whole reason the shipped guard claims only "nothing to restore" and explicitly disclaims judging completeness. What actually discriminates here is the receipt's per-substrate
integrity[].status, and this line survived two earlier passes over the same body because it argued for the right conclusion — distrust size — from the wrong premise.)Related
configBase.mjs:259— the backup-root escape hatch and its stated reason.Origin Session ID: 4141258c-36d3-4788-b0c2-ab3ebe0867be
Live latest-open sweep: checked the latest open issues at 2026-08-07T04:3xZ; #16563 read in full to separate scope (it owns the receipt, not retention); no equivalent found.
Retrieval Hint:
query_raw_memories("backup retention keepMinimum empty kb bundle restorable")·ai/scripts/maintenance/backup.mjs·configBase.mjs:1744Authored by @neo-opus-vega (Claude Opus 5).