Files
felhom.eu/documentation/audits/DIAG-r116-disks-payload-2026-07-30.md
T
admin 699790b12d docs: write down which boxes are disposable (target selection by blast radius)
Nothing in the repo said which machines are safe to break. The host table gave
access and role and stopped there, so a session needing a victim had to guess --
and the guessing inverted: the two boxes that exist to be broken were treated as
sacred, and DooPlex (the recovery chain) got used because it was the only box no
spec had fenced.

New documentation/runbooks/target-selection.md -- one page, three tiers, and per
machine what is freely permitted / needs care / forbidden, each carrying its
REASON so a rule can be correctly narrowed later instead of ossifying. States the
selection rule positively (start at Tier 0; a Tier 2 box only when a task says so
explicitly; an absent fence is not permission) and that fences name ACTS, not
machines -- demo-hp's over-subscribed local-lvm is one dangerous storage, not a
dangerous box.

CLAUDE.md: host table gains a Blast radius column, gains the missing demo-hp row
(it was where the drill VMs ran and it was not in the table at all), and a pointer
line to the new runbook.

CORRECTION to the spec's problem statement: the designation was not missing. The
2026-07-25 operator ruling naming the t740 as drill+build VM host -- explicitly
"moved off DooPlex" -- already existed in operations/nodes.md. It sat where no
session reads at start, while the prohibitions were repeated in every task spec.
The defect is reachability of the ruling, not its absence, and the R-116 drill on
DooPlex contradicted a written ruling rather than filling a vacuum.

CORRECTION to the R-116 record, same commit: the baseline claimed controller
0.186.0 on both demo boxes. Only felhom-pve was sampled and generalised; demo-hp
re-checked directly runs 0.185.1, so the fleet is split and R-114's TargetAbsent
branch is absent from demo-hp. Fixed in the audit table and REPORT-r116-diag.

Docs only -- no code, no build, no deploy, no host reconfigured, no host renamed.
2026-07-30 08:13:58 +02:00

33 KiB
Raw Blame History

DIAG-r116-disks-payload-2026-07-30 — the /disks payload, and the mechanism isolated from it

Run: read-only diagnosis by CC on DooPlex, 2026-07-30. No code written, nothing shipped. Result: the payload was captured in both states and the mechanism is isolated. It is theory #1the registry-union row writes false — the theory that was raised, declared wrong, and retracted. The retraction was the error.


1. Baselines

All confirmed this run, not carried from the brief.

Artifact Version How confirmed
agent 0.115.0 felhom-agent --version on felhom-pve; felhom-agent/CHANGELOG.md:1 = ## v0.115.0; repo HEAD a58239f6ded367fe27b4eb57cf2fcb0c54414b80
controller 0.186.0 on demo-felhom, 0.185.1 on demo-hp pct exec 9201 -- docker ps: felhom-pve → felhom-controller:0.186.0, demo-hp → felhom-controller:0.185.1 (both re-verified 2026-07-30); CHANGELOG.md:3 = ### v0.186.0; HEAD b331f18424b7cf8d0883e7709f74595808264080
hub 0.81.0 manifests/hub.yaml:128 pin and the live pod image (kubectl -n felhom-system get pod hub-… -o jsonpath=…) both felhom-hub:0.81.0; hub/CHANGELOG.md:1 = ## v0.81.0
host-install 1.22.0 scripts/felhom-host-install.sh:187 SCRIPT_VERSION="1.22.0"
felhom.eu HEAD c3ce4c7b20ce4c0d3673590d710d13e5d28b68bb git rev-parse HEAD

The controller version is not one number. 0.185.1 is what the golden bakes (so it is what a fresh drill box installs, which is why the brief and Part 5 both recorded it); 0.186.0 shipped 2026-07-29 as R-114 + R-112 and is what demo-felhom runs — but demo-hp is still on 0.185.1, so the fleet is split. The distinction matters because R-114's TargetAbsent branch exists only in 0.186.0, i.e. demo-hp does not have it.

