Files
felhom.eu/documentation/audits/DIAG-bootrecon-drive-absent-2026-08-02.md
admin ad28699761
gates / gates (push) Successful in 7s
docs: R-157 A / R-170 / R-171 closed — boot recovery finished
Controller v0.190.0. Docs only here; no hub change, no hub version bump.

- audits/DIAG-bootrecon-drive-absent-2026-08-02.md — NEW. The Part 0 diagnosis,
  including the run that produced a FALSE NEGATIVE and the mechanism behind it
  (the agent re-binds an unmounted drive within ~60s, so the drive gate's startup
  reconcile restarted the apps one second before the sweep looked). Records that
  the write hazard was blocked only by an ACCIDENTAL filesystem permission that no
  code owns and no test pins.
- architecture/02 §0a — the boot-recovery contract (S-1): both gates read desired
  state; the sweep observes a SETTLED fleet and each sample must refresh first;
  nothing is started without asking, fail-safe. Plus the durable warning:
  Manager.StartStack has no gate of its own.
- 00-capability-map — the boot-recovery row, with the repeat count cited per N.5
  (6 of 6 hard resets) rather than a bare PROVEN-LIVE.
- OPEN-ITEMS / ROADMAP — R-157 CLOSED (both mechanisms), R-170 CLOSED, R-171 NEW
  and closed the same session, marked a regression from v0.189.0.
- STATUS.md — the power-cut line moved from "What's broken" to "What works right
  now" with its repeat count; one dated bullet in the change log.
- CONTEXT.md S-13 — the lessons worth carrying: "it didn't happen this time" is
  not a disproof; widening a window makes previously-unreachable overlaps
  reachable; and a settle detector is only as good as the freshness of what it
  samples — the fix's own defect, found live rather than by review.
2026-08-02 20:38:21 +02:00

7.0 KiB

DIAG — does the R-166 boot sweep start an app whose data drive is absent?

Date: 2026-08-02 · Box: demo felhom-pve guest 9201 · Controller: v0.189.0 Question raised by: reading the v0.189.0 diff, not by an incident. Diagnosed before any fix was written (task §9.2, "diagnose before theorising").

Verdict

CONFIRMED — the sweep starts it. Observed directly, twice in the same run (both attempts).

With a qualification that changes the severity but not the fix: on this box the resulting docker compose up -d failed and nothing was written to the wrong disk. The mechanism that prevented the write is a filesystem-permission accident that no code owns, and the harm that DID occur is a different one: a false dead-app alarm for an app the drive gate is deliberately holding.

The reasoning under test

bootrecon imports stacks alone and has no storage awareness. Since v0.189.0 isBootOrphan returns true for desired_state: running + zero containers. The drive-absent gate stops apps with compose down (leaving exactly zero containers) and never touches desired_state, because it is not the customer. Manager.StartStack has no drive gate. Therefore the sweep should start an app whose drive is absent.

Method

The drive is a real USB disk (/dev/sdb), bound under the stable parent at /mnt/felhom-drives/hdd_1. The gate's absence signal is the agent's BoundUnderParent (planDriveGates, intermediary.go:226), so the drive was made absent by unmounting it, not by editing controller state — the state edit would have proven a different thing.

Run 1 was contaminated and is reported because it produced a mechanism. Unmounting only the parent bind was not enough: the agent re-binds it within ~60 s while /dev/sdb is still mounted at /mnt/hdd_1. In that run the drive gate's startup reconcile re-attached the drive and restarted the apps at 17:18:47, one second before bootrecon looked at 17:18:48 — which then logged no boot-orphaned apps. That is a race that happened to go the safe way, not a disproof. Had it been reported as one, the conclusion would have been wrong.

Run 2 therefore unmounted both /mnt/felhom-drives/hdd_1 and /mnt/hdd_1, and held them unmounted against the agent's healing for the duration.

Preconditions, all verified before the observation

# Precondition Observed
1 drive absent mount | grep -c hdd_10
2 app has zero containers docker ps -a --filter name=calibre0
3 intent still says running desired_state: running
4 app still deployed deployed: true

The gate stopped the apps itself first, on its own signal:

