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.
6.2 KiB
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.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
felhom-controller cdaeb369726b v0.185.1 · felhom-agent b58d7bcf39a9 v0.114.0 (untouched) ·
felhom.eu 338b2ccf86. All clean and pushed at start.
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.
Part 1 — 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"). 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).
- New
BackupTargetState.TargetAbsent.Degradedkeeps its meaning — is there a problem — so the wire contract is unchanged for every consumer;TargetAbsentanswers which problem, because the two have opposite remedies. - Discriminator
targetIsConfiguredDrive: a real storage id, versus the builtinlocalor 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'sDisconnectedskip — that flag comes from the agent-side gate in another repo, and this state must be right without it.
Part 2 — R-112: the state gets a consumer
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).
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.
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.
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 |
|---|---|---|
| 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 |
All three reverted; files verified byte-identical after.
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.
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.
NOT LIVE-VALIDATED — awaiting Session C
- 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_requiredsurfacing. - 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.
Observations — filed, not fixed
- The absent copy now lives in two repos — here and hub
internal/notify/templates.go:93— with nothing binding them butTestAbsentCopyMatchesTheHubEmailWordForWord, which only fails if this side drifts. Reword the hub and the test still passes while the banner and the email disagree. resolveBackupTargetStatebypassed the existingdisksFnseam and called the client directly, which is why its branching had no test at all before this session. Now routed throughfetchDiskswith a siblingtiersFn— worth checking whether other agent-reading paths do the same.