v0.115.0 — R-116: the backup-target flag reaches the row the controller keys on

Session C measured it live: a drive whose device vanished raised the GENERIC
storage_disconnected while its return raised the SPECIFIC
backup_target_restored -- an alarm and an all-clear an operator cannot pair.
backup_target_absent never fired at all.

The mechanism is not what the Session-C audit first said, and the difference
decides the fix. RoleForStorage returns RoleSystem whenever backingDevice == ""
(internal/storage/role.go:180-181). When the device goes, exactMountDevice
fails, BackingDevice becomes "", the target row's role flips to system and it
loses its guest path -- but keeps its MountPath. The union loop skips any drive
whose MountPath is already seen, so the registry row is DEDUPED AWAY ENTIRELY.
/disks carries no row with that guest path, so isTarget[guestPath] is a MISSING
KEY, not a false. Setting BackupTarget on the union row -- the obvious fix --
could not have worked, because that row is not emitted when the alarm is needed.
The audit is corrected in the same push.

Fix: on the Observe row only, carry the guest path when the row IS the backup
target and its role flipped because the device vanished.

Three gates, verified not assumed:
- t.BackingDevice == "" restricts it to the vanished-device flip; a genuinely
  system-BACKED storage has a real device and is excluded, so a dir storage at
  /mnt/<name> on the root disk cannot acquire a guest path.
- Case B, the common fresh-box shape, is safe twice over: its target is the
  builtin local on /var/lib/vz and StablePathForRaw returns "" for anything not
  exactly /mnt/<name>, so nothing is set even before the gates apply.
- It cannot make the gate read an absent drive as PRESENT. BoundUnderParent is
  assigned at exactly two sites, both inside guest-path blocks a system-role row
  never enters, so it stays false and planDriveGates computes false || false.
  Pinned by TestAbsentTargetRowDoesNotRegisterPresence -- getting this backwards
  would have silenced the alarm the fix exists to raise.

The :213-214 boundary stands: no system or backup mount gains a guest path.

Tests +5, asserting the emitted /disks JSON through a faithful copy of the
controller's driveTargetByPath, because the failure class is "the value is on
the wrong row". Red-proof: removing the block fails with "isTarget[...] is a
MISSING KEY"; reverted byte-identical.

Filed not closed: the two-row shape that produced this survives.
This commit is contained in:
2026-07-29 23:51:03 +02:00
parent b58d7bcf39
commit a58239f6de
3 changed files with 279 additions and 0 deletions
+50
View File
@@ -1,3 +1,53 @@
## v0.115.0 — R-116: the backup-target flag reaches the row the controller keys on (2026-07-29)
**The defect, measured live in Session C.** A drive whose device vanished raised the **generic**
`storage_disconnected`, while its return raised the **specific** `backup_target_restored` — an alarm
and an all-clear an operator cannot pair. `backup_target_absent` never fired at all.
**The mechanism, and it is not what the Session-C audit first said.** `RoleForStorage` returns
`RoleSystem` whenever `backingDevice == ""` (`internal/storage/role.go:180-181`). When the device goes,
Observe's `exactMountDevice` fails, `t.BackingDevice` becomes `""`, the target row's role flips to
system and it **loses its guest path** — but keeps its `MountPath`. The union loop skips any drive whose
`MountPath` is already `seen`, so the registry row is **deduped away entirely**. `/disks` ends up with
**no row carrying that guest path**, so the controller's `isTarget[guestPath]` is a **missing key**, not
a `false`. The obvious fix — setting `BackupTarget` on the union row — **could not have worked**: that
row is not emitted in the state where the alarm is needed. The audit has been corrected.
**The fix.** On the Observe row only, carry the guest path when the row **is** the backup target **and**
its role flipped because the device vanished:
```go
if di.GuestPath == "" && di.BackupTarget && t.BackingDevice == "" {
di.GuestPath = StablePathForRaw(t.MountPath)
}
```
**Three gates, each verified rather than assumed:**
- `t.BackingDevice == ""` restricts this to the vanished-device flip. A storage that is `RoleSystem`
because it is genuinely system-**backed** has a real device and is excluded — otherwise a dir storage
at `/mnt/<name>` on the root disk would acquire a guest path.
- **Case B, the common fresh-box shape, is safe twice over.** Its target is the builtin `local` on
`/var/lib/vz`, and `StablePathForRaw` returns `""` for anything that is not exactly `/mnt/<name>`
(`DriveNameFromRaw`, `intermediary.go:79-88`) — nothing is set even before the gates apply.
- **It cannot make the gate read an absent drive as PRESENT.** `BoundUnderParent` is assigned at exactly
two sites (`disks.go:222`, `:280`), both inside guest-path blocks a system-role row never enters, so
it stays `false` and `planDriveGates` computes `present[gp] = false || false`. Inert by construction —
pinned by `TestAbsentTargetRowDoesNotRegisterPresence`. Getting this wrong would have **silenced the
alarm this fix exists to raise**.
**The `:213-214` boundary stands.** No system or backup mount gains a guest path; only the drive the
alarm is *about* keeps its identity while it is missing, and only while its device is gone.
**Tests** +5 in `internal/localapi`, asserting the emitted `/disks` JSON through a faithful copy of the
controller's `driveTargetByPath`, because the failure class is "the value is on the wrong row" and a
hand-built fixture proves nothing about which row the handler emits. Red-proof: removing the block
fails with *"isTarget[…] is a MISSING KEY"*; reverted, byte-identical.
**Known limitation, filed not closed:** the two-row shape that produced this survives. The flag and the
guest path still live on different rows in the healthy state, and nothing prevents a future consumer
keying on the wrong one.
## v0.114.0 — R-113: drive presence means the DEVICE, not the bind (2026-07-29)
**The bug, measured live in E-2d.** `BoundUnderParent` — the one field the controller's drive-absent