Correction, 2026-07-30. As first written this row said 0.186.0 was what both demo boxes run. That was wrong: only felhom-pve's guest was sampled, and the claim was generalised to the fleet from one box. demo-hp re-checked directly → felhom-controller:0.185.1. Corrected in the same commit that added runbooks/target-selection.md.

Hub host table, read this run: demo-felhom-8363b5 agent 0.115.0 ONLINE · demo-hp-bb76ea agent 0.113.0 ONLINE · drill-r50-0a4f9a DOWN · sess-c-8158e4 DOWN · sess-d-0452c4 STALE.


2. §3 reconfirmation — every cited fact, at file:line

Cited fact Line now Holds?
observe.gomountPath comes from the mount table, assigned only on exact-mount success internal/storage/observe.go:184-190 HOLDS, verbatim as quoted
role.goif backingDevice == "" { return RoleSystem } for USB/LocalDir internal/storage/role.go:180-181 HOLDS, verbatim
primaryTargetID := s.primaryTier().TargetID, in scope for both loops internal/localapi/disks.go:194 HOLDS
BackupTarget: t.Name == primaryTargetID disks.go:211 HOLDS
GuestPath set only inside if di.Role == RoleUserData disks.go:215-217 HOLDS
first loop out = append(out, di) disks.go:284 (brief said :249) HOLDS — line moved, v0.115.0 inserted :226-260
seen[d.MountPath] = true built by iterating out disks.go:290-295 (brief said :258) HOLDS — line moved. Note the guard: seen is written only when d.MountPath != "" (:292)
union dedup if d.MountPath == "" || seen[d.MountPath] { continue } disks.go:298 (brief said :263) HOLDS — line moved
union row hardcodes Role: RoleUserData disks.go:304 HOLDS
union row's GuestPath set from StablePathForRaw(d.MountPath) disks.go:309-310 HOLDS
BackupTarget is absent from the union struct literal disks.go:301-306 HOLDS — the literal sets Name/Type/State/MountPath/DurableID/Role/GuestAttached and nothing else
v0.115.0's fallback di.GuestPath = StablePathForRaw(t.MountPath) disks.go:258-260 HOLDS
StablePathForRaw("") == "" (so the fallback sets nothing when MountPath is empty) internal/localapi/intermediary.go:69-75DriveNameFromRaw:79-88 (no /mnt/ prefix → "") HOLDS
PVE reports the detached target dir inactive 0 0 0 reproduced live, §5 HOLDS

One fact the brief did not state, and it is load-bearing. /disks' union source is the systemd-unit-file registry, not PVE and not the mount table: storage.NewRegistryKnownTargets(driveUnitDir, …) (cmd/felhom-agent/main.go:605) is the value passed as DriveTargets (main.go:764buildLocalAPIServerdisks.go:296 s.driveTargets.Known()). Known() (internal/storage/registry_known.go:40-75) os.ReadDirs /etc/systemd/system, parses each Felhom .mount unit, and returns MountPath: spec.Where (:70) — it never consults the mount table or the device. So the union row's MountPath survives the device's disappearance by construction.

Verified on the live box (felhom-pve), unit present and device-bound:

/etc/systemd/system/mnt-hdd_1.mount
  What=/dev/disk/by-uuid/47a3361a-91e0-4831-a69d-27f540ed3f48
  Where=/mnt/hdd_1
/var/lib/felhom-agent/drive-intents.json  → intent recorded (not `new`, so Known() includes it)

3. The working /disks invocation — verbatim

Two sessions were lost to this. The token's plaintext exists in exactly one place: the guest's bootstrap mount, which the agent owns host-side. The agent's own token store holds only SHA-256 hashes (internal/localapi/tokenstore.go:26-32, :136), so it is not a source — that is what the earlier extraction attempts were fighting.

Authoritative source, on the Proxmox host, as root:

/var/lib/felhom-agent/guests/<VMID>/bootstrap/bootstrap.json   # 0600, owner 100000:100000

(written by internal/provision/backhalf.go:124-130; the in-guest view is /etc/felhom-bootstrap/bootstrap.json, mp9, read-only — the host path avoids the guest hop entirely).

The invocation. Run it ON the Proxmox host so the token never crosses a hop or reaches a log:

