v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it

Two defects E-2d found on a real box, fixed in this order deliberately: the
message is corrected BEFORE it is put on screen, because switching on a banner
that lies is worse than a silent one.

R-114 — the third state. resolveBackupTargetState had two outcomes: a disk
claims the target (healthy), or nothing does (degraded, "the backup is on the
system disk"). The state "configured, and its drive is gone" had no branch, so
it fell into the second and inherited its message AND its offer. Observed live
with the target detached: degraded:true, target:"felhom-backup" plus the
system-disk copy (false -- the backup was on a drive that had vanished) plus
offer_path naming that same vanished drive as the remedy.

New BackupTargetState.TargetAbsent discriminates. Degraded keeps its meaning
("is there a problem") so the wire contract is unchanged for every consumer;
TargetAbsent answers "which problem", because the two have opposite remedies --
attach any second drive, versus reconnect THAT one. Copy routed through
degradedMessageFor so one place still decides what a customer reads. The offer
is suppressed on the branch itself, NOT left to firstOfferableDrive's
Disconnected skip: that flag is set by the agent-side gate in another repo
(R-113), and this state must be correct independently of it.

R-112 — the state finally has a consumer. The endpoint was byte-correct and
nothing in the product ever asked for it: templates fetch 18 distinct
/api/storage/* endpoints and backup-target[/assign] were the only two with zero
references. Server-rendered on /backups now, following the existing
SingleCopyWarning banner pattern -- not a 19th JS fetch, because a banner that
needs JavaScript to appear is one more thing that can silently not happen.
backupTargetView returns nil for healthy and unknown so those render nothing at
all. The offer control POSTs to the existing assign endpoint behind the standard
inline confirm, never auto-submits, and surfaces restart_required honestly
instead of adding a self-restart.

Scenario E (the seam test) drives backupsHandler over httptest and asserts the
RENDERED HTML -- handler -> view -> resolver -> template. It deliberately does
not call the resolver and assert a string, which would prove the resolver that
was never broken. Deleting the one line that sets data["BackupTarget"]
reproduces the R-112 state and fails every render assertion.

Tests 326 -> 338 (+12) in internal/web; suite green (27 packages); both template
gates pass. Three red-proofs run and reverted, files byte-identical after.

MinAgent unchanged at 0.113.0: R-114 reads BackupTarget/MountPath/GuestPath/Role,
none of which R-113 altered (it changed BoundUnderParent, which this code does
not read). demo-hp on agent 0.113.0 is not held.

The absent copy is verbatim the hub's customerMessages["backup_target_absent"]
so the banner and the email tell one story -- filed as a two-repo drift risk,
not solved.

NOT LIVE-VALIDATED. Scenario C cannot occur on a healthy box; Session C proves it.
This commit is contained in:
2026-07-29 19:21:32 +02:00
parent cdaeb36972
commit b331f18424
10 changed files with 651 additions and 77 deletions
+76 -64
View File
@@ -1,84 +1,96 @@
# REPORT — R-101 + F-DIAG: the customer must not be told a failed backup is a copy (v0.182.0) (2026-07-28)
# REPORT — R-114 + R-112: tell the truth about the backup target, then show it (v0.186.0, 2026-07-29)
**Overwritten** per the standing rule. Controller **v0.181.0 → v0.182.0**. Full arc incl. Phase 0, the
F-OPS runbook and the rendered dialogs: `felhom.eu/REPORT-r101.md`.
**Overwritten** per the standing rule. Controller **v0.185.1 → v0.186.0**. Session B of three
(A → R-113 agent v0.114.0 shipped-unvalidated · **B → this** · C → one drill rebuild proving all three).
`felhom-agent` untouched. **NOTHING HERE IS LIVE-VALIDATED** — see the bottom.
## Baselines (reconfirmed, not copied)
`felhom-controller 3db8bfb` = origin/main, clean; 0.181.0 live on both boxes; hub 0.80.0 ready 1/1.
## Baselines
## R-101 — misinformation at a decision point, not an alarm bug
felhom-controller `cdaeb369726b` v0.185.1 · felhom-agent `b58d7bcf39a9` v0.114.0 (untouched) ·
felhom.eu `338b2ccf86`. All clean and pushed at start.
`Tier2LastRun` is the ATTEMPT clock (`recordTier2Failure` writes it too) and it was rendered as
„Legutóbbi másolat" in the **restore confirm dialog**. The restore it guards fills in MISSING files
without touching existing ones — so a customer whose Tier-2 had been failing was told a copy existed
from last night, restored, and silently received **older** files while believing they were recent. No
error, no signal, and the customer's own reason for restoring quietly defeated.
**MinAgent decision: unchanged at 0.113.0.** R-114 reads `BackupTarget`, `MountPath`, `GuestPath`,
`Role`**none of which R-113 altered**; it changed `BoundUnderParent`, which this code does not
read (`internal/agentapi/client.go:303-338`). Consequence, stated rather than discovered: **demo-hp,
still on agent 0.113.0, is not held** and needs no move for this release.
`CrossDriveBackup` gains `LastSuccess` (the offsite anchor's rule and shape, not a second
implementation) plus `SuccessTracked`, which distinguishes "predates the anchor" from "has one and it
is empty". That marker is not optional: **all 7 Tier-2 rows on the fleet** were pre-anchor at deploy,
so without it every customer would have been shown „Még nincs sikeres másolat" at once. Legacy rows
migrate truthfully on first touch — an `ok` row adopts its time, an `error` row seeds nothing.
## Part 1 — R-114: the third state
Three dishonest render sites fixed; the main configured branch and the shares surface were already
honest. The dialog also stops printing raw UTC RFC3339 — new `fmtTimeStr` gives Budapest-local
`2026-07-28 16:40`.
`resolveBackupTargetState` had two outcomes — a disk claims the target (healthy), or nothing does
(degraded, "the backup is on the system disk"). *Configured, and its drive is gone* had no branch, so
it fell into the second and inherited its message **and** its offer. Observed live:
`degraded:true, target:"felhom-backup"` + the system-disk copy (false) + `offer_path` naming the drive
that had just vanished (`audits/E2D-fresh-vm-2026-07-29.md` §5.3).
## Part 2 — the copy-site hazard, in the path of Part 1
- New `BackupTargetState.TargetAbsent`. `Degraded` keeps its meaning — *is there a problem* — so the
wire contract is unchanged for every consumer; `TargetAbsent` answers *which* problem, because the
two have opposite remedies.
- Discriminator `targetIsConfiguredDrive`: a real storage id, versus the builtin `local` or an unset
tier (both = never configured).
- Copy routed through `degradedMessageFor`, so one place still decides what a customer reads.
- **Offer suppressed on the branch itself**, not left to `firstOfferableDrive`'s `Disconnected` skip —
that flag comes from the agent-side gate in another repo, and this state must be right without it.
The three `record*` helpers each built a WHOLE struct literal with two fields re-applied; everything
else was zeroed on every status write. Adding `LastSuccess` to that shape would have had
`recordTier2Failure` **clear** it — the mirror image of the defect, firing on the first failure.
`tier2Update` now copies the existing row and overlays the outcome: **safe by construction**. Sweep:
`SetTier2Preference` mutates in place (safe), `SetCrossDriveConfig(name, nil)` is a deliberate delete.
## Part 2 — R-112: the state gets a consumer
## F-DIAG — six classes, and a leak I caught in my own fix
The endpoint was byte-correct and **nothing asked for it** — templates fetch 18 distinct
`/api/storage/*` endpoints and these were the only two with zero references (§5.1).
`ClassifyOffsiteFailure``quota` / `orphaned` / `no_repo` / `no_units` / `transport` / **`unknown`**,
each with its own Hungarian message; unclassifiable says so rather than being folded into a neighbour.
Server-rendered on `/backups`, following the existing `SingleCopyWarning` banner at `backups.html:11`.
`backupsHandler``backupTargetView` → template. **Not a 19th JS fetch**: the state is already
resolved when the page is built, and a banner needing JavaScript to appear is one more thing that can
silently not happen. `backupTargetView` returns **nil** for healthy and unknown, so a template typo
cannot decorate a working box. The offer control POSTs to the existing assign endpoint behind the
standard inline confirm, never auto-submits, and surfaces `restart_required` honestly.
The old message was a raw `err.Error()` passthrough carrying `sftp:<user>@<host>:<path>` off the box.
My first sanitiser regex-matched `sftp:…` and `user@host` and looked complete — **its own test caught it
leaking** on `ssh: connect to host <host> port 23: Connection refused`, a bare hostname in neither
shape. It now redacts the target's **actual** host/user/path literally, regex kept as a backstop.
**Placement:** the E-2 design doc names no surface — `07-backup-architecture.md` has no banner or
degraded reference, and the only `E-2` string in the capability map is the unrelated `CAMPAIGN-6E-2`.
So the choice is mine: the backups page already hosts a same-severity backup warning and the copy is
about `rendszermentés`.
## Red-proofs — all observed failing
| # | red-proof | observed failure |
## Files
`internal/web/backup_target_offer.go` (third state, view, tiers seam) · `internal/web/server.go`
(`tiersFn`) · `internal/web/handlers.go` (one line wiring the view) ·
`internal/web/templates/backups.html` (banner + offer + assign script) · two new test files ·
`CHANGELOG.md` · `REUSE.md` · `controller/README.md`.
## Tests: 326 → 338 (+12) in `internal/web`. Suite green (27 packages), both template gates pass.
**Scenario E — the seam test, which is the one whose absence let E-2 ship.** It does *not* call the
resolver and assert a string (that proves the resolver, which was never broken). It drives
`backupsHandler` over `httptest` and asserts the **rendered HTML**, travelling
handler → `backupTargetView``resolveBackupTargetState``degradedMessageFor` → the production
`backups` template.
| Red-proof | Mutation | Failure text produced |
|---|---|---|
| A | dialog back on the attempt clock | `the dialog does not name the last SUCCESSFUL copy` |
| C | gate the restore on `LastRun` | `a tier that has NEVER succeeded still offers a restore` |
| D | caution made unconditional | `a HEALTHY tier shows the failed-attempt caution ("nem sikerült")` |
| F | clear the anchor on failure | `a FAILED run wiped the success anchor (round 1)` |
| + | raw sanitiser | `the repo reference reached the message ("sftp:" leaked)` |
| 1 | delete the `targetIsConfiguredDrive` branch | `TargetAbsent is false for target "felhom-backup"…` **and** `offered "/mnt/felhom-drives/mentes2" while the configured target is absent — E-2d observed this exact payload offering the drive that had just vanished` |
| 2 | drop the absent case from `degradedMessageFor` | `the customer is told the backup is on the SYSTEM DISK, which is false…` + all three copy fragments missing |
| 3 | delete `data["BackupTarget"] = …` from the handler | `the absent-drive copy never reached the page…` + every render assertion in the never-configured case |
F runs the **real** `recordTier2Success``recordTier2Failure` sequence; A/C/D **render the production
template tree** and assert the string the customer reads.
All three reverted; files verified byte-identical after.
`go build`, `go vet ./...`, `go test ./...` — 27 packages `rc=0`; template-ID and emoji gates OK.
**Negative assertions:** healthy and unknown are checked for the absence of **all three** copy strings
and of the banner element — not for the absence of an error.
## LIVE on demo-hp — the rendered dialog
**A test caught its own imprecision:** the first Scenario-C assertion matched the bare id
`backup-target-assign`, which also appears inside the page script that always renders, so it reported
a control that was not there. Tightened to the control's own markup.
Failure induced by moving the Tier-2 destination aside and leaving a file in its place, so
`mkdir …/recovery-unit` fails. (`chmod` is useless — the controller is root; `chattr +i` is refused in
an unprivileged container. Both tried, both reported.) Data only moved, never deleted; restored intact.
## NOT LIVE-VALIDATED — awaiting Session C
```
status=error last_run=14:42:18Z (ADVANCED) last_success=14:40:55Z (HELD)
```
**failed state:** `… Legutóbbi sikeres másolat: 2026-07-28 16:40. Figyelem: a legutóbbi mentési
kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.`
**healthy state:** `… Legutóbbi sikeres másolat: 2026-07-28 16:43.` — no caution, no tonal change.
**legacy state (pre-run):** `Legutóbbi másolat: 2026-07-28 03:30` — today's wording, unchanged.
- **Scenario C cannot occur on a healthy box.** The demo guest has a present target, so the
absent-drive banner is unproven live by construction.
- Untested live: the offer control's click → assign → `restart_required` surfacing.
- The live check in this session proves only that the seam is wired and which render a healthy box
produces — that is R-112's whole point, but it is not proof of R-114.
Restored: destination a directory again, 86 MB intact, mode 755, `status=ok`.
**demo-felhom is the untouched control** — all 5 rows still legacy, rendering today's way, 15/15 up.
## Observations — filed, not fixed
## Deployed
`felhom-controller` **0.182.0** on demo-hp and demo-felhom, both `Up (healthy)`.
## NOT yet live-validated (carried forward)
- **F-DIAG's classes** — unit-proven; no live failure of each class was induced.
- **Scenario C live** — never-succeeded is unit-proven only; no fleet row is in that state and
manufacturing one means destroying an app's only Tier-2 history.
- **The Tier-2 restore itself** — this arc changed what the dialog says, not what the restore does.
- R-100's 48h threshold (injected clock), fault 4, R-99, F-HUB, fault 12, the concurrency overlap.
1. **The absent copy now lives in two repos** — here and hub `internal/notify/templates.go:93` — with
nothing binding them but `TestAbsentCopyMatchesTheHubEmailWordForWord`, which only fails if *this*
side drifts. Reword the hub and the test still passes while the banner and the email disagree.
2. **`resolveBackupTargetState` bypassed the existing `disksFn` seam** and called the client directly,
which is why its branching had no test at all before this session. Now routed through `fetchDisks`
with a sibling `tiersFn` — worth checking whether other agent-reading paths do the same.