Files
felhom-controller/controller/internal/backup/r403_hollow.go
T
admin e43b5ec07d
gates / gates (push) Successful in 11s
v0.231.0 - the box proves its own off-site copy still holds something (R-87)
R-87 re-scoped by its own spike and built as Option C. MinAgent 0.129.0 unchanged.

THE QUESTION NOTHING ASKED. The weekly check proves the stored bytes are the bytes we
stored; it cannot tell us we stored the WRONG thing. A hollow recovery unit backs up
cleanly, checks cleanly at 100 percent depth, restores cleanly and gives the customer
nothing back - measured on demo-hp 2026-08-31, 120082104 B to 7036 B in one nightly run
recorded as a success (R-403). No tier and no cadence asked it. Now offsite-proof does,
nightly, on one app.

IT DOES NOT prove a restore puts data back into a running app. That stays drill work and
07 section 8 matrix row 4 is NOT moved.

THE ACCEPTANCE RULE HAS TWO PARTS AND THE OBVIOUS ONE IS A TRAP. "Check the unit against
its own packing list" PASSES a hollow unit, because a hollow unit declares nothing. So:
(1) everything declared is present, AND (2) the manifest declares what the app is supposed
to have. Part 2 is the whole value. RED-PROOFED: the naive rule makes the hollow-unit test
read verdict "pass".

THE EXPECTATION COMES FROM INSIDE THE UNIT, never the live box - the snapshot may predate
the app's shape, and GetDockerVolumes describes the running app. Database half is
DBServiceNames, the same discriminator RestoreFromRecoveryUnit uses. Volume half is
ParseComposeNamedVolumes as an EXISTENCE check, not a name match: tars are
<project>_<volume>.tar and ResolveDockerVolumeNames derives the project from the compose
file's parent dir, which inside a unit is the literal string "compose". Measured on all
eight real units on demo-hp the counts match exactly and the naming held every time - but
"held on eight" is not "derivable" (R-355). Half a rule that is true beats a whole rule
that is invented.

THREE OUTCOMES: pass, fail (readable and empty), cannot judge. An app that legitimately
has neither a database nor volumes PASSES. RED-PROOFED: alarming on any empty unit makes
that test read verdict "fail".

IT NEVER WRITES TO THE REPOSITORY and that is asserted on the ARGV as a non-effect:
--no-lock, no unlockStale, and m.runner() rather than resticStep so the unlock --remove-all
escalation is unreachable. RED-PROOFED: routing it the customer path's way makes the test
fail on "unlock" appearing in the argv.

IT TAKES acquireRunning ITSELF and skips rather than waits, because RestoreOffboxScratch
does not take it (R-408) while offbox_integrity.go states that invariant as universal.

