v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
gates / gates (push) Successful in 21s

THE VERDICT. The R-203 block already said "a warning beside a success is read as a
success" and applied it to ONE of the two shapes it describes: an app missing a
declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY
still reported ok. Both do now. Which skips count, decided by measurement:
selected+deployed with no recovery unit YES; selected but NOT deployed no (named,
with what to do — a box left amber by an app somebody removed is a status nobody
reads); disconnected/decommissioned drive no (own signal); nothing selected no.
LastSuccess and SnapshotCount still record what WAS captured.

THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3
stated that toggling an app on leaves it without a bundle so the first run skips
it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits
for every DEPLOYED stack, through admitApp, before the push — a unit moved aside
was RECREATED and the run reported ok. That state does not survive a run.

What actually produced the 2026-08-06 sequence: the manual run was dropped by the
single-flight while an earlier run was still going. runOffboxBackup returned nil,
the handler had already answered "A tavoli mentes elindult", and the card then
showed the PREVIOUS run's green verdict — read as covering the app just selected.
The decision is now taken synchronously in the handler and a dropped request says
so. The nightly path still returns nil on purpose: nobody asked, and it retries.

§7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose +
manifest, only ENUMERATES dumps rather than creating them, is idempotent and does
NOT stop the app — and already runs inside the off-site run. So there is no wait to
remove for a deployed app and NOTHING was built.

