Compare commits

...
2 Commits
Author SHA1 Message Date
admin c0c8fe67bf An unknown drawn as a zero: the defect v0.226.0's own fix introduced
gates / gates (push) Failing after 13s
Writing the REPORT's observation "the no-unit fallback already reports a zero
result, which is honest" exposed that the sentence was FALSE.

A zero UnitRestoreResult is Scenario B's shape. So RestoreFromRecoveryUnit's
fallback to RestoreApp -- which returns only an error, and whose signature is
deliberately out of scope -- would have printed "ez a mentes csak a
beallitasokat tartalmazta, adatot nem" over a restore that may have replayed the
app's entire dataset. That is an unknown drawn as a zero: the exact R-88 failure
direction this whole change exists to remove, re-introduced by the change.

UnitRestoreResult now carries CountsUnknown, the fallback sets it, and there is a
fourth sentence claiming only what is known -- the restore ran, the app is back,
and we cannot say what came back. RestoreApp's signature is untouched.

Pinned by TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty. The A5 seam
test was corrected too: its fixture has no recovery unit, so it exercises exactly
this path and had been asserting the wrong sentence -- it now asserts the
unknown, which is what pins the fallback to it.

IT WAS THE observations GATE REFUSING THE PUSH THAT FORCED THE RE-READ. A gate
written to stop findings dying in an overwritten REPORT.md caught a live defect
instead. Also files R-397 (NotifyIntegrityOK/Failed are dead code AND the
monitoring page advertises a weekly integrity check that does not exist) and
R-398 (resticStep is not a seam, which is why R-358's ordering needed an AST
test) rather than leaving them in a file that is overwritten every session.

REPORT.md is the full run record: baselines re-confirmed, per-test results, the
five red-proofs with their observed output, the live validation with verbatim
Hungarian messages, what was NOT validated and why, teardown across three
layers, and the register 165 -> 167 -> 161.

Green gate clean: 28 packages, rc 0. All 12 controller gates OK.
2026-08-30 19:51:16 +02:00
admin e4e0aa8f46 REPORT: v0.226.0 shipped, three of four fixes proven live on demo-hp
Records what was validated and, in equal detail, what was not.

PROVEN LIVE (demo-hp, endpoints the UI invokes, evidence copied off the box):
  R-353  "A(z) opengist: 1 adatkotet visszaallitva -- az alkalmazas ujraindult."
         read off the customer's own wizard page, with real counts 1/1 volumes
         and 0/0 databases and correctly no database clause.
  R-360  refused in the exact flag state that produced the bug, and the planted
         canary file survived -- the consequence, not the branch.
  R-358  a mode=unit restore wrote {"schema":1,...,"full":false} at mode 0600
         with no .tmp left, and the gate logged place-to-live closed.

NOT live-validated, and each says why rather than being omitted:
  R-357  filling a real filesystem is a drill step, not a build step.
  R-353 Scenario B  NO app on demo-hp still has a data-less unit -- the spec
         named opengist from 21 August and it has since been recaptured (now
         1 volume dump). Manufacturing one means falsifying a manifest, which is
         the hand-set-state shortcut this project forbids.
  R-353 Scenario C and R-358's failed-download branch: unit-tested only.

Also recorded, because a near-miss that is quietly fixed teaches nobody: the
first B1 red-proof exposed a HOLLOW TEST OF MY OWN. With the gate removed the
run refused earlier, at the placement stat pre-pass, so `stops == 0` passed
against the pre-fix code. Fixture corrected and assertions reordered; only then
does the red-proof print THE APP WAS STOPPED (1 call(s)).

Register 165 -> 167 -> 161. Six rows compressed into CLOSED-ITEMS keeping title,
version, evidence and every sentence stating a rule; full original at
`git show e027b5d9`. No open row touched. ROADMAP not edited -- none of these
four ever had a row there, stated rather than silently skipped.

Teardown: this run provisioned nothing, across all three layers. Two throwaway
scripts and one canary directory were planted in guest 9201 and both removed.
2026-08-30 19:41:48 +02:00
5 changed files with 348 additions and 105 deletions

No files matched your search

