Files
felhom-agent/REPORT.md
T
admin 21b0164fad R-116 (v0.116.0): give the backup-target flag and the gate's key the same row
The absent-drive alarm was generic while its recovery was specific -- a pair an
operator cannot match. Mechanism now measured, not reasoned (felhom.eu
audits/DIAG-r116-disks-payload-2026-07-30.md): with the device gone /disks returns
4 rows, not 3. The drive appears TWICE and the two facts the controller needs are
on different rows -- the Observe row has backup_target:true but mount_path:"" and
guest_path:"" (so driveTargetByPath registers NO key from it), while the registry
row owns /mnt/felhom-drives/<name>, the key the gate looks up, with BackupTarget
absent from its struct literal => false.

WHY v0.115.0 WAS INERT: its fallback computed StablePathForRaw(t.MountPath), and in
the absent state MountPath is ALSO "" -- emptied by the same exactMount failure
that empties BackingDevice. It assigned nothing. Its test passed because the fixture
supplied a MountPath production never supplies, and the harness left DriveTargets
nil so the union loop never ran. Both corrected here; red-proof 1 replays v0.115.0's
exact code against the real shape and it fails.

THE JOIN, which was the hard part: with the device gone the two records share no
runtime field -- no mount, no backing device, and the Observe row's DurableID has
degraded off the fs-UUID. They share CONFIGURATION: storage.cfg's path on one side,
the .mount unit's Where on the other, both yielding the same stable guest path. New
hub.StorageTarget.ConfigPath (json:"-" -- that struct is a cross-repo contract
pinned by the golden + contract_test key-set comparison, and nothing off-box needs
the value), set from s.Path in observe.go, consulted in disks.go only after MountPath
so the present-state path is byte-identical, plus a guest-path arm on the union dedup
so exactly one row carries the drive.

WHY NEITHER OBVIOUS OPTION WAS TAKEN -- both regress R-114, which shipped yesterday.
backup_target_offer.go:79 reads (BackupTarget && MountPath != "") as "a real
drive with its own mountpoint -- healthy" and returns before its TargetAbsent
branch. Back-filling MountPath onto the Observe row (the smallest change, and the
spec's lean) and teaching the registry row the flag (its MountPath is non-empty, read
from the stale unit file) BOTH manufacture that row while the drive is missing, which
would have told the customer the backup target is fine while its drive is gone.
R-114's correctness rests on the absent-state rows not combining the flag with a
mount path; that coupling was invisible until the payload existed. Pinned by
TestAbsentTargetKeepsR114DegradedSignal.

Role unchanged, BoundUnderParent conjunction not widened, no wire field changed.
Suppressing the registry row in the absent state also removes its false
state:"attached" and its root-filesystem-derived total_bytes -- R-118's symptom
goes incidentally; R-118 is NOT fixed and stays open.

Tests 845 -> 849, suite rc=0 read separately from this commit. Four red-proofs, each
mutation asserted to have landed first.

NOT live-validated at this commit: publish+vouch, C5, discrimination, over-correction.
2026-07-30 08:48:31 +02:00

8.0 KiB

REPORT — v0.116.0: R-116, the flag and the key share a row (2026-07-30)

Overwritten per the standing rule. Agent-only fix: the controller was not touched (one fix, one place) and v0.115.0 was not reverted. Mechanism evidence: felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md.

Baselines as actually running (not as on main)

main running a FRESH box gets (hub Day-0 manifest)
agent 0.115.0 → 0.116.0 felhom-pve 0.115.0, demo-hp 0.113.0 0.115.0
controller 0.186.0 felhom-pve 0.186.0, demo-hp 0.185.1 golden bakes 0.185.1
hub 0.81.0 0.81.0 min_agent 0.113.0, min_controller 0.156.0
felhom.eu 1aa1bd1

The fleet is not uniform on either component. The golden baking controller 0.185.1 while demo-felhom runs 0.186.0 is load-bearing for the ruling below: a controller-side fix would not reach a fresh box without a re-bake, whereas the agent channel already serves the newest published version.

The ruling: neither (a) nor (b) — a hybrid, because both regress R-114

primaryTargetID is a PVE storage name (felhom-backup); the registry row's Name is the drive UUID. With the device gone the two records of one drive share no runtime field — no mount, no backing device, and the Observe row's DurableID has degraded off the fs-UUID to path:/mnt/cel.

Rejected (a) — back-fill MountPath on the Observe row. The spec's lean, and it does re-activate v0.115.0. Rejected on source evidence: backup_target_offer.go:79 reads d.BackupTarget && d.MountPath != "" as "a real drive with its own mountpoint — healthy" and returns before its TargetAbsent branch. Option (a) manufactures exactly that row while the drive is missing, so it would have silently flipped R-114 — shipped 2026-07-29, proven live — back to a false healthy banner. Also MountPath's own contract is "the RAW host PVE mount" (disks.go:152-153); a path that is not mounted is not that, so the downstream reading is correct and the change would be the bug.

Rejected (b) — carry BackupTarget on the registry row. A join does exist (below), so this was available — but the registry row's MountPath is non-empty, read from the by-then-stale .mount unit file. It trips :79 identically. Same regression, same reason.

