docs(v0.230.0): R-403 — CHANGELOG, CONTEXT rulings, README, REPORT
gates / gates (push) Failing after 13s

CHANGELOG v0.230.0, leading with the measurement rather than the fix: 120 082 104 B -> 7 036 B on
the shipped v0.229.0, reproduced before anything was built.

CONTEXT records three rulings: hollowness is a MANIFEST question and never a size question; the
guard fences one shape and NOT shrinking, because the derived-copy rebuild is a design decision; and
the rehydrate happens inside the restore because a follow-up job races the 5-minute capture. Plus
the shape the live run taught: a warning that fires on everything costs the same as the comforting
lie it replaces.

README documents the refusal, what each surface says, and why the capture job is deliberately not
guarded. REPORT leads with Part 1's result, carries the six red-proofs, the per-row Scenario D table
with its seven-app control, and eight observations including R-404 filed-not-acted-on and three
mistakes of mine recorded rather than tidied away.
This commit is contained in:
2026-08-31 14:39:13 +02:00
parent b48a7fa326
commit 1cfdde968f
4 changed files with 371 additions and 250 deletions
+94
View File
@@ -1,3 +1,97 @@
## v0.230.0 — a poorer copy must never delete a richer one (2026-08-31, R-403)
**MinAgent: 0.129.0** (unchanged)
### The loss was MEASURED first, then fixed
R-403 was filed yesterday from a code reading with the dangerous half explicitly recorded as
**unverified**. It was run before anything was built, on the shipped **v0.229.0**, on `demo-hp`:
```
BEFORE secondary db-dumps: 4 volume-dumps: 3 size: 120082104 bytes
(a hollow primary unit, produced through the R-102 restore path exactly as on 2026-08-31)
RUN [backup] Tier 2 copied docmost → …/backups/secondary/docmost (14.9 KB, 0 leg(s), 0s)
AFTER secondary db-dumps: 0 volume-dumps: 0 size: 7036 bytes
```
**Four database dumps and three volume tars — the customer's last surviving package — deleted in one
nightly run, which recorded itself a SUCCESS.** Evidence:
`felhom.eu/documentation/audits/DRILL-r403-tier2-delete-2026-08-31/`.
The mechanism was three individually-correct lines: `RunTier2` guarded the unit leg with `os.Stat`
alone (*does the folder exist*), `rsyncMirror` is `rsync -a --delete`, and nothing between them
compared source to destination. **An empty recovery unit is a folder that exists.**
### The guard
- **One predicate, `unitCarriesData` / `unitIsHollow`** (`internal/backup/r403_hollow.go`). It **asks
the MANIFEST and never the byte size**: a unit with a big compose tree and no dumps is dangerous, a
tiny unit for a tiny app is fine. Fail-closed on an absent or unparseable manifest.
`TestR403_SizeIsNeverConsulted` is the guard that keeps `dirSizeBytes` out.
- **`RunTier2` skips the unit leg** when the source is hollow **and** the destination is not. The
other legs still run, the run is **not** failed, and the skip is recorded **for the surface**
(`CrossDriveBackup.UnitLegSkipped` + `UnitPackageDate`) as well as logged loudly.
- **`--delete` STAYS and shrinking stays legal.** `07-backup-architecture.md` §8 row 5's derived-copy
rule ("Migration = rebuild, not preserve") is unchanged, and `tier2.go`'s own header records that a
classified app's copy legitimately shrinks as `export` drops out. The fence is exactly one shape.
`TestR403_DataLegShrinkIsUnaffected` is the guard on the guard.
### The honesty — a preserved copy must not read as a fresh one
A preserved package is older than the run that preserved it. The per-app card carries a notice, and
the unit-restore confirm names the **package's** date — read from the mirrored manifest's own
`created_at`, not from the status record — plus a clause saying why it is older. **The outcome
sentence names the same date**, because §2.3's rule is "not a plain green success anywhere".
> **The live run caught a defect the unit tests did not.** The first draft also flagged "older" by
> comparing the package's date to the run's — but a unit is ALWAYS captured shortly before the run
> that mirrors it, so that was true for **every healthy app on the box**. Measured: bookstack, kimai,
> opengist and privatebin all had manifests at `12:03:49Z` against a run at `12:14:24Z`, and all four
> would have been told their package was stale. **A warning that fires on everything costs the same
> as the comforting lie it replaces.** The flag is now `UnitLegPreserved` and nothing else;
> `TestR403_AHealthyAppIsNeverCalledStale` pins it.
### The cause — the primary is filled back in
`RestoreTier2Unit` now refills an **absent or hollow** primary unit from the mirror it just restored
from, **inside the call, before it returns**. The hollow manifest was written **two seconds** after a
restore by the 5-minute capture job; any follow-up job or goroutine races it.
`TestR403_RehydrateHappensBeforeTheCallReturns` asserts the ordering, never a timer.
Never over a **complete** primary (it may be newer — that is R-403 pointed the other way) and never
after a **failed** restore. **The capture job is deliberately NOT guarded:** a capture that describes
an empty drive as empty is correct, and with the primary refilled there is no hollow state left to
describe. Guarding it would make the manifest lie.
### The rider — a credential reader that ends a three-time mistake
`felhom.eu/scripts/read_credential.py`. Values in `~/.config/credentials` are single-quoted; stripping
only `"` sends two extra characters and the failure looks exactly like a stale password. **2026-07-20**
it was diagnosed as drift and written into memory; **2026-08-31** it recurred and was caught;
**2026-08-31, hours later, it recurred again and rewrote a live box's `password_hash`.** Between them
the project already had a memory file, a worked recipe and a session report — none of it stopped
occurrence three. The rule now lives in the code path: one matching quote pair is unwrapped, the
result is **refused** if it still carries a quote, `--expect-length` gives the caller a second
opinion, and the value goes file→file at 0600 with only its length on stdout.
### Live validation on `demo-hp`
| Scenario | Result |
|---|---|
| **A** — the loss on v0.229.0 | **CONFIRMED** — 120 082 104 B → 7 036 B |
| **B** — the same state on v0.230.0 | **PRESERVED** — all 7 files, all sha256 identical, WARN quoted |
| **C1** — both sides complete | mirrors as before (`114.5 MB, 0 leg(s)`, no skip) |
| **D** — the surfaces | only the skipped app carries the notice; the other **seven are the control** |
| **E** — the rehydrate | primary real the instant the call returned, and still real after **3** capture cycles |
### Tests
**24 new Go tests (1632 → 1656)** plus 2 Python tests. Red-proofs run and reverted: **A6** (predicate →
size threshold), **B1** (guard removed → the copy's 3 files are DELETED and the seam is called), **B6**
(a general never-shrink rule → the shrink case fails), **C2** (only-when-hollow dropped → the complete
primary is overwritten), **E1** (quote assertion removed → three cases fail by name), plus the
over-eager-stale red-proof. `recordTier2Success` and `tier2UnitConfirmMsg` keep their old signatures as
thin callers, so **no existing test needed editing**.
## v0.229.0 — the second drive's copy becomes a way back (2026-08-31, R-102 + R-103)
**MinAgent: 0.129.0** (unchanged)