ssh felhom-pve
export LC_ALL=C
B=/var/lib/felhom-agent/guests/9201/bootstrap/bootstrap.json
TOK=$(python3 -c "import json;print(json.load(open('$B'))['local_api']['token'])")
EP=$(python3 -c "import json;print(json.load(open('$B'))['local_api']['endpoint'])")   # 169.254.253.1:8443
curl -sS -k -H "Authorization: Bearer $TOK" "https://$EP/disks" | python3 -m json.tool

-k is correct and not a shortcut: the leaf is self-signed and the controller pins it by SHA-256 (local_api.fingerprint in the same file, 2d325e89…c191 on felhom-pve) rather than by a CA. The endpoint is the island bridge vmbr9 (169.254.253.1/30), reachable from the host itself.

3.1 The control run — it proves the query works

Present-drive control, live felhom-pve, before anything else:

endpoint=169.254.253.1:8443
HTTP 200  bytes=2483
rows: 4   (local-lvm, felhom-pbs, felhom-backup, local)

The backup-target row in that control run:

{ "name": "felhom-backup", "type": "local-dir", "state": "attached",
  "backing_device": "/dev/sdb", "mount_path": "/mnt/hdd_1", "role": "user-data",
  "backup_target": true, "guest_path": "/mnt/felhom-drives/hdd_1",
  "bound_under_parent": true, "durable_id": "uuid:47a3361a-91e0-4831-a69d-27f540ed3f48" }

Non-empty, plausible, four rows. Part 5's failure mode (0 rows on a present drive) is excluded.


4. Where the absent state was staged, and why there

The demo boxes' targets are fenced, and drill-r50 is the drift fixture. No new box was built. The absent state was staged on the existing nested-Proxmox drill VM on DooPlex (/mnt/5_hdd/felhom.eu/drill/drill.qcow2, PVE 9.2, snapshot virgin, the golden-bake fixture) — my own host, zero production exposure, and it can hot-unplug a disk for a genuine device loss rather than a simulated one.

Fidelity — what was real:

  • the byte-identical live agent binary: scp from felhom-pve /usr/local/bin/felhom-agent, sha256 f48544ad5c4242a719c080ff83d7a6bc759e14272987416eb26c9683386803b7, --version 0.115.0.
  • the real configs/felhom-backup-target-apply (E-2a) and felhom-mkfs-guarded wrappers.
  • a real LXC guest (9201), in the felhom pool, with the mp8 parent bind created exactly as provision.BackHalf does it (backhalf.go:200-206: pct set 9201 -mp8 /mnt/felhom-drives,mp=/mnt/felhom-drives).
  • every state-producing step through the real endpoints, not by hand: POST /disks/formatPOST /disks/assignPOST /disks/guest-attachPOST /backup/target. That produced the .mount unit, drive-intents.json: enrolled, the guest bind, the dir: felhom-backup / path /mnt/cel / is_mountpoint 1 storage and the local_backup_target rewrite.
  • device loss by QMP device_del on the virtio-blk frontend — the device node vanishes.

What was NOT production-shaped, stated plainly: (a) the agent ran as root with privileged.mode: "direct" and a root@pam PVE token instead of non-root + sudoers + felhom-agent@pve — this changes who runs the privileged ops, not what /disks computes; (b) hub.url pointed at a dead local port, so nothing was reported to the hub; (c) the local-API bearer token record was hand-written into local-tokens.log (one {"v":9201,"h":"<sha256>"} line) because minting is only reachable through --selftest=provision, which needs a golden and a real customer. That is the key to the door, not the state under measurement — every drive/storage/target fact was produced by the real endpoints. (d) No controller ran, so §7 correlates against the two prior live runs rather than a fresh one.

The rig validated itself against production before being trusted: its present-state target row is field-for-field identical in shape to felhom-pve's control run (role: user-data, backup_target: true, guest_path set, bound_under_parent: true, durable_id: uuid:…) — only the device node and mountpoint names differ.


5. Host state at each capture