Rejected (c) — resolve controller-side. The mapping is not there to find, and the one state, one owner ruling stands: the agent owns drive identity.

Taken (d) — give the Observe row the guest path (and NO MountPath), then dedup the registry row on guest path. The gate keys on GuestPath; R-114 keys on MountPath. Separating them satisfies both: the surviving row carries the flag and the guest path with mount_path: "", so isTarget[guestPath] is true and :79 does not match, leaving R-114's TargetAbsent branch reachable.

The join: with the device gone the shared identity is configuration, not state — the storage's path from storage.cfg (Observe side) and the .mount unit's Where (registry side) are the same path, so both derive the same stable guest path. That is the key both sides can still compute.

What (d)'s dedup removes in the absent state, enumerated: the registry row's uuid: durable_id, its hardcoded and false state: "attached", its role: user-data, and its total_bytes/used_bytes copied from the root filesystem. No consumer found — wipe/decommission require the drive present; firstOfferableDrive (:138) already requires MountPath != "" and Role == "user-data"; diskKey (disk_health.go:130) changes key transiently but the health check excludes UNKNOWN verdicts, so no false degradation. R-118's symptom disappears incidentally — R-118 is NOT fixed and stays open.

Deliberately unchanged: Role (the absent target still reads system), the BoundUnderParent conjunction, and every wire field.

Files

internal/hub/report.go (ConfigPath, json:"-" — that struct is a cross-repo contract pinned by testdata/host-report.golden.json + contract_test.go's key-set comparison, so a wire-visible field would have needed a matching felhom.eu/hub change; nothing off-box needs the value) · internal/storage/observe.go (set from s.Path) · internal/localapi/disks.go (ConfigPath fallback + guest-path dedup) · internal/localapi/disks_backup_target_row_test.go (fixture corrected + 4 tests) · CHANGELOG.md.

observe.go's change is not the fallthrough its :176-183 comment forbids: that prohibition is about resolving a device or UUID from the containing filesystem, which would hand back root's identity and mis-target a DR re-attach. s.Path is the storage's own declaration, identifies nothing but itself, and is used for no resolution. MountPath stays empty, which is the truth.

§2 reconfirmation

Cited Line now Holds
mountPath assigned only on exactMount success observe.go:184-190 HOLDS verbatim
empty backingDeviceRoleSystem role.go:180-181 HOLDS verbatim
guest-path guard skipped for non-user-data disks.go:215-217 HOLDS
Known() reads unit FILES, never the mount table registry_known.go:40-75, MountPath: spec.Where at :70 HOLDS
driveTargetByPath keys only from non-empty fields intermediary.go:602-618 (:611, :614) HOLDS
the dedup keys on d.MountPath at :263 :263 is if t.BackingDevice != "". Actual: seen built :290-295, dedup :298 HOLDS; the line number in the spec is wrong
"seen[""] is set, so the union row is not skipped" DOES NOT HOLD as statedseen[d.MountPath] = true is guarded by if d.MountPath != "", so seen never gains "". The union row survives because /mnt/<name> is absent from seen, not because "" is present. Same outcome, different reason corrected

Tests — 845 → 849, suite green (go build ./... && go vet ./... && go test ./... rc=0)

All PASS: TestAbsentBackupTargetIsResolvableByGuestPath (rewritten — real shape and the union loop), TestAbsentTargetAppearsExactlyOnce (new, the join), TestAbsentTargetKeepsR114DegradedSignal (new, the regression guard), TestPresentTargetPayloadUnchanged (new, fleet-wide parity), TestAbsentNonTargetKeepsItsRegistryRowAndNoFlag (new, negative + over-suppression), TestAbsentTargetRowDoesNotRegisterPresence, TestCaseBLocalTargetGetsNoGuestPath, TestSystemBackedTargetUnderMntGetsNoGuestPath, TestNonTargetDriveNeverCarriesTheFlag.

Red-proofs — each mutation asserted to have landed before the run:

  1. v0.115.0's MountPath-only fallback → isTarget[…] = FALSE … rows=2. The empirical proof v0.115.0 was inert, rather than an argument for it.
  2. Drop the guest-path dedup → the absent drive is carried by 2 rows, want exactly 1.
  3. Give the absent row a MountPath (what option (a) produces) → the R-114 guard fires.
  4. Over-broad dedup (gp != "" without seenGuest) → the non-target drive loses its own row, and two pre-existing R-113 union tests fail as well.

The corrected fixture is the headline test change. theAbsentTarget supplied MountPath: "/mnt/mentes", which production never supplies, and targetRowServer left DriveTargets nil, so the union loop never ran and the two-row shape was invisible to the entire suite. Both fixed — that combination is why an inert fix shipped green.

Process note

One red-proof was restored with git checkout <file>, which discarded the whole implementation rather than the one-line mutation. Re-applied and re-verified; later red-proofs restored from pristine copies kept outside the repo. git checkout is not an undo for a mutation on a work-in-progress file.

NOT yet live-validated at the time of this commit

Publish + vouch, C5 pairing, discrimination, and the over-correction guard — the session's live half. Outcome recorded in the audit under felhom.eu/documentation/audits/.