28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture
note: the shared provider's ListDeployedStacks returned nil, so Scenario A first
passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
This commit is contained in:
2026-08-06 21:58:21 +02:00
parent 53e9bf0224
commit c6b69d888e
11 changed files with 578 additions and 84 deletions
+91 -67
View File
@@ -1,91 +1,115 @@
# REPORT — v0.204.0: the restore list is keyed on the store (R-237), and the size gate stops refusing in silence (R-238)
# REPORT — v0.205.0: a backup that skipped an app the customer chose is not „Rendben" (R-234)
2026-08-06. Controller **v0.203.0 → v0.204.0**. MinAgent unchanged (**0.127.0**) — nothing here needs
a new agent capability. `felhom-agent` untouched.
2026-08-06. Controller **v0.204.0 → v0.205.0**. MinAgent unchanged (**0.127.0**). `felhom-agent`,
`app-catalog-felhom.eu` untouched. No hub change.
## R-238 — classified: a HARNESS ARTIFACT, with a real residue that is fixed
## The correction that outranks the task
The two runs diverge at one parameter, and the state at that point is quoted rather than inferred:
§3 stated the mechanism as established: *"the off-site copy sends the local recovery bundle an app
already has; switching an app on does not create one, so the first run after the toggle finds nothing
to send."* **Measured on demo-hp, that is not what the code does.**
- `POST /backup/offbox/restore` with `mode=full` and **no** `confirm=1` is **step 1 of a deliberate
two-step** (`offbox_handlers.go:314`). It computes size + headroom via `OffboxRestorePrepareFull`,
**starts no job**, and redirects to
`restoreWizardPath(app) + "&full_prep=<app>&full_size=<size>"`.
- `deriveWizardStep` (`restore_wizard.go`) reveals the commit **only** when
`in.FullPrepApp == in.App`, sourced from `?full_prep=`.
- The endpoint-level driver posted step 1 and then re-fetched the wizard **without** that parameter.
The pure function therefore returned the **intent** step — correctly. `restore-status.last == null`
is likewise **correct**: step 1 starts no job by design.
- The operator's browser run followed the redirect, saw the confirm, pressed „Igen" twice, and the
restore completed.
The off-site run's own **pre-dump phase** calls `captureAllRecoveryUnits()`, which writes a unit for
**every deployed stack** — through `admitApp`*before* the per-app push loop. I moved
`calibre-web`'s unit aside (never deleted) on demo-hp and triggered a run through the real endpoint:
**So the button is not dead, and the wizard was not re-keyed.** The precedence rules exist so a stale
`?full_prep=` can never resurrect a commit button mid-restore, and they were left alone.
```
BEFORE : units = calibre-web, opengist, privatebin status=ok snapshots=9
moved : calibre-web unit → .calibre-web.R234-aside
RUN : POST /backup/offbox/run → 302, finished ~90 s, status=ok
AFTER : status=ok snapshots=9 warning=(none)
unit : /mnt/sys_drive/felhom-data/backups/primary/calibre-web → RECREATED
```
**The residue, which is real whoever triggers it:** neither branch of step 1 wrote anything to the
log. `offboxRedirectTo` only flashes to the page. A customer refused a disaster restore — **including
a refusal by the headroom gate** — left **no trace on the box at all**. Fixed: the refusal logs
`[WARN] … full-restore preparation REFUSED for <app> (no job started): <err>`, the success logs
`[INFO] … full-restore prepared for <app> (size N) — awaiting the customer's confirm; no restore has
started`, and the concurrent-op refusal logs too.
So "selected but no bundle yet" **does not survive a run** for a deployed app. The filed mechanism
could not have produced the 2026-08-06 sequence.
## R-237 — the restore list, rebuilt on the store
**What did.** `POST /backup/offbox/run` launched the run in a goroutine and immediately answered
„A távoli mentés elindult". Inside, `acquireRunning` refused (a run was already in flight) and
`runOffboxBackup` returned **nil** — no error, no signal. The card then showed the **previous** run's
„✓ Rendben · 1 pillanatkép", read as covering the app just selected. It did not: the restore refused
for that app minutes later, and a third run carried it. That is R-234's real cause, and it is the same
family — a skip that reached a log and not a verdict.
`buildOffsiteRestoreRows` (new, pure) merges `OffsiteInventoryList` (the repository's own snapshot
tags — the existing R-193 reader) with the installed set. `resolveOffsiteRestoreApp` replaces
`resolveWizardApp`'s toggle requirement.
## Live validation (§12)
Every §7 case, and the Hungarian as rendered:
| # | what | observable |
|---|---|---|
| 1 | selected app with no unit → run | the run **recreated the unit** and reported ok — the measurement above. The verdict change is exercised by the run-level tests, because production cannot easily be held in that state |
| 2 | counters intact | `snapshot_count` 9 → 9, `last_success` advanced to `2026-08-06T19:34:27Z` — what was captured is still recorded |
| 3 | the third-run behaviour arriving sooner | **not applicable as filed**: there is no wait for a deployed app; the unit is created by the run itself (§7.3) |
| 4 | a box with nothing selected | unchanged — Scenario D is pinned by test, and demo-hp's other apps stayed `ok` |
| case | rendered |
|---|---|
| snapshot present, app not installed | restore offered + „Nincs telepítve — a visszaállítás előbb újratelepíti." |
| installed, no snapshot | „Nincs mentése a távoli tárolóban — nincs mit visszaállítani." |
| store unreadable | „Nem tudjuk elolvasni a távoli tárolót, ezért **nem tudjuk, mi van benne**. Ez nem azt jelenti, hogy üres — próbáld újra később, vagy jelezd az üzemeltetőnek." + „Nem tudjuk, van-e mentése — a tárolót nem sikerült elolvasni." **and the action stays offered** |
| no target yet | „A távoli tároló kapcsolódási adatai még nem érkeztek meg ehhez a géphez, ezért még nem tudjuk megmutatni, mi van benne. Ez magától rendeződik." |
| store empty | „A távoli tároló üres — nincs mit visszaállítani." |
| app under a different name | **not guessed** — it lists under the tag the store holds, and if nothing is installed under that name the row says so. No fuzzy matching. |
**Access note:** demo-hp's dashboard password had been changed by the customer claim, so I reset it
through the documented `--print-reset-code` escape hatch (code streamed file→file, extracted by shape,
shredded; the new password is a 24-char generated value in a `0600` file on DooPlex). That is a
deliberate change to a Tier-0 demo box, recorded here.
Wizard refusals also changed: „Ehhez az alkalmazáshoz nincs mentés a távoli tárolóban." and, when the
store could not be read, „Nem tudjuk elolvasni a távoli tárolót, ezért nem tudjuk, van-e benne mentés
ehhez az alkalmazáshoz." Both log an INFO naming the app and the store state.
## §7.2 — which skips count, as decided
`felhom-offbox` and `_shares` are excluded from the app list.
| skip | counts? | why |
|---|---|---|
| selected + **deployed**, no recovery unit | **YES**`incomplete` | the app the customer chose is not in the snapshot at all |
| selected but **not deployed** | **no** — named, with what to do | a box left amber forever by an app somebody removed is a status nobody reads |
| drive disconnected / decommissioned | **no** | it has its own card and its own signal; re-reporting it here would double-count |
| nothing selected at all | **no** | unchanged: the existing „nincs mentésre jelölt alkalmazás" notice |
## Tests
## §7.3 — the measurement, and the decision it forced
`CaptureRecoveryUnit` writes `compose/` (docker-compose.yml, .felhom.yml, app.yaml) + `manifest.json`
**a few KB**, per `admission.go`'s own note — and **enumerates** the DB/volume dumps already present
rather than creating them. It is idempotent (skips all writes when current) and **does not stop the
app**; the stopping work belongs to the separate dump flow.
**Decision: nothing to build.** The cheap, non-stopping capture already runs for every deployed stack
inside the off-site run's pre-dump phase, through `admitApp`. For a deployed app there is no wait to
remove, and the §7.3 branch that would have added an inline capture would have duplicated it.
## §7.4 — every changed Hungarian string
- „Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek."
- „Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon."
- „Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik."
- **sibling, extended so both read alike:** „Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s. **Ellenőrizd, hogy a mappák megvannak-e a meghajtón; ha igen és ez a következő mentés után is látszik, szólj az üzemeltetőnek.**"
The replaced sentence was: „Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s" — a count with no next step, reading the same whether the customer must act or simply wait.
## Tests and red-proofs
`go build` · `go vet` · `go test ./...`**28 packages ok**. `controller_gates.py --fast`**9/9 OK**.
New: `offsite_restore_list_test.go` (7 tests — the truth table, the rebuilt box, unreadable-is-unknown,
no-target, empty, and two rendered-page tests) and `offbox_restore_silence_test.go` (handler-level,
because the silence was in the handler).
New: `internal/backup/offbox_verdict_r234_test.go` (Scenarios A, C, D, F — run-level) and
`internal/web/offbox_run_inflight_test.go` (handler-level).
## Red-proofs — each mutation asserted to have applied before the result was trusted
| # | mutation | result |
| # | mutation (each asserted to have applied) | result |
|---|---|---|
| RP-1 | store rows dropped from the builder — the list keyed back on installed apps (the original defect) | `TruthTable` + `RebuiltBox_SeesItsSnapshots` **FAIL** |
| RP-2 | `state` forced to `known` **and** `StoreUnknown` forced false (both guards) | `UnreadableStoreIsUnknownNotEmpty` + `NoTargetIsItsOwnState` **FAIL** |
| RP-3 | both size-gate log lines removed | `FullPrepareRefusal_IsNotSilent` **FAIL** — „wrote NOTHING to the log" |
| RP-1 | drop `unprotected` from the verdict — the defect itself | `SkippedSelectedAppIsIncomplete` **FAIL** — „LastStatus = ok, want incomplete" |
| RP-2 | remove the classification switch; count **every** skip | `SelectedButUndeployedIsNamedNotCounted` **FAIL** — a removed app turns the box amber |
| RP-3 | stop adding skipped apps to the operator signal | `SkippedSelectedAppIsIncomplete` **FAIL** — „operator signal … got map[]" |
| RP-4 | remove the synchronous in-flight check in the handler | `InFlightRequestIsNotReportedAsStarted` **FAIL** |
All three restored; suite green again afterwards.
All restored; `grep -c RED-PROOF` = 0 in both files afterwards; suite green again.
**Fixture note:** the shared `offbox3aProvider.ListDeployedStacks()` returned nil, so my first run of
Scenario A passed *for the wrong reason* and F passed vacuously. Fixed with an **opt-in** `deployed`
map that defaults to nil, so no existing fixture's behaviour moves.
## Files
- new `internal/web/offsite_restore_list.go`, `internal/web/offsite_restore_list_test.go`,
`internal/web/offbox_restore_silence_test.go`
- `internal/web/restore_wizard.go` (gate on the store, not the toggle)
- `internal/web/offbox_handlers.go` (three log lines)
- `internal/web/handlers.go` (wire the rows)
- `internal/web/templates/backups_restore.html` (the list + the state wording)
- `internal/web/backups_split_test.go` (fixture: the new data contract)
- `internal/backup/offbox_inventory.go` (`ErrNoOffsiteTargetSentinel`, so the no-target case is
constructible from another package's table test)
- `CHANGELOG.md`, `controller/README.md`
- `internal/backup/offbox.go` `ErrOffboxRunInFlight`, `offboxWholeUnitGap`, the skip classification,
`missingUnprotected`/`missingNotDeployed`, `stackDeployed`, `driveUnavailableFor`, the verdict, the
messages
- `internal/backup/offbox_capture.go` — the sibling message
- `internal/backup/backup.go``AcquireRunningForTest`/`ReleaseRunningForTest` (test-only seams)
- `internal/web/offbox_handlers.go` the synchronous single-flight refusal
- new `internal/backup/offbox_verdict_r234_test.go`, `internal/web/offbox_run_inflight_test.go`
- `internal/backup/offbox_3a_test.go` — opt-in deployed set
- `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`
## Not done here, deliberately
## Observations, not acted on
- **R-236 is diagnosed, not fixed** — the fix's shape depends on the diagnosis, and this arc has twice
shipped a fix aimed at the wrong half of a defect.
- The R-193 unlock listing still shows the `felhom-offbox` marker as if it were an app. Noticed while
reusing its reader; **out of scope and not touched.**
- **The zero-selection notice reads „Sikeres — nincs mentésre jelölt alkalmazás"** while the status is
`ok`. It is honest, but "Sikeres" beside "nothing is covered" is the same rhetorical shape this
session is about, one notch weaker. Not touched: Scenario D forbids changing that path.
- **`res.missing` is still used verbatim** for the no-silent-success error text (every app missing →
`error`). Correct as-is, and deliberately left, since that path already refuses loudly.