Context
#16348 AC3 remains after its newest-valid selection half merged and its capture-continuity half moved to #16404. Live source inspection on current origin/dev found a deterministic path from an ordinary export failure to a restore-authorized torn bundle; this is not limited to an abrupt process kill.
Live latest-open sweep: checked the latest 20 open issues at 2026-08-02T22:17Z; no equivalent ticket exists. The closest artifacts are #16348 (the parent problem ledger) and #16404 (collection continuity, a different mechanism). A2A all-read-state sweep over the latest 30 messages found no overlapping lane claim; Ada’s active plan round is explicitly scoped to #16404.
The historical contract already calls this an atomic timestamped bundle: #10129 established one backup-<timestamp>/ as the indivisible recovery unit. This ticket completes that contract at the publication boundary rather than changing the bundle format.
The Problem
A final restore-candidate directory becomes visible before the capture has earned that status.
ai/scripts/maintenance/backup.mjs currently:
- resolves the final
backup-* path (runBackup, around line 263);
- creates its complete directory tree before the first export (around line 288);
- streams KB, MC, graph, and file-backed members directly into that final tree;
- runs integrity verification only after those writes (around line 342);
- writes
bundle-meta.json only after every export and integrity check succeeds (around line 378).
If an export throws after earlier members were written, runBackupWithOffHostSync() writes last-backup-receipt.json at the backup root with bundleName: null; the torn directory itself carries no failure identity.
The restore side compounds that publication error. validateBundle() treats an absent bundle-meta.json as a legacy bundle, returns a synthetic {legacy: true} receipt, and probeBundle() authorizes RESTORABLE whenever the rows it streamed total above zero. A partial capture can therefore shadow a complete older recovery source while accurately being the newest backup-* directory.
The Architectural Reality
- Publication belongs to the producer. Teaching every consumer another torn-bundle heuristic leaves the final namespace lying.
runBackup() owns when a candidate becomes a candidate.
- The final directory name is the recovery admission surface. Restore, retention, health, deployment preflight, and off-host sync all discover or identify bundles through the published
backup-* path.
bundle-meta.json is completion evidence, but today it is not the publication fence. It lands after integrity verification inside a directory that has been externally visible throughout the run.
- The same-filesystem parent provides the atomic primitive. A staging directory created beside the destination can be renamed into the final name only after the meta receipt exists. No cross-store transaction is claimed.
- Owning placement: existing
ai/scripts/maintenance/backup.mjs (structure map: ai/scripts/maintenance/, existing file; no new .mjs placement). Existing unit witnesses live in test/playwright/unit/ai/scripts/maintenance/backup.spec.mjs and restore.spec.mjs.
The Fix
Make runBackup() a publish-on-complete transaction over directory identity:
- Resolve the requested final root exactly as today and fail loud if it already exists; never overwrite a prior recovery source.
- Create a unique sibling staging directory whose basename cannot match the root-level
backup-* candidate filter, while retaining the intended final basename for diagnosis (for example .backup-partial-<final-name>-<unique>).
- Point the entire current layout and every export at the staging root.
- Write the existing
bundle-meta.json in staging after the existing integrity gate and await write completion. This is the publication fence; it does not claim power-loss durability because the current writer does not fsync the files or directory.
- Atomically rename the same-filesystem staging directory to the final root.
- Return only the final
bundleRoot; runBackupWithOffHostSync() therefore cannot sync or receipt-name a bundle before publication.
- Remove the owned staging directory on caught failures and rethrow. An uncatchable process termination may leave a
.backup-partial-* directory, but it is self-identifying and outside every backup-* recovery candidate set by construction.
The final bundle layout and bundle-meta.json schema do not change.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback / Edge Case |
Docs |
Evidence |
runBackup({bundleRoot}) publication lifecycle |
#10129 atomic-bundle contract + #16348 AC3 |
All writes target a non-candidate sibling; final root appears via same-filesystem rename only after bundle-meta.json and integrity success |
Existing destination fails loud; caught failure removes only this run’s staging root |
Update JSDoc to name final-vs-staging semantics |
Unit: failure, success, existing-destination, and explicit-root cases |
| Root-level bundle candidate namespace |
verifyLatestBackupRestorable() and cleanOldBackups() current backup-* discovery |
Only published final directories match; .backup-partial-* residue is never restore/retention input |
Abrupt-death residue remains diagnostic, not authoritative |
Inline invariant at producer + probe fixture |
Restore spec: valid-looking partial residue is never examined |
runBackupWithOffHostSync() result consumption |
Existing off-host wrapper contract |
Sync and success receipt consume the final returned path only after publication |
Backup failure keeps bundleName: null and never invokes sync |
No external schema change |
Existing wrapper seam plus publication-order assertion |
Surface anchors were verified against current source before filing; the proposed staging namespace is new and owned by this ticket.
Decision Record impact
none. This completes the existing atomic-bundle publication contract and adds no config, service boundary, MCP surface, or persistent schema field.
Acceptance Criteria
Out of Scope
#16348 AC5 retry/cadence policy. It needs an explicit retry clock, budget, restart persistence, config authority, and exhausted-budget observability before implementation.
#16404 collection-identity continuity and captured / empty / unavailable semantics.
- Reclassifying or deleting historical torn
backup-* directories already on disk.
- Automatic retention of abrupt-death
.backup-partial-* residue. It is excluded from recovery authority here; a cleanup policy should be evidence-led rather than silently deleting forensic artifacts.
- Changing the final bundle layout, receipt schema, restore import semantics, or off-host transport.
Avoided Traps
- Restore-only heuristic. Rejecting one observed torn shape leaves future consumers and new partial shapes exposed; publication must be truthful at the owner.
- Writing a failure receipt after the fact. A hard process termination cannot run a catch block. The staging name must be non-authoritative before the first byte is written.
- Deleting the final directory on error. It is briefly authoritative and creates a race in which a consumer can observe it. Never publish is stronger than publish-then-delete.
- Cross-filesystem move/copy fallback. Atomicity relies on staging beside the destination and using rename semantics; a copy fallback recreates the partial-visibility defect.
- Folding retry policy into this repair. Artifact publication and scheduler cadence have different authorities and falsifiers.
Related
- Parent problem ledger: #16348
- Capture-continuity sibling: #16404
- Newest-valid selection shipped by PR #16385
- Native graph export completeness shipped by PR #16409
- Original atomic-bundle contract: #10129
Origin Session ID: 97816bde-fe7c-4b23-98c4-fbe4d89ed53d
Retrieval Hint: query_raw_memories("backup torn directory bundle-meta absent atomic publish staging restore legacy RESTORABLE")
Context
#16348AC3 remains after its newest-valid selection half merged and its capture-continuity half moved to#16404. Live source inspection on currentorigin/devfound a deterministic path from an ordinary export failure to a restore-authorized torn bundle; this is not limited to an abrupt process kill.Live latest-open sweep: checked the latest 20 open issues at
2026-08-02T22:17Z; no equivalent ticket exists. The closest artifacts are #16348 (the parent problem ledger) and #16404 (collection continuity, a different mechanism). A2A all-read-state sweep over the latest 30 messages found no overlapping lane claim; Ada’s active plan round is explicitly scoped to#16404.The historical contract already calls this an atomic timestamped bundle: #10129 established one
backup-<timestamp>/as the indivisible recovery unit. This ticket completes that contract at the publication boundary rather than changing the bundle format.The Problem
A final restore-candidate directory becomes visible before the capture has earned that status.
ai/scripts/maintenance/backup.mjscurrently:backup-*path (runBackup, around line 263);bundle-meta.jsononly after every export and integrity check succeeds (around line 378).If an export throws after earlier members were written,
runBackupWithOffHostSync()writeslast-backup-receipt.jsonat the backup root withbundleName: null; the torn directory itself carries no failure identity.The restore side compounds that publication error.
validateBundle()treats an absentbundle-meta.jsonas a legacy bundle, returns a synthetic{legacy: true}receipt, andprobeBundle()authorizesRESTORABLEwhenever the rows it streamed total above zero. A partial capture can therefore shadow a complete older recovery source while accurately being the newestbackup-*directory.The Architectural Reality
runBackup()owns when a candidate becomes a candidate.backup-*path.bundle-meta.jsonis completion evidence, but today it is not the publication fence. It lands after integrity verification inside a directory that has been externally visible throughout the run.ai/scripts/maintenance/backup.mjs(structure map:ai/scripts/maintenance/, existing file; no new.mjsplacement). Existing unit witnesses live intest/playwright/unit/ai/scripts/maintenance/backup.spec.mjsandrestore.spec.mjs.The Fix
Make
runBackup()a publish-on-complete transaction over directory identity:backup-*candidate filter, while retaining the intended final basename for diagnosis (for example.backup-partial-<final-name>-<unique>).bundle-meta.jsonin staging after the existing integrity gate and await write completion. This is the publication fence; it does not claim power-loss durability because the current writer does notfsyncthe files or directory.bundleRoot;runBackupWithOffHostSync()therefore cannot sync or receipt-name a bundle before publication..backup-partial-*directory, but it is self-identifying and outside everybackup-*recovery candidate set by construction.The final bundle layout and
bundle-meta.jsonschema do not change.Contract Ledger Matrix
runBackup({bundleRoot})publication lifecyclebundle-meta.jsonand integrity successverifyLatestBackupRestorable()andcleanOldBackups()currentbackup-*discovery.backup-partial-*residue is never restore/retention inputrunBackupWithOffHostSync()result consumptionbundleName: nulland never invokes syncSurface anchors were verified against current source before filing; the proposed staging namespace is new and owned by this ticket.
Decision Record impact
none. This completes the existing atomic-bundle publication contract and adds no config, service boundary, MCP surface, or persistent schema field.Acceptance Criteria
bundle-meta.jsoncomplete, the finalbundleRootdoes not exist.backup-*directory and rethrows the original failure; the wrapper still writes its existing root-level failed receipt and never invokes off-host sync..backup-partial-*fixture containing parseable recovery rows but no meta is never examined byverifyLatestBackupRestorable(); an older valid published bundle remains the selected recovery source.bundleRootoverrides retain their current caller-visible semantics while staging beside that destination on the same filesystem.backup-*candidate namespace) makes the focused guard fail.Out of Scope
#16348AC5 retry/cadence policy. It needs an explicit retry clock, budget, restart persistence, config authority, and exhausted-budget observability before implementation.#16404collection-identity continuity andcaptured/empty/unavailablesemantics.backup-*directories already on disk..backup-partial-*residue. It is excluded from recovery authority here; a cleanup policy should be evidence-led rather than silently deleting forensic artifacts.Avoided Traps
Related
Origin Session ID: 97816bde-fe7c-4b23-98c4-fbe4d89ed53d
Retrieval Hint:
query_raw_memories("backup torn directory bundle-meta absent atomic publish staging restore legacy RESTORABLE")