fb652024ea
gates / gates (push) Successful in 7s
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.
217 lines
14 KiB
Markdown
217 lines
14 KiB
Markdown
# 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 341–404 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.
|