+18
View File
@@ -109,6 +109,24 @@ fixed:** the fixture refused earlier, at the placement stat pre-pass, so `stops
the pre-fix code. The scratch is now populated the way a completed download leaves it, and the
assertions are ordered so a removed gate reports the outage rather than "no error returned".
### One defect the fix itself introduced, caught by a gate and fixed rather than filed
Writing the REPORT's observation *"the no-unit fallback already reports a zero result, which is
honest"* exposed that the sentence was **false**. A zero `UnitRestoreResult` is Scenario B's shape, so
`RestoreFromRecoveryUnit`'s fallback to `RestoreApp` — which returns only an error, and whose signature
is deliberately out of scope — would have printed „ez a mentés csak a beállításokat tartalmazta, adatot
nem" over a restore that may have replayed the app's entire dataset. **An unknown drawn as a zero: the
exact R-88 failure direction this whole change exists to remove, re-introduced by the change.**
`UnitRestoreResult` now carries **`CountsUnknown`**, the fallback sets it, and there is a fourth
sentence claiming only what is known: „A(z) X visszaállítása lefutott — az alkalmazás újraindult. Ehhez
a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle." Pinned by
`TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty`; the A5 seam test was corrected too, because
its fixture has no unit and so exercises exactly this path while asserting the wrong sentence.
**It was the `observations` gate refusing the push that forced the re-read** — a gate written to stop
findings dying in an overwritten REPORT.md caught a live defect instead.
**Green gate:** `go build ./... && go vet ./... && go test ./...` — 28 packages, rc 0.
## v0.225.0 — the hub could not tell an empty off-site store from an unmeasured one (2026-08-30, R-331)
+266 -95
View File
@@ -1,132 +1,303 @@
# REPORT — R-331: the operator Backup card said every customer had no backups
# REPORT — R-353 / R-357 / R-358 / R-360: the restore tells the truth
**Controller v0.225.0 + hub v0.109.0 · 2026-08-30**
This is Option B of a two-part request. Option A (the nightly false `app_start_failed` e-mails) shipped
earlier today as controller v0.224.0 (R-330) and is recorded in this repo's `CHANGELOG.md`.
**Controller v0.226.0 · 2026-08-30 · implemented on DooPlex, validated live on `demo-hp`**
---
## 1. What was wrong
## 1. Confirmed baselines used — BOTH HAD MOVED
The hub customer page's **Backup** card read, for **every customer, indefinitely**:
| repo | spec baseline | **actual at start** | version |
|---|---|---|---|
| `felhom-controller` | `f8c9390` / v0.223.0 → v0.224.0 | **`e5eee50` / v0.225.0** | → **v0.226.0** |
| `felhom.eu` | `c2c1fb4` | **`ac6ac03`** | docs only |
| `felhom-agent` | not touched | v0.130.0 | unchanged |
**Both targets were consumed earlier the same day by my own work** — v0.224.0 (R-330) and v0.225.0
(R-331). The drift was re-confirmed against live Gitea before the first edit and the operator
authorised proceeding. **Every symbol in the spec's §5 table was re-verified present at the real
baseline** before editing; all 20 resolved, and almost every line landmark still matched. `MinAgent`
stays **0.129.0**. Both trees were clean and equal to `origin/main` at the start.
## 2. Files created / modified
**`felhom-controller`** (all paths under repo root):
| file | change |
|---|---|
| `controller/internal/backup/restore.go` | `restoreDockerVolumes` → `(int, error)`; caller updated |
| `controller/internal/backup/restore_unit.go` | `UnitRestoreResult`; `RestoreFromRecoveryUnit` → `(UnitRestoreResult, error)` on every return path |
| `controller/internal/web/handlers.go` | `unitRestoreOutcomeMsg` + 3 message constants; handler publishes the outcome |
| `controller/internal/backup/offbox_reconstitute.go` | the R-357 headroom gate |
| `controller/internal/backup/offbox_restore.go` | scratch marker (write/clear/read), `OffboxFullScratchReady` rewritten, shared refusal constants, `SetOffboxLatestSnapshotFn`, `WriteScratchMarkerForTest` |
| `controller/internal/backup/backup.go` | `offboxLatestSnapFn` field |
| `controller/internal/web/offbox_handlers.go` | server-side scratch refusals in 2 handlers; R-360 delete guard; corrected doc comment |
| `controller/internal/backup/r357_reconstitute_headroom_test.go` | **new** |
| `controller/internal/backup/r358_scratch_marker_test.go` | **new** |
| `controller/internal/web/r353_unit_outcome_test.go` | **new** |
| `controller/internal/web/r358_r360_handlers_test.go` | **new** |
| 3 existing `*_test.go` in `internal/backup` | mechanical `_, err :=` for the changed signature |
| `CHANGELOG.md`, `CONTEXT.md`, `REUSE.md`, `controller/README.md`, `REPORT.md` | docs |
**`felhom.eu`**: `STATUS.md`, `documentation/backlog/OPEN-ITEMS.md`,
`documentation/backlog/CLOSED-ITEMS.md`, `documentation/architecture/07-backup-architecture.md`,
`documentation/architecture/00-capability-map.md`,
`documentation/audits/evidence-r353-r360-live-2026-08-30/live-validation.txt` (**new**).
## 3. Commits pushed to `main`
| repo | commit | contents |
|---|---|---|
| `felhom-controller` | **`b8af7276`** | all four fixes, all tests, controller docs |
| `felhom.eu` | **`e027b5d9`** | register closures, R-395 fix, architecture, capability map, evidence |
| `felhom.eu` | *(this report's commit)* | housekeeping compression + REPORT |
## 4. Tests and red-proofs
**All pass.** New tests, by group:
| test | result |
|---|---|
| `TestUnitRestoreOutcome_VolumesAndDatabaseNamed` (A1) | pass |
| `TestUnitRestoreOutcome_BackupHeldOnlySettings` (A2) | pass |
| `TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn` (A3) | pass |
| `TestUnitRestoreOutcome_DatabaseOnly` (A4) | pass |
| `TestR353_HandlerPublishesTheOutcome` (A5, the seam test) | pass |
| `TestR357_DestructiveRestoreRefusesWithoutHeadroom` (B1) | pass |
| `TestR357_UnknownSizeFailsClosed` (B2) | pass |
| `TestR357_UnknownFreeSpaceFailsClosed` (added — the mirror hole) | pass |
| `TestR357_AmpleSpaceIsUnchanged` (B3) | pass |
| `TestR358_FailedRestoreLeavesNoUsableScratch` (C1) | pass |
| `TestR358_UnitOnlyScratchIsNotFullReady` (C2) | pass |
| `TestR358_StaleMarkerIsClearedBeforeTheRun` (C3) | pass |
| `TestR358_UnreadableMarkerFailsClosed` (C4) | pass |
| `TestR358_WrongSchemaFailsClosed` (added) | pass |
| `TestR358_CompletedFullScratchStillReady` (C5) | pass |
| `TestR358_MarkerIsWrittenAt0600AndAtomically` (added) | pass |
| `TestR358_MarkerIsNeverPlaced` (D3) | pass |
| `TestR358_MarkerIsClearedBeforeResticAndWrittenAfter` (added, AST) | pass |
| `TestR358_PlaceHandlerRefusesIncompleteScratch` (D1) | pass |
| `TestR358_ReconstituteHandlerRefusesIncompleteScratch` (D2) | pass |
| `TestR358_UnitOnlyScratchClosesTheFullRestoreCard` (Scenario F, flow level) | pass |
| `TestR360_VerifyCopyDeleteRefusedDuringRestore` (D4) | pass |
| `TestR360_VerifyCopyDeleteStillWorksWhenIdle` (Scenario H) | pass |
**E1 — no existing test was modified for its content.** Every `TestReconstituteOutcome_*` passes
unmodified. The only test-file edits were mechanical call-site updates for the changed
`RestoreFromRecoveryUnit` signature (`err :=` → `_, err :=`) in three files.
### Red-proofs — each mutated, observed failing, reverted, `git diff` clean
| # | mutation | observed failure |
|---|---|---|
| **A5** | `EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")` restored | `THE PRE-FIX SENTENCE REACHED THE CUSTOMER: "opengist visszaállítva (snap-123)."` |
| **B1** | the whole R-357 gate deleted | `THE APP WAS STOPPED (1 call(s)) for a restore with 300 KB free for a 1 MB copy … (err=<nil>)` — and the same on both fail-closed tests |
| **C1** | the old `len(entries) > 0` check restored | `a part-copy was reported READY`, plus the unit-only and unreadable-marker cases |
| **D4** | the delete guard reverted to `IsRunning()` | `THE VERIFICATION COPY WAS DELETED while a restore was writing into it` |
| **D1/D2** | both server-side scratch refusals removed | both handlers redirected with „…elindult" over a part-copy |
> **⚠ THE FIRST B1 RED-PROOF EXPOSED A HOLLOW TEST OF MY OWN, and it is recorded rather than quietly
> fixed.** With the gate removed, the run refused *earlier* — at the placement stat pre-pass — so
> `stops == 0` passed against the pre-fix code and the test proved nothing about the thing it exists
> for. Two corrections: the fixture now populates the scratch the way a completed download leaves it
> (the snapshot's own absolute paths mirrored under the scratch, plus `SetSafetyDumpFn` so no Docker
> is needed), and the assertions are **reordered** so a removed gate reports the outage rather than
> "no error returned". Only after that does the red-proof print `THE APP WAS STOPPED (1 call(s))`.
> The lesson is the doctrine's own: a test that cannot fail on the pre-fix shape is decoration.
**Test count:** 24 new tests added across 4 new files. **Green gate:**
`go build ./... && go vet ./... && go test ./...` → **28 packages, rc 0**, no failures.
**All 12 controller gates OK.**
## 5. Deployed version
```
Enabled Yes Snapshots 0
Repo Size 0 MB Integrity Unknown
$ ssh hp "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
gitea.dooplex.hu/admin/felhom-controller:0.226.0 Up 13 seconds (healthy)
```
Measured on `demo-hp` 2026-08-30, at which moment the truth was:
**`demo-hp` only.** `demo-felhom` stays on 0.225.0 and the rest of the fleet on 0.223.0 (the floor).
| source | value |
|---|---|
| the box's own `settings.json` | `snapshot_count: 67, repo_size_bytes: 140829678, stats_known: true` |
| that night's controller log | `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s` |
| the hub's **Offsite** page | `0.1 GB` used of a `50 GB` quota — read from the stored report |
## 6. Live validation — endpoint level, on `demo-hp`
**A card that reads "no backups" over a working backup is worse than no card.** It is the R-88
direction of failure — degrading to *no backup* rather than to *unknown* — on the one screen an
operator consults to answer "is this customer protected?".
**Method:** the exact endpoints the UI invokes, driven from inside guest 9201 (no browser on
DooPlex; the residual is client-side rendering only). No state was hand-set. Evidence copied off the
box at the end of the phase: `felhom.eu/documentation/audits/evidence-r353-r360-live-2026-08-30/`.
## 2. Root cause
### The verbatim messages
The card rendered the report's **`backup`** object. Its `snapshot_count`, `repo_size_mb` and
`integrity_ok` fields have had **no producer** since disk-tier restic moved to the host agent (slice
8C) — `buildBackupReport` leaves them zero *deliberately* and says so in a comment. So the zeros were
not a bug in the controller; they were correct values for dead fields, being rendered as if live.
**R-353** — `POST /backup/restore` for `opengist`, then the sentence read off the customer's own
wizard page (`GET /backups/restore/app?name=opengist`):
**The data was never missing.** The live numbers ride in the report's **`offsite`** object
(`backup.OffboxReportStatus`), which the hub *already* reads for the Offsite page
(`offsiteUsageBytes`) and which `monitor.OffsiteChecker` *already* drives fill and staleness alarms
from. That the Offsite page rendered demo-hp's real usage from the same stored report, at the same
moment the Backup card said `0 MB`, is the proof the bytes were arriving.
```
A(z) opengist: 1 adatkötet visszaállítva — az alkalmazás újraindult.
```
So the fix is a **render change over an existing feed**, not a new pipeline.
and the controller's own lines:
## 3. Why it was not a one-line template swap
```
[INFO] [backup] Restore-from-unit completed: opengist — 1 volume(s) of 1 listed, 0 database(s) of 0 listed
[INFO] [web] Restore completed (async): stack=opengist in 9.112668936s (volumes 1/1, dbs 0/0)
```
`snapshot_count: 0` means two opposite things — *this repository holds nothing* and *nobody has ever
measured this repository*.
**This is Scenario A, not Scenario B, and the substitution is stated rather than glossed.** The spec
named `opengist` as the data-less app from 21 August. **It is not one any more** — checked before
relying on it, as the spec instructed: every unit on `demo-hp` today lists at least one volume dump
(`opengist 1/0`, `privatebin 1/0`, `calibre-web 1/0`, the rest 2–3 volumes plus a database). **No app
on the box has the Scenario B shape**, and manufacturing one would mean falsifying a manifest — the
hand-set-state shortcut this project forbids. Scenario B is carried by
`TestUnitRestoreOutcome_BackupHeldOnlySettings` and the A5 seam test. What the live run *does* prove
is the whole path: real counts, correct clause selection (no database clause for 0 databases), and
the sentence reaching the customer's page.
**R-225 already measured that exact confusion one layer down**: a rebuilt box rendered
„Tarolo meret · 0 pillanatkep" over a store that really held snapshot `f3d9cd67`, and
`settings.OffboxTarget.StatsKnown` is what fixed it for the controller's own UI. It was never put on
the wire, so the hub was free to make the identical mistake one layer up — and rendering the count
without consulting it would have **moved R-225 to the hub instead of fixing anything**.
**R-360** — a `mode=unit` scratch restore started, and the verification-copy delete POSTed while it
ran. The refusal, urldecoded from the `Location` header:
## 4. What changed
```
/backups/restore?flash_error=Egy visszaállítási művelet (kimai) már fut, ezért most nem indítható
újabb. Az állapotát ezen az oldalon követheted; amint befejeződik, újra indíthatsz visszaállítást.
```
**Controller (v0.225.0) — one field, and the dead ones labelled.**
```
[WARN] [web] verification-copy delete refused for kimai: a backup/restore op is running
```
- `OffboxReportStatus.StatsKnown` now forwards `settings.OffboxTarget.StatsKnown`. It is `omitempty`,
so a controller below this version sends no key, a reader sees `false`, and **false means "cannot
answer", never "the answer is zero"**. Absence is ignorance, not emptiness.
- The four dead `BackupReport` fields (`SnapshotCount`, `RepoSizeMB`, `LastIntegrityCheck`,
`IntegrityOK`) are **kept on the wire** so historical reports in the hub's store keep parsing, but
now carry a warning naming R-331 and pointing at `Offsite`. `TestBackupReport_DeadFieldsStayZero`
fails the moment a producer appears for any of them — the prompt to update the hub card in the
**same** change rather than shipping a field nothing renders.
**And the consequence, which is the assertion that matters:** a canary file planted in the copy was
still there afterwards — `-rw-r--r-- 1 root root 10 Aug 30 17:34 canary.txt`, contents `defend-me`.
**Hub (v0.109.0) — the card reads `offsite`, resolved in Go.**
**R-358 / R-396** — after the `mode=unit` restore completed:
`backup_card.go` builds a typed view, because the card's whole subject is a distinction a template
`{{if}}` chain over `map[string]interface{}` float64s cannot keep:
```
$ cat …/backups/offsite-restore/kimai/.felhom-restore-complete.json
{"schema":1,"snapshot_id":"84542ec8","full":false,"finished_at":"2026-08-30T17:34:42Z"}
mode=600 (no .tmp left behind)
| report state | card shows |
|---|---|
| no `offsite` object at all | "No off-site data reported" — **and says explicitly this is not the same as "no backups"** |
| `enabled:false` + declared `state` | the blocker by name (`needs_credential`) — a different operator action from "not enabled" |
| enabled, `stats_known:false` | **&mdash;**, plus "never been measured". Never `0` |
| enabled, `stats_known:true` | the real count and size, **including a real `0`** — measured empty is knowledge |
[INFO] [offbox] kimai: scratch holds a UNIT-ONLY restore (snapshot 84542ec8) — not a full copy,
so place-to-live stays closed
```
**The Integrity row is deleted, not re-sourced.** Nothing produces it: the controller runs no
integrity check, and `NotifyIntegrityOK` / `NotifyIntegrityFailed` exist and are **called from
nowhere**. A row that can only ever read "Unknown" is not information, and one that could read "OK"
from an unwritten field would be a lie.
**This is Scenario F proven live**, and it is the case that pre-fix would have unlocked the
destructive restore.
`fmtBytesAuto` is new rather than reusing the Offsite page's `fmtBytesGB`: that one is fixed at GB
because it renders against GB quotas, and it turns demo-hp's real 140 829 678 bytes into `0.1 GB` —
which on a card whose entire defect was under-reporting a real backup reads as "nearly nothing".
## 7. NOT yet live-validated — awaiting a supervised drill
## 5. Tests and red-proofs
1. **R-357's disk-full behaviour.** Filling a real filesystem to prove it is a drill step, not a build
step. Carried by the seam tests, whose central assertion is `StopStack` call count 0.
2. **R-353's Scenario B** (the "backup held only settings" sentence) — no app on `demo-hp` has that
unit shape any more; see §6.
3. **R-353's Scenario C** (the unit lists dumps, none return) — the R-367 stranded-dump shape was not
reproduced on live hardware; unit-tested only.
4. **R-358's failed-download branch** was not induced live. It was not needed: the unit-only branch
exercises the same marker gate through a real restore, without pointing restic at a bad snapshot.
The hub tests assert the **rendered page**, using demo-hp's real reported values, so a regression fails
against the same numbers the defect was measured against. The defect lived in the template's choice of
source object, so a test one layer below it would have been green against the shipped bug.
## 8. Teardown
| test | pins |
|---|---|
| `TestBackupCard_RendersTheRealSnapshotCount` (hub) | 67 and 134.3 MB are on the page; no `0 MB`; no Integrity row |
| `TestBackupCard_ThreeWayRuling` (hub) | unmeasured ≠ measured-empty ≠ no-object, all four branches |
| `TestBackupCard_OldControllerDegradesToUnknownNotEmpty` (hub) | upgrading the hub ahead of the fleet must not report every customer as empty |
| `TestFmtBytesAuto_...` (hub) | the real byte value renders as 134.3 MB, not 0.1 GB |
| `TestOffboxReportStatus_CarriesStatsKnown` (controller) | the field is on the wire |
| `TestOffboxReportStatus_MeasuredEmptyIsNotUnmeasured` (controller) | asserted on the **JSON**, not the struct — the hub sees bytes |
| `TestOffboxReportStatus_AbsentStatsKnownParsesAsUnknown` (controller) | the fail-safe direction |
| `TestBackupReport_DeadFieldsStayZero` (controller) | the dead fields stay dead |
**This run provisioned nothing.** No machine, no guest, no hub record — all three layers:
**Red-proofs, each printing the pre-fix value:**
- **Layer 1 (host):** nothing created on `felhom-pve` or `demo-hp`. No VM, no CT, no storage entry.
- **Layer 2 (guest):** two throwaway shell scripts were pushed into guest 9201 to drive the endpoints
and **both were removed**; one canary directory was planted under `backups/offsite-restore/kimai`
on the system drive to prove R-360 and **was removed**. The `mode=unit` scratch restore left a
normal verification copy on the HDD, which is ordinary product state a customer can delete.
- **Layer 3 (hub):** no customer, appliance or host record created, so none to discard.
1. Restore the pre-fix card markup → **all four hub tests fail**, reporting `67` and `134.3 MB` absent
from the rendered page and the `Integrity` row present.
2. Drop `StatsKnown: t.StatsKnown` from `OffboxReportStatus()` → `a MEASURED empty repository reported
stats_known=<nil>`.
`demo-felhom`, `ep0`, DooPlex and Peti's box were not touched.
Both restored immediately; `git diff` clean afterwards.
## 9. Register
**Green gates:** controller `go build && go vet && go test ./...` — 28 packages, rc 0. Hub, same
command in `hub/` — 18 packages, rc 0.
**Size: 165 rows before → 167 after filing → 161 after housekeeping.**
## 6. Deployment and live verification
- **Closed:** R-353, R-357, R-358, R-360 — each with shipping version and evidence path.
- **Filed and closed in the same session:** **R-396** (Scenario F's answer — see §11) and **R-395**
(`STATUS.md` contradicting itself; **fixed**, not merely recorded).
- **Re-ranked:** none.
- **Housekeeping:** the six closed rows were compressed into `CLOSED-ITEMS.md` keeping title,
version, evidence and every sentence that states a rule; the full original is
`git show e027b5d9 -- documentation/backlog/OPEN-ITEMS.md`. **No open row was touched.**
- **`ROADMAP.md` was not edited:** none of these four ever had a ROADMAP row — they live in the
register only. Stated rather than silently skipped.
Recorded in the hub's own `REPORT.md`; the two halves ship together (the hub renders "unknown" for any
box still on 0.224.0, which is correct rather than wrong).
## 10. Documentation coupling (§5.5)
## 7. Not done, and why
- **`00-capability-map.md`** — one new row, splitting the claim: R-353/R-358/R-360 **PROVEN-LIVE**
with the evidence citation; **R-357 IMPLEMENTED only**, with the reason.
- **`07-backup-architecture.md`** — §10.2 gained five rows (the four plus R-396). **§8 matrix row 3
KEEPS its PROVEN status**, with a note recording why: R-353 was a defect in the *message*, not the
mechanism.
- **`OPEN-ITEMS.md` / `CLOSED-ITEMS.md` / `STATUS.md`** — as §9.
- **`ROADMAP.md`** — no applicable rows.
- **Website version bump:** not applicable — the site does not display the controller version
(checked, not assumed).
- **No staleness verdict on the card.** `monitor.OffsiteChecker` already owns that and alarms on it.
A second verdict on the same data is two things that can disagree, which this codebase has been bitten
by before (`LastRun` vs `LastSuccess`, R-100).
- **The dead `BackupReport` fields were not removed from the wire.** Removing them would stop
historical reports already in the hub's store from parsing, for no gain — nothing renders them now,
and a test fails if anything starts producing them.
## 11. Observations — each with a register row, or a stated reason it needs none
1. **FILED: R-396.** *Scenario F's question, answered — and the answer is worse than the question
assumed.* The spec asked whether the real UI flow can reach a state where a unit-only scratch makes
the full-restore action appear. **It can, by the safest-looking action on the page.**
„Ellenőrző visszaállítás" (`mode=unit`, the default, advertised as non-destructive) calls
`RestoreOffboxScratch(full=false)`; `offboxRestoreScratchDir` **ignores `full`**, so both modes
write the same directory, and `--include` limits *what* restic extracts, never *where*; the wizard
sets `ScratchReady` from `OffboxFullScratchReady`; `deriveWizardStep` derives **both**
`PlaceEnabled` and `RestoreEnabled` from that one flag. R-358 as filed assumed the bad state needed
a *failed* download. It needed only a successful safe one. **The generalisable defect: one boolean
answered three different questions** — "is there a scratch", "may we place", "may we destructively
restore" — and the weakest of the three set the answer.
2. **FILED: R-397.** *`NotifyIntegrityOK` / `NotifyIntegrityFailed` are dead code and the product
advertises a check that does not exist.* Both notifiers have no caller anywhere; the controller
runs no integrity check at all. **The part that actively misleads:** `config.Monitoring.PingUUIDs`
carries a `backup_integrity` field and the monitoring page renders
„Mentés integritás — Hetente (vasárnap)", so the operator is told a weekly check runs. Noticed
twice in one day from opposite directions (R-331's dead report fields, then this task), which is
why it earned a row rather than a note.
3. **FILED: R-398.** *`resticStep` is not a seam, so no test can drive any restic-backed path.* Felt
directly here: R-358's safety property is an ORDER (clear before restic, write after) and with no
seam it could only be pinned by an **AST walk** rather than by execution. That is honest about what
it proves and would still miss a reordering introduced through a helper. The contrast is the
argument: `offboxLatestSnapshot` got a seam in this same release, in four lines, because a
correctness gate could not otherwise be proven.
4. **NOT-A-FINDING: it was ACTED ON in this release instead of filed — and the acting is the story.**
`RestoreApp`'s own `restoreDockerVolumes` count is still discarded, and the spec scopes
`RestoreApp` out. My first draft justified that as *"already reported as a zero result, which is
honest, because a box with no recovery unit really did return no data from one."*
> **⚠ THAT SENTENCE WAS FALSE, AND WRITING IT EXPOSED A DEFECT THE FIX ITSELF HAD INTRODUCED.**
> A zero `UnitRestoreResult` is *Scenario B's shape*, so the no-unit fallback would have printed
> „ez a mentés csak a beállításokat tartalmazta, adatot nem" over a restore that may have replayed
> the app's entire dataset — **an unknown drawn as a zero, the exact R-88 failure direction this
> whole change exists to remove**, re-introduced by the change itself.
>
> Fixed rather than filed: `UnitRestoreResult` now carries `CountsUnknown`, the fallback sets it,
> and the surface has a fourth sentence claiming only what is known — the restore ran, the app is
> back, and we cannot say what came back. **`RestoreApp`'s signature is untouched**, so §5 holds.
> Pinned by `TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty`; the A5 seam test was
> corrected too, because its fixture has no unit and therefore exercises exactly this path while
> asserting the wrong sentence.
>
> **It was the `observations` gate refusing the push that forced the re-read.** The gate exists to
> stop findings dying in an overwritten REPORT.md; here it caught a live defect instead.
5. **NOT-A-FINDING: the spec's own instruction already handled it, so there is nothing to carry forward.** §13 step 1 named `opengist` as
the data-less app; it is not one any more. Not a defect in the spec: it said *"confirm its manifest
first rather than assuming"*, and that is exactly what caught it. Recorded only as a reminder that
fixture assumptions about live boxes decay faster than the documents naming them.
## 12. Two gates fired; one push was bypassed, and both are declared
**`felhom.eu` — `golden-currency` CONVICTED.** Three controller releases shipped today (0.224.0,
0.225.0, 0.226.0) and the vouched golden still carries **0.223.0**, so a machine installed right now
receives none of them. **The gate is right.** A **BYPASS, not a waiver**, on the operator's standing
ruling from earlier today — re-checked for this release rather than reused blindly: all three are
invisible to a day-0 box, and a *restore-surface* fix in particular has nothing to act on there.
**The ground expires the moment a release changes first-boot behaviour.** Tracked on **R-242**; **one
bake carrying 0.226.0 covers all three**, then raise the floor to 0.226.0.
**`felhom-controller` — nothing was bypassed.** The `observations` gate refused the first REPORT push
and was **fixed, not bypassed** — see §11 item 4 for what that cost and what it caught.
**And one earlier gate was fixed rather than bypassed:** `due-checks` was red on R-341's overdue
`+7 d` measurement. It was **taken** during this session (ep0: fd **17**, the baseline, same proxy
generation, PID 551655 unchanged), and the verdict recorded as **unanswerable** — our own R-344 fix
removed the leak mid-interval, so a slope of ~0 measures that fix and not the PBS upgrade the row was
asking about.
+14 -5
View File
@@ -155,6 +155,16 @@ type UnitRestoreResult struct {
ManifestVolumes int
// ManifestDBs is len(manifest.DBDumps): the same claim for the database leg.
ManifestDBs int
// CountsUnknown marks a run whose counts could not be established AT ALL — today the one case is
// the no-unit fallback to RestoreApp, which returns only an error and whose signature is
// deliberately out of scope.
//
// IT EXISTS BECAUSE THE ZERO VALUE WOULD OTHERWISE LIE. Without it a fallback restore that really
// replayed three volumes reports VolumesReplayed=0 / ManifestVolumes=0 — the shape the surface
// reads as „ez a mentés csak a beállításokat tartalmazta, adatot nem". That is a confident false
// statement, and precisely the R-88 failure direction (degrade to NO DATA rather than to UNKNOWN)
// this whole change exists to remove. An unknown must be carried, never drawn as a zero.
CountsUnknown bool
}
// RestoreFromRecoveryUnit recreates an app from its on-drive recovery unit.
@@ -199,11 +209,10 @@ func (m *Manager) RestoreFromRecoveryUnit(stackName string) (UnitRestoreResult,
m.mu.Lock()
m.running = false // RestoreApp re-acquires the running flag
m.mu.Unlock()
// The fallback path has no unit and therefore no manifest to count against: a ZERO result is
// the honest answer, not a missing one. The surface must be able to tell "nothing came back"
// from "we never looked", and it can — ManifestVolumes/ManifestDBs are zero too, which is
// Scenario B's shape and reads as "the backup held no data", which is exactly true of a box
// with no recovery unit.
// The fallback has no unit and no manifest, and `RestoreApp` returns only an error — so the
// counts here are genuinely UNKNOWN, not zero. Reporting zero would tell a customer whose
// volumes were just restored that their backup held no data.
res.CountsUnknown = true
return res, m.RestoreApp(stackName, "")
}
+10
View File
@@ -1528,6 +1528,12 @@ func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
}
return fmt.Sprintf(unitRestoreDataMsgFmt, app, what)
}
// An unknown must never be drawn as a zero. The no-unit fallback cannot report counts, and the
// zero-value shape would otherwise read as „the backup held only settings" over a restore that may
// have replayed the app's whole dataset. Same failure direction as R-88: degrade to UNKNOWN.
if res.CountsUnknown {
return fmt.Sprintf(unitRestoreCountsUnknownMsgFmt, app)
}
if res.ManifestVolumes+res.ManifestDBs > 0 {
return fmt.Sprintf(unitRestoreNoneReturnedMsgFmt, app, res.ManifestVolumes, res.ManifestDBs)
}
@@ -1551,6 +1557,10 @@ const (
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
// replay only ever writes into volumes, so a replay that did nothing removed nothing, and a customer
// who believes otherwise will do something worse than waiting.
// unitRestoreCountsUnknownMsgFmt — the no-unit fallback. It claims only what is known: the restore
// ran and the app is back. It deliberately does NOT say data returned and does NOT say it did not.
unitRestoreCountsUnknownMsgFmt = "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid."
unitRestoreNoneReturnedMsgFmt = "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod."
)
@@ -170,14 +170,49 @@ func TestR353_HandlerPublishesTheOutcome(t *testing.T) {
t.Fatal("the restore never reached a terminal status")
}
// There is no recovery unit and no data on this drive, so nothing came back: Scenario B.
// THE ASSERTION THAT CARRIES THE SEAM: whatever branch fires, the sentence the customer is shown
// must be `unitRestoreOutcomeMsg`'s output and not the pre-fix string.
if strings.Contains(last, "visszaállítva (snap-123)") {
t.Fatalf("THE PRE-FIX SENTENCE REACHED THE CUSTOMER: %q — it is true of a restore that "+
"returned an entire dataset and of one that returned nothing", last)
}
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(last, want) {
t.Fatalf("the published outcome does not state that no data came back; missing %q in %q", want, last)
}
// This fixture has no recovery unit, so the production path takes the RestoreApp fallback — where
// the counts are genuinely unknown. The honest sentence for that is the unknown one, and asserting
// it here is what pins the fallback to it: the zero-value shape would otherwise print
// „csak a beállításokat tartalmazta" over a restore that may have returned everything.
if strings.Contains(last, "csak a beállításokat tartalmazta") {
t.Fatalf("the no-unit fallback claimed the backup held no data, from counts it never "+
"established: %q", last)
}
if !strings.Contains(last, "nem tudjuk megmondani") {
t.Fatalf("the published outcome does not state the unknown as unknown; got %q", last)
}
}
// TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty — the defect the first draft of this fix
// introduced, caught by the observations gate forcing a re-read of my own code.
//
// `RestoreFromRecoveryUnit` falls back to `RestoreApp` when there is no recovery unit, and `RestoreApp`
// returns only an error — its signature is deliberately out of scope. So the result is a ZERO value,
// and a zero `UnitRestoreResult` is Scenario B's shape: „ez a mentés csak a beállításokat tartalmazta,
// adatot nem". That sentence would be printed over a fallback restore that had just replayed the app's
// entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what
// this whole change exists to remove — so it must not be re-introduced by the fix itself.
func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) {
msg := unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " +
"established — a fallback restore may have returned everything they own")
}
if strings.Contains(msg, "adatkötet") {
t.Fatal("claimed a volume count that was never measured")
}
if !strings.Contains(msg, "nem tudjuk megmondani") {
t.Errorf("an unknown must be STATED as unknown, not left silent; got %q", msg)
}
// And it must still tell them the restore ran, or the sentence reads as a failure.
if !strings.Contains(msg, "lefutott") {
t.Errorf("the message must say the restore completed; got %q", msg)
}
}