Files
felhom-agent/REPORT.md
T
admin b58d7bcf39 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.
2026-07-29 17:20:16 +02:00

7.0 KiB
Raw Blame History

REPORT — R-113: drive presence means the DEVICE, not the bind (agent v0.114.0, 2026-07-29)

Overwritten per the standing rule. R-113, Session A of three (R-113 → R-114+R-112 → one drill-VM validating all three). Repos touched: felhom-agent only; felhom-controller deliberately UNCHANGED — see the ruling. Status: code + tests complete, NOT LIVE-VALIDATED. No drive was pulled; that is Session C.

Phase 0 ruling — shape (a), the agent tells the truth

BoundUnderParent becomes bound && devicePresent. Controller unchanged, no MinAgent bump.

Q Answer
1. the symbol ResolveStorageDevice (internal/storage/durable_device.go:82, EvalSymlinks of /dev/disk/by-uuid/<uuid>) behind hostops.go:298; mountByDurable (localapi/disks.go:960-969, raw mounts only — binds skipped at :962) behind disks.go:986
2. available at /disks? Yes at both sites. Observe path has t.MountPath/t.State; union path has d.MountPath/d.UUID and already resolves that UUID eight lines later
3. cost os.Stat / EvalSymlinks — at most two syscalls per drive per poll. No exec, no shell-out
4. State == "attached" Split verdict. Observe path: truthful (reachable(), storage/observe.go:321-330 = exactMount && DeviceExists). Union path: hardcoded "attached" (disks.go:257) — so planDriveGates:229's legacy raw branch carries the same defect. Filed as an observation, not fixed

Why (a) and not (b): BoundUnderParent has exactly one functional consumer, planDriveGates (intermediary.go:226); everything else across both repos is a comment or a test, and boot convergence deliberately moved off it (intermediary.go:362). Tightening it changes one decision — the broken one. Shape (b) is actively dangerous: a new bool absent from a pre-0.114.0 agent's JSON decodes to false, so ANDing it in would make every drive on an older agent read ABSENT and stop its apps — the exact Scenario D failure the spec forbids. Defending it needs *bool + feature detection: more machinery, worse failure mode.

The signal chosen, and why it is the right one

Not a UUID probe — the raw mount. E-2d measured the asymmetry directly: with the device pulled, /mnt/mentes2 was NOT mounted while /mnt/felhom-drives/mentes2 still read /dev/sdb[/felhom-data]. The raw mount is a device-bound systemd unit; the agent's bind is not. So "is the raw mount still mounted" is device presence, it is the same check on both construction paths, and it rests on a live measurement rather than inference.

Files modified

File Change
internal/localapi/server.go new deviceCheck func(string) bool seam (mirrors boundCheck)
internal/localapi/disks.go new devicePresent(rawMountPath); conjunction at BOTH construction sites (:210 Observe, :263 union); BoundUnderParent's doc restated to name both halves
internal/localapi/intermediary_test.go existing fixture pinned deviceCheck=true — its subject is the bind half; unchanged otherwise
internal/localapi/disks_device_presence_test.go new — 6 tests, groups AD + the wire contract
REUSE.md devicePresent/deviceCheck registered, with the "use this, never the bind" rule

Tests: 208 → 214 in internal/localapi (+6). Full agent suite green.

Group Test Pins
A ..._ObservePath_DeviceLossReadsAbsent the E-2d shape: bind survives, device gone ⇒ absent
A ..._UnionPath_DeviceLossReadsAbsent same on the registry path — the shape E-2d actually detached
B ..._HealthyReadsPresent (observe+union) a healthy bound drive reads PRESENT — no false absent
C ..._BootWindowStillReadsAbsent raw mounted early, bind not yet ⇒ still absent (no regression)
D ..._UnknownIsNotAbsent devicePresent("") is TRUE — unknown never means gone
wire ..._WireFieldIsFalseOnDeviceLoss the ENCODED bound_under_parent, which is what crosses

Seam wiring: every test drives NewServerGET /disks through srv.Handler() → the real JSON. Only the two lowest-level mount reads are injected (a unit test cannot create real mounts); nothing above them is faked. The wire test asserts the encoded field rather than the struct, so a regression that dropped the field from the wire would fail it.

Consumer contract already pinned: TestPlanDriveGates (controller) asserts BoundUnderParent:false ⇒ exactly one Stop, a healthy drive ⇒ zero actions, with an exact action count. Verified green on the untouched controller tree, so no redundant case was added.

Red-proofs — all four went red with the expected text, all reverted

# Mutation Failure text produced
1 drop && devicePresent from the Observe path BoundUnderParent reports present — the bind outlived the device (R-113). The controller's gate would emit no Stop action, so no alarm can fire.
2 drop it from the union path union-path drive reports present — the bind outlived the device (R-113) and wire bound_under_parent = true (want false)
3 make unknown mean absent (return false) devicePresent("") = false — an unanswerable question was reported as ABSENT
4 drop the bind half (device only) boot window reports present before the bind landed — this regresses the reboot convergence…

After the last revert disks.go was verified byte-identical to the pre-mutation file (diff -q).

NOT LIVE-VALIDATED — awaiting Session C

The leg not exercised: device loss → gate StopSetDisconnectedbackup_target_absent on the wire. Deployment proves the service starts, nothing more.

⚠️ Session C blocker to settle first: a throwaway box installs the agent from the hub's Day-0 artifact manifest, which currently vouches 0.113.0. Unless 0.114.0 is published (scripts/publish-agent.sh) and vouched, Session C's fresh box runs the agent without this fix and validates nothing for R-113. This is R-111's trap in the same shape.

Observations — filed, not fixed

  1. The legacy raw branch carries the same defect. disks.go:257 hardcodes State:"attached" on the union path, and planDriveGates:229 uses d.State == "attached" as presence for legacy raw paths — so a registry-only drive registered at its raw path can never read absent either. Phase 0 Q4; out of scope per the spec.
  2. intermediary.go:220-224 is now incomplete, not wrong. Its claim (presence is the bind, not merely State==attached) still holds; it no longer says the bind now also implies the device. That clause belongs there — add it in Session B when the controller is legitimately open, rather than touching a repo Phase 0 ruled out.
  3. R-114's second half is expected to resolve as a side effect. SetDisconnected is called only from the Stop branch, and firstOfferableDrive skips sp.Disconnected — so once the gate fires, the vanished drive should stop being offered. Not acted on, not verified; Session B/C.