v0.114.0 — R-113: drive presence means the DEVICE, not the bind

BoundUnderParent, the one field the controller's drive-absent gate keys on,
reported only "is this path a mount target in the guest's mountinfo". The
drive's raw mount at /mnt/<name> is a systemd mount unit bound to its device and
dies with it, but the agent's own bind of <raw>/felhom-data under the shared
parent is an ordinary bind: nothing ties it to the device, so its mountinfo
entry OUTLIVES the device as a stale shell. Presence read that survivor as true,
planDriveGates never produced a Stop action, and nothing fired on any channel --
not backup_target_absent, not the generic storage_disconnected. Measured live in
E-2d: detached at 10:58:37Z, silent for 4.5 minutes while the agent itself
logged "enrolled drive absent by UUID" every 20s (felhom.eu
audits/E2D-fresh-vm-2026-07-29.md §5.2).

The fix: BoundUnderParent becomes a CONJUNCTION -- bound under the parent AND
the drive's raw host mount still mounted (devicePresent, new deviceCheck seam
defaulting to isHostMountpoint). Applied at BOTH /disks construction sites. The
union path matters more, not less: it hardcodes State:"attached", so the
raw-mount check is the only device truth that row carries, and it is exactly the
shape E-2d detached.

Why a conjunction and not a replacement: half 2 alone would regress boot
ordering, where the raw drive mounts early and the bind lands ~18s later; the
gate depends on that window reading ABSENT. The conjunction leaves that
byte-identical and closes only the case the gate could never see.

Unknown is never absent: devicePresent("") returns TRUE. A false absent stops a
working customer's apps -- the failure mode of this fix, not of the bug.

Controller UNCHANGED, no MinAgent bump. BoundUnderParent has exactly one
functional consumer (planDriveGates, intermediary.go:226); every other mention
in both repos is a comment or a test, and boot convergence deliberately moved
off it to pollLiveBinds/driveBindLive. The alternative -- a new DevicePresent
bool the controller ANDs in -- was rejected as dangerous: a bool absent from an
older agent's JSON decodes to false, so every drive on a pre-0.114.0 agent would
have read ABSENT and stopped its apps.

Tests +6 in internal/localapi (208 -> 214): groups A-D plus a wire-contract test
asserting the ENCODED bound_under_parent, since that is what crosses to the
controller. Four red-proofs run and reverted (drop the conjunction on each path;
invert unknown; drop the bind half); disks.go verified byte-identical after.

NOT LIVE-VALIDATED. No drive was pulled. Leg awaiting Session C: device loss ->
gate Stop -> SetDisconnected -> backup_target_absent on the wire.
This commit is contained in:
2026-07-29 17:20:16 +02:00
parent 58b598b697
commit b58d7bcf39
7 changed files with 364 additions and 213 deletions
+43
View File
@@ -1,3 +1,46 @@
## v0.114.0 — R-113: drive presence means the DEVICE, not the bind (2026-07-29)
**The bug, measured live in E-2d.** `BoundUnderParent` — the one field the controller's drive-absent
gate keys on — reported only *"is this path a mount target in the guest's mountinfo"*. The drive's raw
mount at `/mnt/<name>` is a systemd mount unit bound to its device and dies with it, but the agent's own
bind of `<raw>/felhom-data` under the shared parent is an ordinary bind: nothing ties it to the device,
so **its mountinfo entry outlives the device as a stale shell**. Presence read that survivor as `true`,
`planDriveGates` never produced a `Stop` action, and **nothing fired on any channel** — not
`backup_target_absent`, not the generic `storage_disconnected`. Detached at 10:58:37Z, silent for 4½
minutes while the agent itself logged `enrolled drive absent by UUID` every 20 s
(`felhom.eu` `audits/E2D-fresh-vm-2026-07-29.md` §5.2).
**The fix.** `BoundUnderParent` becomes a **conjunction**: bound under the parent **AND** the drive's raw
host mount still mounted (`devicePresent`, new `deviceCheck` seam defaulting to `isHostMountpoint`).
Applied at BOTH `/disks` construction sites — the Observe path and the registry union path. The union
path matters more, not less: it hardcodes `State:"attached"`, so the raw-mount check is the only device
truth that row carries, and it is exactly the shape E-2d detached.
**Why a conjunction and not a replacement.** Half 2 alone would regress boot ordering, where the raw
drive mounts early and the bind lands ~18 s later; the gate depends on that window reading ABSENT so apps
stay stopped until the bind is live. The conjunction leaves that behaviour byte-identical and closes only
the case the gate could never see. Pinned by `TestDisks_DevicePresence_BootWindowStillReadsAbsent`, whose
red-proof is dropping the bind half.
**Unknown is never absent.** `devicePresent("")` returns **true**. A false absent stops a working
customer's apps — the failure mode of this fix, not of the bug — so an unanswerable question must never
be reported as "gone". Red-proofed by inverting it.
**Controller UNCHANGED, no MinAgent bump.** `BoundUnderParent` has exactly one functional consumer,
`planDriveGates` (`intermediary.go:226`); every other mention across both repos is a comment or a test,
and boot convergence deliberately moved off it to `pollLiveBinds`/`driveBindLive`. Tightening the field's
meaning therefore changes exactly one decision — the broken one. The alternative (a new `DevicePresent`
bool the controller ANDs in) was rejected as actively dangerous: a bool absent from an older agent's JSON
decodes to `false`, so every drive on a pre-0.114.0 agent would have read ABSENT and stopped its apps.
An older controller against this agent simply gets a more truthful bool.
**Tests:** +6 in `internal/localapi` (208 → 214), groups AD plus a wire-contract test that asserts the
encoded `bound_under_parent` field, since that is what actually crosses to the controller. Four
red-proofs run and reverted (drop the conjunction on each path; invert unknown; drop the bind half).
**NOT LIVE-VALIDATED.** No drive was pulled. The leg awaiting Session C: device loss → gate `Stop`
`SetDisconnected``backup_target_absent` on the wire.
## v0.113.0 — E-2a: the guarded backup-target wrapper + POST /backup/target (2026-07-29)
**The agent cannot do this itself, and that is the point.** Creating a PVE storage needs