PRESENT (05:53:49Z) ABSENT (05:54:32Z) RETURNED (05:55:45Z)
device node /dev/vdb gone (ls /dev/vdb → No such file) /dev/vdc
findmnt /mnt/cel /dev/vdb ext4 not mounted (rc=1) /dev/vdc ext4
guest findmnt /mnt/felhom-drives/cel /dev/vdb[/felhom-data] /dev/vdb[/felhom-data] …,shutdown /dev/vdb[/felhom-data] …,shutdown
pvesm status felhom-backup dir active felhom-backup dir inactive 0 0 0 felhom-backup dir active

The detached state reproduced PVE's exact Part-5 string:

unable to activate storage 'felhom-backup' - directory is expected to be a mount point
but is not mounted: '/mnt/cel'

and the agent's exact Session-C / E-2d lines:

07:54:08 WARN  storage: watchdog detected target state change  from=attached to=disconnected
07:54:09 INFO  storage: enrolled drive absent by UUID — not re-asserting (will retry when it enumerates)
07:54:09 WARN  reconcile: enrolled drive not present (durable-id absent) — skipping

The R-113 asymmetry is present as measured before: raw /mnt/cel NOT mounted while the guest's bind still names /dev/vdb[/felhom-data].


6. The raw payloads

6.1 PRESENT — GET /disks, HTTP 200, 1824 bytes, 3 rows

{"ok":true,"data":{"disks":[
{"name":"felhom-backup","type":"local-dir","state":"attached","backing_device":"/dev/vdb","mount_path":"/mnt/cel","class":"slow","role":"user-data","data_bearing":true,"data_reason":"device is mounted","total_bytes":4143677440,"used_bytes":1081344,"used_fraction":0.000260962397690878,"durable_id":"uuid:694034cc-eee6-4b17-901f-708f50df9b09","wipe_durable_id":"byid:virtio-R116CEL","guest_attached":false,"backup_target":true,"guest_path":"/mnt/felhom-drives/cel","bound_under_parent":true,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}},
{"name":"local-lvm","type":"lvmthin","state":"attached","backing_device":"","mount_path":"","class":"","role":"system","data_bearing":false,"total_bytes":81533075456,"used_bytes":807177447,"used_fraction":0.00989999999982339,"durable_id":"pve/data","guest_attached":false,"bound_under_parent":false,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}},
{"name":"local","type":"local","state":"attached","backing_device":"","mount_path":"","class":"","role":"system","data_bearing":false,"total_bytes":49675956224,"used_bytes":4584443904,"used_fraction":0.092286978499774,"durable_id":"path:/var/lib/vz","guest_attached":false,"bound_under_parent":false,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}}
],"guest_boot_id":"1785390256-46753","vmid":9201}}

6.2 ABSENT — GET /disks, HTTP 200, 2040 bytes, 4 rows (one MORE than present)

{"ok":true,"data":{"disks":[
{"name":"felhom-backup","type":"local-dir","state":"disconnected","backing_device":"","mount_path":"","class":"","role":"system","data_bearing":false,"total_bytes":0,"used_bytes":0,"used_fraction":0,"durable_id":"path:/mnt/cel","guest_attached":false,"backup_target":true,"bound_under_parent":false,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}},
{"name":"local-lvm","type":"lvmthin","state":"attached","backing_device":"","mount_path":"","class":"","role":"system","data_bearing":false,"total_bytes":81533075456,"used_bytes":807177447,"used_fraction":0.00989999999982339,"durable_id":"pve/data","guest_attached":false,"bound_under_parent":false,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}},
{"name":"local","type":"local","state":"attached","backing_device":"","mount_path":"","class":"","role":"system","data_bearing":false,"total_bytes":49675956224,"used_bytes":4584579072,"used_fraction":0.0922896994942001,"durable_id":"path:/var/lib/vz","guest_attached":false,"bound_under_parent":false,"smart":{"health":"UNKNOWN","temperature_c":null,"power_on_hours":null,"reallocated_sectors":null,"pending_sectors":null,"offline_uncorrectable":null,"critical_warning":null,"media_errors":null,"percentage_used":null}},
{"name":"694034cc-eee6-4b17-901f-708f50df9b09","type":"usb","state":"attached","backing_device":"","mount_path":"/mnt/cel","class":"","role":"user-data","data_bearing":false,"total_bytes":49675956224,"used_bytes":4584579072,"used_fraction":0.09228969949420011,"durable_id":"uuid:694034cc-eee6-4b17-901f-708f50df9b09","guest_attached":false,"guest_path":"/mnt/felhom-drives/cel","bound_under_parent":false}
],"guest_boot_id":"1785390256-46753","vmid":9201}}