17:22:07 [WARN] [gate] drive ABSENT /mnt/felhom-drives/hdd_1 — stopped+blocked 2 app(s): [calibre-web immich]

immich was set to desired_state: stopped beforehand to scope the blast radius to one app; the gate is per-drive, so it stops every app on that drive regardless.

The observation

17:22:29 [INFO] [stacks]     desired-state backfill: 0 app(s) recorded as running, 0 left unrecorded
17:22:34 [INFO] [bootrecon]  Boot reconciliation: 1 boot-orphaned app(s) found: [calibre-web] — up to 2 attempt(s)
17:22:34 [INFO] [stacks]     Starting stack: calibre-web
17:22:35 [WARN] [bootrecon]  attempt 1/2: start "calibre-web" failed after 0.4s: exit code 1
17:23:05 [WARN] [bootrecon]  attempt 2/2: start "calibre-web" failed after 0.4s: exit code 1
17:23:05 [WARN] [bootrecon]  gave up after 2 attempt(s): recovered=[] still down=[calibre-web] (the dead-app alarm now owns these)

The sweep selected a drive-absent app and called StartStack on it. That is the confirmation.

What stopped the write, and why it must not be relied on

Error response from daemon: error while creating mount source path
  '/mnt/felhom-drives/hdd_1/userdata/media/books':
  mkdir /mnt/felhom-drives/hdd_1/userdata: permission denied

With the drive unbound, /mnt/felhom-drives/hdd_1 is an empty directory on the host's pve-root, and it is host-root-owned:

/mnt/felhom-drives        uid=0 gid=0 mode=755
/mnt/felhom-drives/hdd_1  uid=0 gid=0 mode=755

Guest 9201 is an unprivileged LXC, so its container root is uid 100000 and cannot mkdir there. Confirmed no write occurred: find /mnt/felhom-drives/hdd_1/ returned the directory alone, and df -h / was unchanged at 25G used / 28% before and after.

This protection is accidental. Nothing in the controller chose it, no test pins it, and it rests on two conditions that are not guaranteed and are not checked anywhere:

  1. the guest is unprivileged (a privileged guest maps root→0 and the mkdir succeeds);
  2. the stable-parent mountpoint directory is root-owned. When the drive is bound, that same path shows uid=100000 gid=100000 — i.e. guest-writable. Any code path or agent version that pre-creates the mountpoint with guest ownership removes the protection silently.

It is one chown away from being gone, and its removal would be invisible until data landed on the wrong disk. It is therefore not a reason to leave the sweep unguarded.

The harm that DID occur

Independent of the write question, and real on every box:

  • the sweep burns both attempts and 30 s of retry delay on an app that cannot start by design;
  • it then hands the app to the dead-app alarmstill down=[calibre-web] (the dead-app alarm now owns these) — producing a false alarm about an app the drive gate is deliberately holding, which is exactly the noise class R-97/F-A1 exist to prevent;
  • it leaves a Created container behind on each attempt.

Before v0.189.0 none of this happened: isBootOrphan required len(Containers) > 0, and a gate-stopped app has zero. This is a regression introduced by v0.189.0.

A second asymmetry found while restoring the box

The API start path already refuses this correctly. Restoring immich through the endpoint the UI calls, while the path was still flagged disconnected, returned:

{"ok":false,"error":"A(z) /mnt/felhom-drives/hdd_1 tárhely jelenleg nem elérhető —
 az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik."}

That is startGatedByMissingDrive (internal/api/router.go). So the controller already holds the rule "do not start an app whose drive is missing" — it is enforced on the customer's path and bypassed by the sweep, which calls Manager.StartStack directly. The fix is to give the sweep the same question to ask, not to invent a new rule.

Consequence for the task

Part 3 applies and is implemented first, before the sweep's window is widened — a wider window makes both the false alarm and the (currently accident-blocked) write hazard wider. Fail-safe direction per §8.4: cannot determine drive liveness → do not start. Not starting is recoverable (the gate's Return branch restarts the app when the drive comes back, and the alarm reports it meanwhile); starting on an absent drive is not recoverable by anything automatic.

Register row: R-171, marked as a regression from v0.189.0.