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

99 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `NewServer``GET /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 `Stop``SetDisconnected``backup_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.