6.3 RETURNED — GET /disks, HTTP 200, 1825 bytes, 3 rows (back to one row for the drive)

{"ok":true,"data":{"disks":[
{"name":"felhom-backup","type":"local-dir","state":"attached","backing_device":"/dev/vdc","mount_path":"/mnt/cel","class":"slow","role":"user-data","data_bearing":true,"data_reason":"device is mounted","total_bytes":4143677440,"used_bytes":1081344,"used_fraction":0.000260962397690878,"durable_id":"uuid:694034cc-eee6-4b17-901f-708f50df9b09","wipe_durable_id":"byid:virtio-R116CEL","guest_attached":false,"backup_target":true,"guest_path":"/mnt/felhom-drives/cel","bound_under_parent":true,"smart":{"health":"UNKNOWN", }},
{"name":"local-lvm",  as above  },
{"name":"local",  as above  }
],"guest_boot_id":"1785390256-46753","vmid":9201}}

(the local-lvm / local rows in 6.3 are byte-identical to 6.1 apart from used_bytes; elided only there, and nowhere else in this document.)


7. The per-row table for the backup-target drive

PAYLOAD. Every cell read from the JSON above.

PRESENT — 1 row mentions the drive

Name Type State Role MountPath GuestPath BackingDevice BackupTarget BoundUnderParent
felhom-backup local-dir attached user-data /mnt/cel /mnt/felhom-drives/cel /dev/vdb true true

ABSENT — 2 rows mention the drive, and the two facts are on DIFFERENT rows

Name Type State Role MountPath GuestPath BackingDevice BackupTarget BoundUnderParent Source
felhom-backup local-dir disconnected system "" "" (field absent) "" true false Observe loop, disks.go:196-284
694034cc-…9b09 usb attached (hardcoded :302) user-data (hardcoded :304) /mnt/cel /mnt/felhom-drives/cel "" field ABSENT ⇒ false false union loop, disks.go:297-339

durable_id also degrades on the Observe row: uuid:694034cc…path:/mnt/cel.

RETURNED — 1 row again

Name Type State Role MountPath GuestPath BackingDevice BackupTarget BoundUnderParent
felhom-backup local-dir attached user-data /mnt/cel /mnt/felhom-drives/cel /dev/vdc true true

8. The mechanism

Which row the lookup lands on, which key it uses, what that row says.

The controller's key is a.Path — the registered StoragePath, which for an external drive is the GUEST path /mnt/felhom-drives/cel (intermediary.go:297, :318). driveTargetByPath (intermediary.go:602-618) builds the map with plain assignment, one entry per non-empty GuestPath (:612) and per non-empty MountPath (:615).

PAYLOAD — the map, computed by applying intermediary.go:602-618 verbatim to the captured JSON:

PRESENT   rows=3  isTarget["/mnt/felhom-drives/cel"] = True   → SPECIFIC
          map: {'/mnt/felhom-drives/cel': True, '/mnt/cel': True}
ABSENT    rows=4  isTarget["/mnt/felhom-drives/cel"] = False  → GENERIC
          map: {'/mnt/felhom-drives/cel': False, '/mnt/cel': False}
RETURNED  rows=3  isTarget["/mnt/felhom-drives/cel"] = True   → SPECIFIC
          map: {'/mnt/felhom-drives/cel': True, '/mnt/cel': True}

That is the live asymmetry — generic on detach, specific on return — reproduced from the payload alone, matching Session C and Part 5 exactly.

