Files
felhom.eu/REPORT.md
T
admin fb652024ea
gates / gates (push) Successful in 7s
docs: R-181 closed, R-156 closed, R-110 + R-115 rulings recorded, R-182 filed
R-181 CLOSED (controller v0.193.0 + v0.193.1) and proven live on demo-hp for
BOTH reserve terms. The reserve is now a per-app, per-run ADMISSION decision
taken before the app's first write and covering all three write legs, and it
gained a size term. The refusal's wording was not weakened; the behaviour moved
so it became true, verified by sha256 tree fingerprint.

R-156 CLOSED — papra's template mounts the app's own data root. Precondition
re-measured rather than inherited (both boxes were wiped today).

Part 4, documentation only, nothing built:
- R-110 WAITING-ON-OPERATOR -> READY. Ruling: option (b), the installer's
  publish channel moves to a TAG. Recorded with the condition that decides
  whether it works at all — it must cover BOTH the /scripts/ git-sync and the
  nine files the installer fetches from raw/branch/main.
- R-115 WAITING-ON-OPERATOR -> READY. Ruling: mechanism (b), a build-side gate
  refusing to deploy or vouch an unpublished version. The third instance (agent
  v0.120.0) would have silently downgraded both demo boxes while succeeding.

R-182 NEW: the periodic status refresh has no admission scope, so a refused app
re-alerts on every poll (measured: a second alert pair 13s after the run's).
Pre-existing in v0.192.0; deliberately not fixed in the R-181 task.

capability map: the local-backup row moves to PROVEN-LIVE in BOTH halves.
ROADMAP: R-165 collapses to CLOSED; R-181 collapsed into it.
07-backup-architecture.md: the reserve's contract stated as what the code
provides (S-1 — an architectural contract changed in the same session).
STATUS.md trimmed 150 -> 111 lines, "What's broken" no longer holds shipped
work, and the stale "After:" line (pointing at work that shipped on 2 August)
is fixed.
2026-08-03 11:36:16 +02:00

217 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REPORT — R-181 (the reserve guards the write that fills the disk) + R-156 (papra) + two operator rulings
**Date:** 2026-08-03 · **Repos:** `felhom-controller` (v0.192.0 → **v0.193.1**), `app-catalog-felhom.eu`, `felhom.eu` (docs only — **no hub change, no hub version bump**)
## 1. Baselines — re-read on arrival, all matched §1
| Repo | `main` @ arrival | Version | Shipped |
|---|---|---|---|
| `felhom-controller` | `4be6467b501b` | v0.192.0 | **v0.193.0 `fef07c3`****v0.193.1 `6c43bf6`** |
| `app-catalog-felhom.eu` | `7cb58ecdf8e7` | n/a | `122bbee` |
| `felhom.eu` | `6b5d64c1fa73` | hub v0.89.0 | docs only, **no bump** |
All three clean (`git status --porcelain` empty, `HEAD == origin/main`) before every build.
## 2. The fix
**One admission verdict per app per run** (`controller/internal/backup/admission.go`), taken before
that app's **first** write and consulted by all three legs — DB dump, volume dump, unit capture. The
three write under one per-app root (`appbackup.RecoveryUnitPath`), which is what makes one verdict
able to cover them honestly.
- **Lazy, not run-wide.** App A's dump can put app B under the reserve; a run-start verdict reads a
disk that no longer exists. **Never re-decided between an app's own legs** — that is the split being
closed. **Reset per run.**
- **Ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act, so a refused app is never
bounced. **After** the volume-less check, which has no write to gate.
- **Exactly one operator alert per refused app per run.** Leg order unchanged.
- **Size term added:** *would this app's write cross the reserve?* — estimated from its previous
`.sql` + `.tar`. **No history → headroom-only**, or the first backup becomes the one that can never
happen; the alert says so when that applies.
## 3. Files
| File | |
|---|---|
| `controller/internal/backup/admission.go` | **new** — the gate, the memo, the estimator |
| `controller/internal/backup/admission_test.go` | **new** — 11 tests |
| `controller/internal/backup/backup.go` | run scope + gates in the DB and volume legs |
| `controller/internal/backup/recovery_unit.go` | `floorVerdict` size-aware; capture leg via `admitApp` |
| `controller/internal/backup/capture_floor_test.go` | 3 call sites updated for the new signature |
| `controller/README.md`, `REUSE.md`, `CHANGELOG.md` | |
| `app-catalog-felhom.eu/templates/papra/docker-compose.yml` | mount moved to `/app/app-data` |
## 4. Tests — 28 packages `ok`, `rc=0` (read separately from any commit)
All 11 new tests pass, plus the pre-existing floor suite. Refusal assertions are **sha256 tree
fingerprints before and after**, never log lines — the defect being fixed *is* a log line the tree
contradicted.
The DB leg cannot run without Docker (`DiscoverDatabases` shells out), so its gate is pinned by an
**AST walk** of `backup.go` asserting `admitApp` precedes `DumpOne`. `strings.Contains` is
insufficient: a commented-out call still contains the string.
### Red-proofs — each demonstrated failing, then restored
| # | Mutation | Result |
|---|---|---|
| 1 | **Both** dump-leg `admitApp` gates removed (= exactly v0.192.0) | Scenario A **RED***"the VOLUME leg ran for a refused app"*; with the leg assertions temporarily made non-fatal, the **tree fingerprint changed** too. Also red: Scenario C, Scenario D, and the AST wiring test (which named the DB leg specifically) |
| 2 | The entire size term removed from `floorVerdict` (both its thresholds) | Scenario D **RED** — 0 alerts where 1 was required |
| 3a | The reserve removed entirely | Scenario F **PASSED — recorded honestly.** The specified mutation does not exercise the assertion: removing the reserve makes every app write, which overwrites and adds but **deletes nothing**, so a deletion-watching test correctly stays green |
| 3b | A prune injected into the refusal path | Scenario F **RED** — this is the mutation that proves the test watches deletion |
| 4 | Floor moved above the warning band (90% / 6 GiB) | `TestFloorSitsBelowTheCriticalWarningBand` **RED** |
Every mutation removed **every** guard its test covers (#1 removed both dump-leg gates, not one).
## 5. Live validation — demo-hp guest 9201 (Tier 0), the method that found the defect
**Method:** endpoint-level — `POST /api/debug/backup/dbdump`, the exact endpoint the debug UI button
calls, which runs the production `RunDBDumps`. No browser on DooPlex.
**The instrument was re-proven before use.** demo-hp's thin pool is 53.93 GiB, so a real fill of a
70 G volume would exhaust it and corrupt every guest. A 5 GiB `fallocate` step moved guest `df`
1.2G → 6.2G while thin-pool `data_percent` held **36.83 → 36.83** — zero blocks allocated. Re-checked
at every step of the fill.
### Headroom term — 08:59:46, 906 MB free / 99% used
| Observable | Result |
|---|---|
| Tree fingerprint before | `TREE_SHA=111d1760c18d3440f700634ab325f8b8` (10 files; opengist's tar **182,272 B** — R-181's own "before" figure) |
| Tree fingerprint after | **`111d1760c18d3440f700634ab325f8b8` — identical** |
| Volume dumps written | **0** (baseline run at 08:58 wrote 2) |
| `Stopping <app> for safe volume dump` | **absent** — and this is evidence, not an absence, because that line **is** present in the 08:58 baseline |
| Operator alerts | one `recovery_unit_capture_failed` per app, severity `error`, HTTP 200 |
Free space restored → re-run at **09:01:33**: both apps captured normally.
### Size term — 09:03:00, proven separately
Reproducing the original sequence: a real 2 GiB file planted in opengist's volume, backed up so its
**previous** tar became **2,147,666,432 B** (the exact live figure), then the filesystem set to
**91% used / 2.9 GB free — both headroom terms deliberately clear**.
- **opengist refused `(size)`** — *"this app's last backup was 2.0 GB and writing it again would cross the reserve"*
- **privatebin ADMITTED and dumped normally** — the term is per-app, not a global halt
- Tree unchanged; 1 volume dump instead of 2
### One honest correction to the "app not stopped" claim
`StartedAt` on both apps *did* move, 26 s **after** the refusal. It was the **quiesce loop** for the
whole-guest PBS backup, which my fill had broken — not the app-data path. Its own backoff logic then
behaved correctly (*"deferring its next quiesce by 15m so the apps are not stopped again for a backup
that cannot succeed"*). The app-data claim rests on the **absence of the `Stopping … for safe volume
dump` line**, which is the line that appears when that leg bounces an app.
## 6. The `du` measurement (§Part 1.3) — measured, then rejected
**66 timed runs** on demo-hp guest 9201, `docker run --rm -v <vol>:/v alpine du -sb /v`:
**median ~355 ms per volume, range 341404 ms** — on volumes holding **tens of KB**. The cost is
container start-up, not the walk, so it does not shrink for small apps and only grows for real ones.
**Rejected**, on two grounds beyond the number: `docker run` needs the writable layer, so the
measurement mechanism can fail under exactly the disk pressure the reserve exists to handle; and the
previous-dump estimate measures the **artifact that will be written** rather than the live volume,
which is the truer predictor. The previous-dump estimate stands.
## 7. The refusal message as shipped, and what it guarantees
```
[WARN] [backup] App backup REFUSED for opengist (headroom) — refused: backing up this app would
leave the filesystem below the reserve (reserve: 97% used or 1.0 GiB free; the filesystem is already
below it, before this app's estimated 178.0 KB write) — /mnt/sys_drive: 64.3/68.7 GB used (94%),
0.9 GB free; NO database dump, NO volume dump and NO recovery-unit capture was written for it, the
previous unit is untouched and NOTHING was deleted
```
**It guarantees, for that app in that run:** no DB dump, no volume dump and no capture were written;
every file under `backups/primary/<app>` is byte-identical; the app was not stopped; nothing anywhere
was deleted; exactly one operator alert was sent. All five verified by fingerprint above.
**The wording was not weakened to fit the behaviour** — the behaviour moved so the wording became
true. What was *added* is the bound term (`headroom` / `size`) and the estimate.
**v0.193.1 — found by this very proof run.** The estimate was rendered fixed to two-decimal GiB, so
opengist's real **178 KB** printed as `estimated 0.00 GiB write`, which reads as *no estimate was
available* — the opposite of what happened. Shipped the same session because it is the same defect
class the whole task is about. Re-verified live after redeploy: `estimated 178.0 KB write`.
## 8. papra (R-156, last leg)
**Precondition checked, not inherited** — both boxes were wiped and rebuilt today, so the 2 August
evidence was re-measured: `docker ps -a` (**including stopped**) on **both** demo guests → no papra;
hub `/hosts` → exactly two enrolled hosts (`demo-felhom-8363b5`, `demo-hp-bb76ea`), **zero** papra.
**Decided from the image, not the README:** `WORKDIR=/app`, `DATABASE_URL=file:./app-data/db/db.sqlite`,
`DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents`, `PAPRA_CONFIG_DIR=./app-data` — and
**`/app/data` does not exist in the image at all**.
**Departure from the task's stated preference order, stated because it was deliberate.** Option (1)
(reconfigure the app to write to `/app/data`) *was* available — all three paths are env-settable. Not
taken: it enumerates data paths, so a fourth added upstream would silently escape to the writable
layer again — this defect re-armed and invisible. Mounting the app's own data **root** captures every
current and future path by construction.
**Gate output — the arbiter, run in both directions:**
- fixed → `papra CLEAN`, with the self-test passing on that run: *"prober flags the R-156 signature and clears a correct template — trustworthy"*
- reverted to `/app/data` (red-proof on the **real template**, not just the canary) → `BROKEN`: *"mount /app/data is NOT writable by the app's own uid=999"*, *"DATA in the writable layer at /app/app-data/db (db_signature=True, e.g. ['db.sqlite'])"*, *"declared volume /app/data is EMPTY"*
- `catalog_gates.py papra` (full, not `--fast`) → **rc=0**, all three gates OK
**Two operational findings about the gate:** it needs **root** (it reads `/var/lib/docker/volumes`,
mode `drwx--x---`; as a normal user its own canary fails UNDETERMINED and it correctly refuses a
verdict — fail-closed working as designed), and it hardcodes scratch path `/srv/felhom-gate`, created
on DooPlex. Unscoped it deploys all 53 templates; that run was aborted after 10 minutes and its
`volgate-*` scratch projects were cleaned up.
## 9. §3's correction — confirmed in passing, not chased
`restore_points.go:57-59` takes the manifest's mtime and then `newestArtifact` over the `.sql` and
`.tar` files, so **the newest of the three wins**. The restore point does **not** show a stale
timestamp. Confirmed and dropped, as instructed.
## 10. Register
| ID | Change |
|---|---|
| **R-181** | **CLOSED — SHIPPED** (v0.193.0 + v0.193.1), with the live evidence above |
| **R-156** | **CLOSED** — all three apps fixed |
| **R-110** | WAITING-ON-OPERATOR → **READY**, ruling attached: **option (b), tag-tracked**, and it must cover **both** channels (the `/scripts/` git-sync *and* the nine files fetched from `raw/branch/main`) or it only half-works |
| **R-115** | WAITING-ON-OPERATOR → **READY**, ruling attached: **mechanism (b)**, a build-side gate refusing to deploy or vouch an unpublished version; the third instance (agent v0.120.0) would have silently downgraded both demo boxes while reporting success |
| **R-182** | **NEW.** ID established free: `grep -ro "R-182\b"` over `documentation/` and `*.md` → 2 hits, both prose in `REPORT.md` recording it as *"checked and left unused"*; `R-183` → 0 hits and remains free |
**R-165** is collapsed to CLOSED/PROVEN-LIVE in `ROADMAP.md`; the capability map's local-backup row
moves to **PROVEN-LIVE, both halves**, because the live fill proved the fixed behaviour for **both**
reserve terms.
## 11. Observations — noticed, documented, NOT acted on
1. **R-182 (filed).** The periodic status refresh (`GetFullStatus``captureAllRecoveryUnits`) runs
with no admission scope, so a refused app re-alerts on every poll — measured live: a second
identical alert pair 13 s after the run's. **Pre-existing in v0.192.0**; R-181 changed neither
caller. Its mitigation is a *comment* claiming the hub owns cooldown — which is exactly the
"invariant asserted in a comment with no test pinning it" shape, so verify at the hub before
scoping.
2. **A reserve refusal does not make the run fail.** The DB and volume legs record `SKIP`, not `FAIL`,
so `lastDBDump.Success` stays true and the customer-facing status does not turn red. Deliberate and
consistent with v0.192.0 (the capture refusal never set it either), and the operator alert is the
signal — but it means "backup succeeded" and "every app was backed up" are not the same statement.
3. **`UnitSpace.UsedPercent` and `df` disagree** — `df` reported 99% where the alert said 94%, because
`df`'s figure accounts for ext4 reserved blocks and the floor's does not. Harmless here (the
free-byte term bound), but a percent-term threshold is being compared against a number the operator
cannot reproduce with `df`.
4. **The whole-guest PBS backup fails when the volume is near-full**, pushing
`whole_guest_backup_failed` (severity `error`). Expected under a deliberate fill, and its backoff
behaved correctly; noted because it is collateral any future fill test will also produce.
## 12. Teardown
Fill file removed; the planted 2 GiB file removed; a final backup regenerated a correct 178 KB tar;
`pct fstrim 9201` returned 67.5 GiB and the thin pool settled at **29.43%**, *below* its 36.83%
baseline. The backups tree is byte-identical to the pre-test fingerprint. Guest helper scripts and the
credential file `shred`-ed. `volgate-*` scratch compose projects removed; the unrelated 9-day-old
`jarr-*` containers on DooPlex were left untouched. papra is **not** left deployed.
No `--no-verify` was used on any push; the `felhom-controller` pre-push hook ran and reported
`gates OK` on both pushes.