DUE-NESS IS PER SNAPSHOT (R-86's model), never per clock. RED-PROOFED: recording a
timestamp fails the stored-value test AND breaks the rotation - night 2 re-picks night 1's
app.

ITS SCRATCH IS A SEPARATE ROOT (backups/offsite-proof) and that is a safety decision, not
tidiness: the job deletes its copy on every path, and sharing backups/offsite-restore/<app>
would mean a nightly background job deleting the verification copy a CUSTOMER is looking
at. It is also invisible to placement, so a proof copy can never be pushed into a live app.

SHARED RATHER THAN FORKED: offboxScratchDirIn parameterises the scratch resolver on its
ROOT builder, and unitOnlyHeadroom extracts the free-space gate, so the customer path and
the proof refuse at the same floor with the same Hungarian sentence. RestoreOffboxScratch's
behaviour is unchanged.

NEW EVENT offsite_proof_empty, severity error, operator-only - deliberately NOT
backup_integrity_failed, whose hub template says the store is DAMAGED. Here the store is
sound and the content is absent: different cause, different action. The hub half shipped
FIRST, in felhom.eu 1aeaa30 (hub v0.110.0, live and verified), because an unallowlisted
type is 400'd and vanishes.

33 new tests, all groups green; full suite 1689 tests, 28 packages, rc=0. All 13 controller
gates OK. Five red-proofs run and recorded in REPORT.md.

A golden carrying 0.231.0 is OWED - the fleet is on 0.230.0. Viktor's call (R-242).
2026-08-31 20:55:34 +02:00

235 lines
12 KiB
Go

package backup
import (
"os"
"path/filepath"
"sort"
"strings"
)
// 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) }
// ── R-87 — does this app's backup contain what THIS APP should have? ─────────────────────────────
//
// THE ACCEPTANCE RULE, AND WHY THE OBVIOUS ONE IS A TRAP.
//
// The spike that re-scoped R-87 summarised this job as "check the unit against its own packing list".
// Taken literally that is worthless: **a hollow unit declares nothing, so everything it declares is
// present, and the check passes on exactly the shape it exists to catch.** R-403 measured that shape
// on this fleet — 120 082 104 B of dumps and tars became 7 036 B of neither, and the run recorded
// itself a success.
//
// So the rule has TWO parts and needs both:
//
// 1. everything the manifest declares is present in the restored unit, AND
// 2. the manifest declares what the app is SUPPOSED to have.
//
// Part 2 is the whole value. Part 1 alone is the trap.
//
// WHERE THE EXPECTATION COMES FROM, AND WHY IT IS NOT THE LIVE BOX.
//
// R-403's guard could tell hollow from legitimately-empty because it had TWO copies to compare. This
// has ONE. The expectation therefore comes from INSIDE the unit — its own captured
// `compose/docker-compose.yml` — and never from the running app:
//
// - the snapshot may predate the app's current shape, and the point is to judge the snapshot on its
// own terms rather than against a box that has moved on;
// - `GetDockerVolumes` (backup.go) enumerates from LIVE Docker, which answers a different question.
//
// THE TWO HALVES, both from the unit's own compose:
//
// - DATABASE — `DBServiceNames()` names the compose SERVICES whose `image:` is a supported engine.
// It is the same honest discriminator `RestoreFromRecoveryUnit` already uses to decide whether a
// dump must replay, so this cannot disagree with the restore path about what an app is. If the
// compose declares a database service, the unit must declare at least one database dump.
// - NAMED VOLUMES — `ParseComposeNamedVolumes()` reads the top-level `volumes:` block. If the
// compose declares named volumes, the unit must declare at least one volume tar.
//
// THE VOLUME HALF IS DELIBERATELY AN EXISTENCE CHECK AND NOT A NAME MATCH, and the reason is that a
// name match cannot be done honestly from inside a unit. Volume tars are named
// `<project>_<volume>.tar`, and `ResolveDockerVolumeNames` derives the project from
// `filepath.Base(filepath.Dir(composePath))` — which inside a unit is the literal string `compose`,
// not the stack. Measured 2026-08-31 on all eight real units on demo-hp, the count matches exactly
// (bookstack 2/2, docmost 3/3, kimai 2/2, opengist 1/1, privatebin 1/1, calibre-web 1/1,
// paperless-ngx 3/3, romm 3/3) and `<stack>_<volume>.tar` held in every case — but "held on eight" is
// not "derivable", and R-355's standing rule is that a claim about the app must never be inferred
// from a counter. Half a rule that is true beats a whole rule that is invented.
//
// THREE OUTCOMES, NOT TWO. "I could not judge this" is a first-class answer: collapsing it into a
// pass hides a real gap, and collapsing it into a failure alarms on our own blind spot. That is the
// same distinction `IntegrityResult` draws between a failed check and an unreachable store.
//
// ONE MANIFEST READER: this uses `readManifest`, the same function `unitCarriesData` above uses.
// `unitCarriesData` answers the coarse question ("does this unit carry anything at all") and stays
// exactly as R-403 shipped it; this answers the finer one, and an app whose unit carries nothing at
// all while its compose declares either a database or a volume fails BOTH.
// UnitProofVerdict is the outcome of judging one restored recovery unit.
type UnitProofVerdict string
const (
// UnitProofPass — the unit declares what the app should have, and holds everything it declares.
UnitProofPass UnitProofVerdict = "pass"
// UnitProofFail — the backup is READABLE and does not hold the app's data. Its canonical case is
// the R-403 shape: intact and empty. That is NOT the same fact as a damaged store and must never
// be reported as one — the customer's action differs.
UnitProofFail UnitProofVerdict = "fail"
// UnitProofCannotJudge — the unit does not carry enough to answer. Never reported as a pass.
UnitProofCannotJudge UnitProofVerdict = "cannot_judge"
)
// UnitProofReason is a stable machine code for WHY, so the log, the event and the message can each
// say the same thing without re-deriving it from prose.
type UnitProofReason string
const (
ProofReasonOK UnitProofReason = ""
ProofReasonManifestUnreadable UnitProofReason = "manifest_unreadable"
ProofReasonDeclaredFileMissing UnitProofReason = "declared_file_missing"
ProofReasonNoDatabaseDump UnitProofReason = "database_expected_none_captured"
ProofReasonNoVolumeDump UnitProofReason = "volumes_expected_none_captured"
ProofReasonComposeMissing UnitProofReason = "compose_missing"
ProofReasonComposeUnparseable UnitProofReason = "compose_unparseable"
)
// UnitProofResult is what one judgement decided, and enough to say it out loud.
type UnitProofResult struct {
Verdict UnitProofVerdict
Reason UnitProofReason
// Missing names the declared-but-absent files (ProofReasonDeclaredFileMissing) or the expectation
// that went unmet. ASCII-safe: file names and compose service names only, never a path outside the
// unit and never any file CONTENT — units carry portable secrets (R-9's rule for this whole area).
Missing []string
}
// OK reports whether this verdict is the passing one. A helper rather than a `==` at every call site,
// because "not a failure" and "a pass" are different questions here and the third outcome is why.
func (r UnitProofResult) OK() bool { return r.Verdict == UnitProofPass }
// JudgeRestoredUnit applies the rule above to a restored recovery-unit DIRECTORY.
//
// It is PURE and does no network, no docker and no restic: given a directory it reads the manifest,
// the captured compose and the presence of the declared files, and nothing else. Size is never
// consulted — `TestR403_SizeIsNeverConsulted` guards that for the R-403 predicate and
// `TestR87_SizeIsNeverConsulted` guards it here, because "how big" has never been the question.
func JudgeRestoredUnit(unitDir string) UnitProofResult {
man := readManifest(UnitManifestFile(unitDir))
if man == nil {
// FAIL CLOSED. A unit whose manifest cannot be read is a unit whose contents cannot be
// vouched for — R-403's own rule, and the direction matters: treating it as sound would let
// an unreadable backup pass as a proved one, which is the failure this job exists to end.
return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonManifestUnreadable}
}
// Part 1 — everything declared is actually there.
var missing []string
for _, f := range man.DBDumps {
if !fileExistsIn(UnitDBDumpDir(unitDir), f) {
missing = append(missing, "db-dumps/"+f)
}
}
for _, f := range man.VolumeDumps {
if !fileExistsIn(UnitVolumeDumpDir(unitDir), f) {
missing = append(missing, "volume-dumps/"+f)
}
}
for _, f := range man.ConfigFiles {
if !fileExistsIn(UnitComposeDir(unitDir), f) {
missing = append(missing, "compose/"+f)
}
}
if len(missing) > 0 {
return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonDeclaredFileMissing, Missing: missing}
}
// Part 2 — the expectation, from the unit's own compose.
composePath := filepath.Join(UnitComposeDir(unitDir), "docker-compose.yml")
if _, err := os.Stat(composePath); err != nil {
// CANNOT JUDGE, never a pass. Without the compose there is no honest expectation, and the
// alternative — assuming the app needs nothing — is precisely how a hollow unit would slip
// through.
return UnitProofResult{Verdict: UnitProofCannotJudge, Reason: ProofReasonComposeMissing}
}
dbServices, err := DBServiceNames(composePath)
if err != nil {
// DBServiceNames already refuses to let "cannot tell" read as "no database" (its own doc
// comment); this carries that refusal outward instead of flattening it.
return UnitProofResult{Verdict: UnitProofCannotJudge, Reason: ProofReasonComposeUnparseable}
}
if len(dbServices) > 0 && len(man.DBDumps) == 0 {
return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonNoDatabaseDump, Missing: dbServices}
}
volumes := ParseComposeNamedVolumes(composePath)
if len(volumes) > 0 && len(man.VolumeDumps) == 0 {
names := make([]string, 0, len(volumes))
for _, v := range volumes {
names = append(names, v.Name)
}
sort.Strings(names)
return UnitProofResult{Verdict: UnitProofFail, Reason: ProofReasonNoVolumeDump, Missing: names}
}
return UnitProofResult{Verdict: UnitProofPass, Reason: ProofReasonOK}
}
// fileExistsIn reports whether name exists as a regular file directly inside dir.
//
// `name` is a manifest-declared BASE name; it is joined and then checked to still be inside dir, so a
// manifest carrying `../../etc/passwd` cannot make this answer about a file outside the unit. The
// manifest travels inside the snapshot and a restore writes it from the store, so it is not a trusted
// input — this is the same instinct as DeleteOffsiteRestoreCopy's prefix check.
func fileExistsIn(dir, name string) bool {
p := filepath.Clean(filepath.Join(dir, name))
if !strings.HasPrefix(p, filepath.Clean(dir)+string(filepath.Separator)) {
return false
}
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}