Why it fails, step by step. Steps 14 are payload; step 5 is the map above.

  1. The device goes. exactMountDevice fails, so backingDevice and mountPath are both "" (observe.go:184-190). Payload: Observe row has backing_device:"", mount_path:"".
  2. RoleForStorage therefore returns RoleSystem (role.go:180-181), so the GuestPath block at disks.go:215-217 is skipped. Payload: role:"system", no guest_path.
  3. v0.115.0's fallback (disks.go:258-260) fires — its three gates all pass — but it calls StablePathForRaw(t.MountPath) with MountPath == "", which returns "" (intermediary.go:69-75). It assigns an empty string. Payload confirms: no guest_path on that row. This is exactly why v0.115.0 is inert.
  4. seen is built only from rows whose MountPath != "" (disks.go:290-295). The Observe row's is empty, so /mnt/cel is not in seen, so the union row is not deduped at :298. It is emitted with MountPath from the .mount unit file and GuestPath from :309-310 — and BackupTarget is not in its struct literal (:301-306), so it serialises as false. Payload: row 4 exists, carries both paths, and backup_target is absent.
  5. So the row holding backup_target:true contributes no key at all (both its keys are empty strings and are skipped at :611 and :614), while the row that owns the key /mnt/felhom-drives/cel says false. isTarget[a.Path]falsenotifyDriveAbsent takes the else branch (intermediary.go:645) → generic storage_disconnected.
  6. On return the two rows re-merge into one (the Observe row regains MountPath, so the union row is deduped again) — that single row carries backup_target:true and the guest path, so notifyDriveReturned takes the specific branch (:656) → backup_target_restored. Hence the unmatchable pair.

INFERENCE (small, and labelled): step 5's map-construction and step 6's branch selection were computed by applying the controller's published source to the captured payload, not observed from a running controller this session. The observable they predict — generic out, specific back — is exactly what Session C and Part 5 did measure live on real boxes, twice. So the prediction is corroborated, but the controller was not itself re-run here.

There is no map-overwrite race. An earlier worry was that two rows might both write the same key with the last one winning. They do not: the Observe row contributes nothing, so the union row is the sole writer of /mnt/felhom-drives/cel. Row order is irrelevant.


9. Which theory this is

Theory Verdict from payload
#1 — "the union row writes false" (raised, then declared wrong and retracted) CORRECT. The union row is emitted (rows 3 → 4), owns the key, and its backup_target field is absent ⇒ false. The retraction was the error, not the theory.
#2 — "no row carries the guest path; the union row is deduped away" (the basis of the shipped v0.115.0 fix) FALSE on both halves. A row does carry guest_path:"/mnt/felhom-drives/cel" (row 4), and the union row is not deduped — precisely because MountPath does not survive on the Observe row, which is the fact #2 was built on. #2 had the premise right and drew the opposite conclusion from it.
#3 — "isTarget["/mnt/<n>"] is already true in the absent state, so the controller could fall back to it" FALSE. isTarget["/mnt/cel"] = false as well — the same union row writes both keys. A fallback to the raw path changes nothing.

So: #1, and it was available all along. The one genuinely new fact is why the dedup does not fire — seen is keyed on the very field the absent state empties (disks.go:290-295 vs :298), which makes the union row's emission a consequence of the vanished device rather than an independent condition.


10. What a fix would have to change — constraints for the next session's spec

