package backup // R-403 — a POORER copy must never delete a RICHER one. // // Measured on demo-hp 2026-08-31, on the shipped v0.229.0: an app's Tier-2 copy went from // 120 082 104 bytes (4 database dumps + 3 named-volume tars) to 7 036 bytes (none of either) in one // nightly run, and the run recorded itself as a SUCCESS — `Tier 2 copied docmost → … (14.9 KB, // 0 leg(s), 0s)`. Evidence: `felhom.eu/documentation/audits/DRILL-r403-tier2-delete-2026-08-31/`. // // THE MECHANISM, in three lines of existing code that were each individually correct: // 1. `RunTier2` guards the unit leg with `os.Stat(unitDir)` — *does the folder exist*. // 2. `rsyncMirror` is `rsync -a --delete` — an exact mirror, which is what a derived copy must be. // 3. Nothing between them compares the source to the destination. // An EMPTY recovery unit is a folder that exists. So a primary unit that had lost its dumps — after a // restore, a failed dump run, a crash mid-capture, a remount — was mirrored over a complete copy, and // `--delete` removed the customer's last surviving package. // // WHAT THIS FILE DELIBERATELY DOES **NOT** DO: it does not make the Tier-2 copy un-shrinkable. // `07-backup-architecture.md` §8 row 5 records that the secondary is a DERIVED copy, rebuilt on the // next run ("Migration = rebuild, not preserve"), and `tier2.go`'s own header records that a // classified app's copy legitimately shrinks as `export` drops out of its class set. Fencing // shrinkage would be calling a decision a defect. The fence here is exactly one shape: a source that // carries NO data replacing a destination that carries some. // unitCarriesData reports whether a recovery-unit DIRECTORY holds RECOVERABLE DATA — the app's // database dumps or its named-volume tars. // // IT ASKS THE MANIFEST, NEVER THE BYTE SIZE, and that is the whole design of the predicate. A unit // with a large compose tree and no dumps is dangerous; a tiny unit belonging to a tiny app is fine. // Size answers "how big", and the question here is "is there anything to recover". `dirSizeBytes` // exists two files away and would have been the obvious wrong answer — TestR403_SizeIsNeverConsulted // is the guard that keeps it out. // // FAIL CLOSED on an absent or unparseable manifest: `readManifest` returns nil for both, and a unit // whose manifest cannot be read is a unit whose contents cannot be vouched for. Treating it as // data-bearing would let an unreadable source authorise a delete. // // ONE predicate, every caller. The mirror guard and the post-restore rehydrate both ask this // function; two copies of the definition is how the two halves of a fix drift apart. func unitCarriesData(unitDir string) bool { man := readManifest(UnitManifestFile(unitDir)) if man == nil { return false } return len(man.DBDumps) > 0 || len(man.VolumeDumps) > 0 } // unitIsHollow is `unitCarriesData` negated, named for the way both callers actually ask it. It is a // separate function only so the call sites read as the question they are asking. func unitIsHollow(unitDir string) bool { return !unitCarriesData(unitDir) }