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.
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_1 → 0 |
| 2 | app has zero containers | docker ps -a --filter name=calibre → 0 |
| 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:
- the guest is unprivileged (a privileged guest maps root→0 and the
mkdirsucceeds); - 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 alarm —
still 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
Createdcontainer 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.