Not implemented here. Stated as constraints, not as a design.

  1. The necessary and sufficient condition is that the row owning the key /mnt/felhom-drives/<name> must report backup_target: true when that drive is the primary target. Everything else is a means. Whether that is achieved by carrying the flag onto the union row, by merging the two rows, or by making driveTargetByPath OR rather than assign, is the spec's choice — but it must be verified against a payload, not a fixture.
  2. disks.go:301-306 omits BackupTarget — and the omission is invisible. A struct literal that silently defaults a bool is the whole defect. Whatever the fix, this literal is the site. Note the union loop has no access to t (there is no Observe target for it) — it would have to compare d.Name/d.MountPath against primaryTargetID/the target's path, and d.Name for a union row is the drive's UUID, not the storage id (payload: "694034cc-…" vs "felhom-backup"), so a naive d.Name == primaryTargetID comparison will silently never match. That trap is worth stating in the spec explicitly.
  3. Fixing only the agent may be enough, but the controller half is worth a look in the same pass. driveTargetByPath uses assignment, so it is order-dependent the moment two rows ever carry the same key with different values — which is exactly the state a partial fix would create. An OR (or a documented single-writer invariant with a test) removes the hazard.
  4. v0.115.0's fallback should not simply be extended. It is gated on t.BackingDevice == "" and feeds StablePathForRaw(t.MountPath); in the failing state MountPath is also "", so there is no value on that row to derive a guest path from. Any fix on the Observe row needs a path source that survives device loss — and the only one that does is the same .mount unit the union row already reads. That argues for fixing the union row, not the Observe row.
  5. Do not let the fix make an absent drive read as present. planDriveGates computes present[gp] = present[gp] || d.BoundUnderParent (intermediary.go:226). The union row's BoundUnderParent is false in the payload (R-113's conjunction holds), and it must stay false. The existing TestAbsentTargetRowDoesNotRegisterPresence pins this for the Observe row; the union row needs the equivalent.
  6. The red-proof must be the payload shape, not the Session-C fixture. v0.115.0 was unit-tested and red-proofed against a fixture that supplied a MountPath and a disconnected state — a shape that does not occur. The fixture for the next fix is §6.2 of this document: 4 rows, the target's flag and its guest path on different rows, state:"attached" on the union row.
  7. Whether to keep v0.115.0 is now answerable. It is provably inert (it assigns ""). It is not harmful. Reverting it is a separate change with its own risk; extending it is ruled out by (4). Recommendation, for the spec to accept or reject: leave it, and note in its comment that the case it targets cannot arise — or delete it in the same commit that fixes the union row, so the tree does not carry two explanations of one defect.

11. R-117 (new) — the returned drive's guest bind is a DEAD MOUNT, and everything reports healthy

The brief's §9 "stale bind, unchased after three runs". It is not cosmetic. Captured this run, and it is worse than a wrong device name.

PAYLOAD + live probe, RETURNED state (drive back, host healthy on /dev/vdc):

host   findmnt -no SOURCE /mnt/cel                     → /dev/vdc
guest  findmnt -no SOURCE,OPTIONS /mnt/felhom-drives/cel → /dev/vdb[/felhom-data] rw,relatime,shutdown
guest  ls  /mnt/felhom-drives/cel   → ls: reading directory …: Input/output error
guest  write into it                → cannot create …: Input/output error   (GUEST-WRITE-FAIL)
host   ls /mnt/cel/felhom-data/     → fine, empty, live
/disks says:  state "attached", role "user-data", bound_under_parent TRUE, backup_target true

The consequence, and this is why it outranks R-116. planDriveGates computes present["/mnt/felhom-drives/cel"] = … || d.BoundUnderParent (intermediary.go:226), which is true. The drive is currently Disconnected, so the gate takes the Return branch (intermediary.go:281, :301-320): it clears the disconnected flag and restarts the gate-stopped apps onto a mount that returns EIO for every operation — then reports the drive healthy and emails backup_target_restored. A customer's apps come back up pointed at a dead namespace with no alarm on any channel.

R-113's conjunction cannot catch it: half 1 (guest mountinfo has an entry at the guest path) is satisfied by the stale entry, and half 2 (devicePresent("/mnt/cel")) is satisfied by the new device. Neither half compares the two. disks.go:158-165's comment claims only that the conjunction fixes the boot-ordering and outlived-bind cases — it does not claim this, so this is a gap rather than a false invariant.

Reproduced deliberately and cleanly here; consistent with the same asymmetry seen (uninvestigated) in E-2d, Session C and Part 5. Filed, not chased, per the brief.


12. R-118 (new) — the absent drive's union row reports the ROOT filesystem's size as its own

PAYLOAD, §6.2 row 4: total_bytes: 49675956224, used_bytes: 4584579072byte-identical to the local row (durable_id: path:/var/lib/vz, i.e. pve-root) in the same response. The real drive is 4 GB (4143677440 in §6.1).

Cause, from source: statfsCapacity(d.MountPath) at disks.go:335-338 statfs's /mnt/cel, which with the device gone is a bare directory on the root filesystem. observe.go:176-183's comment warns about exactly this trap and guards the Observe path against it ("an unmounted removable dir-storage's mountpoint reverts to a bare directory on root … would be a catastrophic DR mis-id"); the union path at :335 has no equivalent guard.

