# 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/`) 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 A–D + 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.