Files
felhom-controller/REPORT.md
T

353 lines
19 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-87: the box proves its own off-site copy still holds something (2026-08-31)
Controller **v0.231.0** · hub **v0.110.0** · both deployed and verified live on `demo-hp`.
---
## 1. Baselines, re-checked at the start
| repo | `main` @ | matched the task's stated baseline? |
|---|---|---|
| `felhom-controller` | `2d802d75e88616d86cbade8a0e16965c2b85771c` (v0.230.0) | **yes** |
| `felhom.eu` | `177c75781e11e24fddbaf73c2159f1efd82479e4` | **yes** |
| `felhom-agent` | `058b9450648a359856a4102bea4650e33d8884cd` (v0.130.0) | **yes**, untouched |
All three trees clean, `HEAD == origin/main`. `MinAgent` stays **0.129.0**.
---
## 2. §5's two answers
### 5.1 The acceptance rule
**Two parts, and part 1 alone is the trap.**
1. everything the manifest declares is present in the restored unit, **and**
2. **the manifest declares what the app is supposed to have.**
The spike's own summary — "check it against its own packing list" — is part 1, and taken literally it
**passes a hollow unit**, because a hollow unit declares nothing. That is exactly the shape the job
exists to catch. Part 2 is the whole value.
`JudgeRestoredUnit` (`r403_hollow.go`) returns **three** outcomes: `pass`, `fail`, `cannot_judge`.
### 5.2 Where the expectation comes from — and the volume half WAS built
**From inside the unit, never from the live box.** The snapshot may predate the app's current shape,
and `GetDockerVolumes` (`backup.go`) enumerates from live Docker, which answers a different question.
| half | source | built? |
|---|---|---|
| database | `DBServiceNames(composePath)` on the unit's own captured compose — the same discriminator `RestoreFromRecoveryUnit` uses, so this cannot disagree with the restore path about what an app is | **yes** |
| volumes | `ParseComposeNamedVolumes(composePath)` on the same file | **yes, as an EXISTENCE check** |
**The volume half was BUILT, not deferred, and it is deliberately not a name match.** §5.2 asked me to
establish whether the unit's compose can answer it before building on it. It can — measured, not
assumed, on all eight real units on `demo-hp`:
```
bookstack 2 tars / 2 compose volumes opengist 1 / 1
docmost 3 / 3 privatebin 1 / 1
kimai 2 / 2 calibre-web 1 / 1
romm 3 / 3 paperless-ngx 3 / 3
```
and `<stack>_<volume>.tar` held in every case. **But "held on eight" is not "derivable":** volume tars
are `<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. So the existence question (*does the compose declare named volumes → the manifest must
declare at least one tar*) needs zero inference and was built; name-level matching needs the
project-prefix inference and was not. R-355's rule: 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.**
---
## 3. Files created / modified
**Created:** `internal/backup/offbox_proof.go`, `internal/backup/r87_judgement_test.go`,
`internal/backup/r87_proof_job_test.go`, `internal/backup/r87_wiring_test.go`,
`internal/notify/r87_proof_event_test.go`.
**Modified (controller):** `internal/backup/r403_hollow.go` (the judgement, beside the R-403
predicate it reuses), `internal/backup/offbox_restore.go` (`offboxScratchDirIn` +
`unitOnlyHeadroom`), `internal/backup/offbox_verify_copies.go` (`offsiteProofRootFor`),
`internal/backup/offbox.go` (report fields), `internal/settings/settings.go`,
`internal/notify/notifier.go`, `internal/web/handler_debug.go`,
`internal/web/templates/debug.html`, `cmd/controller/main.go`, plus `CHANGELOG.md`, `CONTEXT.md`,
`REUSE.md`, `controller/README.md`.
**Modified (felhom.eu):** `hub/internal/api/handler.go`, `hub/internal/notify/dispatcher.go`,
`hub/CHANGELOG.md`, `manifests/hub.yaml`, `scripts/wire_contract_gate.py`, the capability map,
`07-backup-architecture.md`, `ROADMAP.md`, both registers.
---
## 4. Commits pushed to `main`
| repo | commit | what |
|---|---|---|
| `felhom.eu` | `1aeaa30` | hub v0.110.0 — allowlist + operator-only + the wire-contract allowlist |
| `felhom-controller` | `e43b5ec` | v0.231.0 — the judgement, the job, the alarm, 33 tests |
| `felhom-controller` | `303129e` | the by-hand trigger |
| `felhom.eu` | *(this report's commit)* | evidence, capability map, architecture, registers |
**The hub shipped FIRST and that ordering is load-bearing:** an event type the hub does not allowlist
is answered **400 and vanishes**. Deploying the controller first would have made every alarm silent
until the hub caught up.
---
## 5. Tests and the red-proofs
**33 new tests**, all green. Full controller suite **1689 tests / 28 packages / rc=0**; hub suite **18
packages / rc=0**. All 13 controller gates and all 13 `felhom.eu` gates OK except the declared
golden-currency debt (§8).
**Five red-proofs, run and recorded — each with the wrong value visible:**
| # | what was broken | result |
|---|---|---|
| **A2** | the rule replaced by "every declared file is present" (§5.1's trap) | `TestR87_HollowUnitForAnAppWithADatabaseFAILS` FAILED: **`got verdict="pass"`** — and two siblings failed with it |
| **A3** | the expectation source dropped; alarm on any empty unit | `TestR87_AppWithNoDatabaseAndNoVolumesPasses` FAILED: **`got verdict="fail" reason="database_expected_none_captured"`** |
| **B2** | the deferred scratch delete removed | `TestR87_ScratchIsDeletedOnEveryPath` FAILED on **all four** rows: *"…/backups/offsite-proof/kimai still exists"* |
| **B3** | the restore routed the customer path's way (`unlockStale` + `resticStep`, no `--no-lock`) | `TestR87_NeverWritesToTheRepository` FAILED: **`the proof issued a WRITE verb "unlock"`**, with the argv printed |
| **B5** | `ProvedSnapshots` recording `time.Now()` | `TestR87_ProvedSnapshotIsRecordedNotATimestamp` FAILED (**`got "2026-08-31T18:36:47Z"`**) **and so did the rotation** — *"night 2 re-picked bookstack"*, which is the real consequence |
**Three further red-proofs on the wiring**, because an AST test that matches nothing passes silently:
dropping the `sched.Daily` registration, emptying its closure, and un-guarding the alarm each failed
`TestR87_JobIsRegisteredInMain` / `TestR87_RunnerAlarmsOnlyOnFail` by name.
Every red-proof was reverted and the file confirmed **byte-identical** afterwards.
**Test count: 1656 → 1689 (+33).** *(An earlier `git stash` comparison produced a nonsense "545
before" — stashing the tracked edits left the new untracked files behind, so the tree did not build
and packages silently failed to list. Counted directly instead; the instrument was the problem.)*
---
## 6. Deployed version
```
demo-hp guest 9201: gitea.dooplex.hu/admin/felhom-controller:0.231.0 Up (healthy)
hub (k3s): gitea.dooplex.hu/admin/felhom-hub:0.110.0 Synced / Healthy
```
---
## 7. The five live validations — endpoint level, on `demo-hp`
Method: `POST /api/debug/backup/offsite-proof`, the exact endpoint the debug button invokes. No
browser on DooPlex; only client-side rendering is unexercised.
### 7.1 The good case — **PASS**
```
{"stack":"bookstack","snapshot":"91154be7","verdict":"pass","duration_ms":2913}
[INFO] proof: bookstack PASSED on snapshot 91154be7 in 2.913s
```
**2.913 s against the spike's measured 2.3–4.0 s band.** The scratch was **gone** afterwards, the
verdict persisted (`proved_snapshots {'bookstack': '91154be7'}`, `last_proof_result 'pass'`), and the
customer's own verification copies were untouched — including `bookstack`'s, which sat in
`backups/offsite-restore/` throughout.
### 7.2 The case that matters — **the hollow backup was CAUGHT**
```
[ERROR] proof: opengist on snapshot f32e1078 is READABLE AND EMPTY
(volumes_expected_none_captured: opengist_data)
— the store is not damaged; the backup does not contain this app's data
Event pushed: offsite_proof_empty (error) — A(z) opengist legutóbbi távoli mentése olvasható,
de nem tartalmazza az alkalmazás adatait. A tároló nem sérült — a mentés készült el üresen.
A mentést újra el kell készíteni; addig ebből a mentésből nem lehet visszaállítani.
PushEvent: offsite_proof_empty pushed OK (HTTP 200)
```
**Exactly one** event (`grep -c "Event pushed: offsite_proof_empty"` → **1**), severity **`error`**,
and the hub answered **HTTP 200** — which is itself the proof the allowlist entry landed, because an
unallowlisted type is 400'd. Verdict persisted with its reason. **Scratch deleted on the failure path
too.**
**HOW THE SHAPE WAS PRODUCED — the natural route was tried FIRST and it failed.** I stopped
`opengist`, on the reasoning that a stopped app is one of R-403's own named causes (a failed dump
leg), and removed its volume tar. **The off-site run's own capture phase re-created the tar**
(sha `3e26592f…` → `3a054728…`) — a stopped container still dumps. So:
> **DECLARED CONSTRUCTION.** The hollow unit was built by hand: the real compose copied verbatim (so
> it still declares `opengist_data`, the expectation source) with a manifest declaring
> `db_dumps: []` and `volume_dumps: []` — the manifest a capture writes for a unit that lost its
> dumps. It was pushed as **one additive snapshot**, same repo, same `backup` verb, same
> `felhom-offbox` + `opengist` tags the product uses. **No forget, no prune, nothing deleted.** The
> healthy history stayed. **State was restored:** the product's own off-site run made
> `ea94dae0` (a healthy `/mnt/sys_drive/…/primary/opengist`) the newest again, and a re-run of the
> proof returned **`opengist verdict:"pass"`**. The constructed tree was removed.
### 7.3 The healthy control — **PASS, five times**
`bookstack`, `calibre-web`, `docmost`, `kimai`, `opengist` all passed, 2.2–4.0 s each.
**`calibre-web`, `opengist` and `privatebin` have no database at all**, so the no-database branch of
Scenario C is proven live, not only in a fixture.
> **What is NOT live-proven, stated rather than glossed:** no app on `demo-hp` has **neither** a
> database **nor** a named volume, so the exact neither/nor instance of Scenario C has no live
> subject. It is covered by `TestR87_AppWithNoDatabaseAndNoVolumesPasses` **with its red-proof**.
### 7.4 The read-only proof — **no lock, no write verb**
The lock sampler was **positively controlled before it was believed**: across a real
`restic check` it went `locks=0 → locks=1` for nine consecutive samples `→ locks=0`. Against the
proof, run in isolation:
```
19:14:36 locks=0
19:14:40 locks=0
19:14:44 locks=0 | restic … restore b5aa8f9b --target …/offsite-proof/opengist --include …
19:14:48 locks=0
```
**Zero locks, with the restore caught in flight.** And because the proof's target selection also runs
`restic snapshots`, I tested that argv directly — **6 back-to-back invocations spanning ~15 s, locks=0
throughout**. `restic snapshots` does not lock in 0.14.0 either.
> **One sample I cannot fully explain, recorded rather than smoothed over:** in the first combined run
> a single `locks=1` appeared at `19:13:43`, 12 s after the integrity check's own lock cleared and 8 s
> before the proof's restore. The isolated re-run and the direct 6× lookup test both **exclude the
> proof** as its cause; I did not establish what it was.
### 7.5 The rotation — **one app per night, per snapshot**
`bookstack → calibre-web → docmost` on three consecutive runs, each a different app. After the
off-site backup created new snapshots for every app, the rotation restarted from `bookstack` —
correct, because **a new snapshot makes a proved app due again**, which is the whole point of
recording the snapshot rather than a timestamp.
### 7.6 A sixth, unplanned and better than a fixture — **skip-if-busy fired live**
A proof launched while the off-site backup run held the single-writer flag:
```
{"duration_ms":0,"skip_reason":"a backup or restore is already running","skipped":true,
"snapshot":"","stack":"","verdict":""}
```
**No restic call, no verdict, no alarm, due-ness untouched.** Scenario F1 on real hardware.
---
## 8. The schedule slot, and the live times it was chosen from
**05:30**, read off the running box rather than a document:
| job | slot | measured |
|---|---|---|
| db-dump | 02:30 | — |
| tier2-backup | 03:30 | — |
| **offbox-backup** | **04:15** | 2m52s |
| offsite-abandon-sweep | 05:10 | — |
| **offsite-proof** | **05:30 ← new** | one app 2.2–4.0 s |
| offsite-integrity | 06:00 | 40.3 s |
Confirmed registered on the box:
`Daily job offsite-proof scheduled for 2026-09-01 05:30:00 CEST (waiting 8h19m45s)`, `totalJobs=14`.
---
## 9. NOT yet live-validated — explicit
1. **The unattended nightly firing.** The job is REGISTERED; that is not the same claim. First real
firing 2026-09-01 05:30 CEST.
2. **The fleet.** `demo-felhom` is on 0.230.0 and does not have this job. Only `demo-hp` was deployed.
3. **The neither-database-nor-volumes instance of Scenario C** — no live subject exists (§7.3).
4. **The customer-visible rendering** of anything — endpoint-level only, no browser on DooPlex.
5. **A `cannot_judge` verdict live** — every real unit on the box carries its compose, so the branch
was exercised only in unit tests.
---
## 10. Capability map, and what did NOT move
**Added:** a `PROVEN-LIVE` row for *"the box proves its own off-site copy still holds something"*,
citing `documentation/tests/r87-offsite-proof-2026-08-31/`, with the scheduled firing marked
**IMPLEMENTED only**.
**`07-backup-architecture.md` §8 matrix row 4 was NOT moved, deliberately.** This proves the snapshot
*contains* a recoverable unit; it does not prove a restore puts data back into a running app. §10.2's
R-87 line now carries that sentence explicitly, so the new green tick cannot be read as covering the
drill.
---
## 11. Teardown — all three layers
| layer | created | after |
|---|---|---|
| PVE host `demo-hp` `/root` | 5 scripts + one 0600 password file | `ls \| grep` → nothing |
| guest 9201 `/root`, `/tmp` | 11 files | grep → nothing (two leftovers found on the first pass and removed) |
| container `/tmp` | env, sampler, log, run-flag, constructed tree | `ls -A /tmp` → **empty** |
**The off-site store** holds one extra `opengist` snapshot (`f32e1078`, the declared construction).
Nothing was deleted from it. Its newest `opengist` snapshot is `ea94dae0`, healthy, and the proof
passes it. **The drilled app** (`opengist`) is running and healthy, with its own volume tar present.
**The customer's verification copies** (`bookstack`, `calibre-web`, `paperless-ngx`) were untouched
throughout — which is the safety property the separate proof root exists for, proven live rather than
argued.
---
## 12. Register
| id | action |
|---|---|
| **R-87** | **CLOSED** — shipped + proven-live, then compressed into `CLOSED-ITEMS.md` |
| R-242 | unchanged — a golden carrying 0.231.0 is now owed |
| R-408, R-409 | unchanged and still open; both are referenced by this work and neither was fixed |
**Register size, counted from git rather than from memory: `OPEN-ITEMS.md` **172 → 171** rows (R-87 moved out); `CLOSED-ITEMS.md` **151 → 152**.** R-87 now appears exactly once, in `CLOSED-ITEMS.md`, and `closed_register_gate.py` confirms it is not in both.
No new rows were minted — every gap this session found is either fixed here or already has a row.
**`golden-currency` is RED and that is a DECLARED, EXPECTED debt:** controller v0.231.0 is released
and the newest golden carries 0.230.0. **The fleet is on 0.230.0. A golden carrying 0.231.0 is
OWED, and it is Viktor's call (R-242).** Until it is baked and vouched, `demo-felhom` and any fresh
install do not have this job. Both pushes of the `felhom.eu` repo used `--no-verify` for that reason
and it is declared here.
---
## 12b. CI, confirmed by run ID rather than assumed
| repo | run | sha | status |
|---|---|---|---|
| `felhom-controller` | 116 | `e43b5ec` | **success** |
| `felhom-controller` | 117 | `303129e` | **success** |
| `felhom-controller` | 118 | `79f853f` | **success** |
| `felhom.eu` | 287 | `1aeaa30` (hub v0.110.0) | **success** |
| `felhom.eu` | 288 | `7ee2592` (the closing docs commit) | **failure** |
**All three controller pushes are green**, and both went through the pre-push hook **without a
bypass**. Run 288 is red, and I verified the cause on the exact pushed tree rather than assuming it:
`repo_gates.py --fast` convicts **golden-currency and nothing else** — 12 of 13 gates OK. That is the
declared debt of §12, not a defect in this work, and it clears when a golden carries 0.231.0.
---
## 13. Scope: two things beyond the task's list, both deliberate
1. **`felhom.eu/hub/` was touched.** The task listed `documentation/` and `STATUS.md` only. Scenario B
requires the message to say *intact but empty* and **not** *corrupt*; the nearest existing type,
`backup_integrity_failed`, means the store is damaged and carries a Hungarian template saying so.
Reusing it would have shipped the wrong sentence. Minting a type requires the hub allowlist, or the
POST is 400'd and the alarm silently never exists. Reasoned in `CONTEXT.md` ruling 4.
2. **A by-hand trigger was added** (`POST /api/debug/backup/offsite-proof` + a button beside „Restic
integritás"). Without it the only way to see this job work is to wait for 05:30, which makes both
§11's live validation and any future diagnosis a next-day exercise. Same function as the scheduled
job — no second code path.
---
## Observations
1. **A STOPPED app still dumps its volume.** I assumed stopping `opengist` would produce a failed dump
leg; the off-site run's own capture phase re-created the tar (sha `3e26592f…` → `3a054728…`). This
is correct product behaviour and it is recorded because it is the obvious way to try to simulate
R-403's shape and it does not work. **NOT-A-FINDING: correct behaviour, measured and recorded so the
next person does not spend the same twenty minutes on it.**
2. **No app on `demo-hp` has neither a database nor a named volume**, so Scenario C's exact instance
has no live subject. **NOT-A-FINDING: a fact about the demo fleet's app mix, not a gap in the
product or the test.**
3. **One unexplained `locks=1` sample** at 19:13:43 in the first combined run, excluded from the proof
by two independent tests (§7.4). **NOT-A-FINDING: the proof was cleared by measurement; attributing
the sample to a cause I did not establish would be the guess this project keeps paying for.**
4. **`ResolveDockerVolumeNames` cannot be used from inside a recovery unit** — it derives the compose
project from the file's parent directory, which inside a unit is the literal string `compose`. This
is why the volume half is an existence check. **NOT-A-FINDING: a documented consequence of the
unit's layout, recorded in `REUSE.md` and `CONTEXT.md` where the next reader will meet it.**