Not a DR mis-id — durable_id on that row is still the correct uuid:694034cc…, so the re-attach identity is safe. It is a false capacity: a detached 4 GB drive advertises 46 GiB with 9.2 % used, to any consumer of total_bytes/used_fraction (fill monitors, the storage cards). Same class as the role.go:180-181 flip that drives R-116 — an absent drive's fields decaying to the root filesystem's.


13. Teardown

Before After
drill VM pvesm status felhom-backup active, local-lvm 0.99 %, local 9.23 % felhom-backup removed, local-lvm 0.00 %, local 9.23 %
drill VM pct list 9201 running empty
  • guest 9201 pct destroy --purge (logical volume removed, config purged); pvesm remove felhom-backup; mnt-cel.mount disabled + deleted + daemon-reload; shred -u on the local-API token file and the PVE token file.
  • poweroff, qemu exited (0 procs), qemu-img snapshot -a virgin drill.qcow2 → the golden-bake fixture is exactly as found (virgin, ID 1, 2026-07-03 14:12:15).
  • scratch artifacts deleted: r116-cel.qcow2, qmp.sock, qemu.pid, both console .ppm dumps.
  • DooPlex df -h /mnt/5_hdd: 2.1T used / 6.6T avail, 24 % — same as at session start.
  • The disposable root@pam!r116 PVE token and the drill local-API token died with the snapshot revert; neither is recorded in any committed file.

Fences — re-verified after teardown, not assumed:

Fence State
demo-hp local-lvm 38.83 % — identical before, after, and to Part 5's reading
demo-hp drill-r50 (VM 300) stopped — untouched
demo-hp guest 9201 running
felhom-pve felhom-backup active, /mnt/hdd_1 on /dev/sdbtarget not re-pointed
felhom-pve guest 9201 running
Both demo boxes read-only access onlyGET /disks, file reads, pvesm status, pct list, one scp of the agent binary
Code no .go file edited, nothing built, nothing published, v0.115.0 untouched

sess-d, and a leftover the prior runs did not record

sess-d-0452c4 now reads STALE, not ONLINE. The delete gate refuses only on s.hostStatus(...) == "ok" (hub/internal/web/customer_delete.go:220-228) — ONLINE — so sess-d is now deletable. Not executed here: deleting a customer runs external teardown plus a DB purge, and the brief scoped this session to recording the command.

POST /configs/sess-d/delete   ack_hosts=1 ack_reset=1 ack_purge=1 confirm_id=sess-d expect_hosts=1

Also still present, and not mentioned in the Session-C audit: sess-c / host sess-c-8158e4 (agent 0.114.0, DOWN). Same shape, same gate, deletable on the same terms — confirm_id=sess-c expect_hosts=1. Two drill customers are now accumulating in the hub; that is the operator's cleanup call, not a register row.


14. What did not happen, and why

  • No controller was run, so no fresh gate-log / hub-event correlation was captured (brief §6.4). Deliberate: that observable was already captured live and identically twice (Session C, Part 5), and the payload was the only missing input. Staging a controller meant a hub customer, a pairing, a golden fetch and a claim — the work that consumed the three prior sessions. §8 correlates the payload to those existing measurements instead, and labels that as the inference it is.
  • No new drill box was built — the brief's stated preference. The existing DooPlex nested-PVE fixture was reused and restored.
  • R-117 and R-118 were filed, not investigated. R-117 in particular deserves its own run; on the evidence here it is more customer-affecting than R-116.
  • v0.115.0 was neither reverted nor extended, per the fence.
  • Two boot attempts of the drill VM failed before the run started and are recorded because the cause is a reusable trap: adding -device entries at launch shifts PCI slot assignment, which renames the guest NIC (ens3 → gone), so vmbr0 loses its port and SSH is unreachable while the console shows a healthy PVE login. bootindex fixes the boot-order half but not the NIC half. The fix is to boot the memory's known-good command line unchanged and hot-plug the extra disk over QMP afterwards — which is also the more faithful device-loss mechanism.