b58d7bcf39
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.
99 lines
7.0 KiB
Markdown
99 lines
7.0 KiB
Markdown
# 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 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.
|