21b0164fad
The absent-drive alarm was generic while its recovery was specific -- a pair an operator cannot match. Mechanism now measured, not reasoned (felhom.eu audits/DIAG-r116-disks-payload-2026-07-30.md): with the device gone /disks returns 4 rows, not 3. The drive appears TWICE and the two facts the controller needs are on different rows -- the Observe row has backup_target:true but mount_path:"" and guest_path:"" (so driveTargetByPath registers NO key from it), while the registry row owns /mnt/felhom-drives/<name>, the key the gate looks up, with BackupTarget absent from its struct literal => false. WHY v0.115.0 WAS INERT: its fallback computed StablePathForRaw(t.MountPath), and in the absent state MountPath is ALSO "" -- emptied by the same exactMount failure that empties BackingDevice. It assigned nothing. Its test passed because the fixture supplied a MountPath production never supplies, and the harness left DriveTargets nil so the union loop never ran. Both corrected here; red-proof 1 replays v0.115.0's exact code against the real shape and it fails. THE JOIN, which was the hard part: with the device gone the two records share no runtime field -- no mount, no backing device, and the Observe row's DurableID has degraded off the fs-UUID. They share CONFIGURATION: storage.cfg's path on one side, the .mount unit's Where on the other, both yielding the same stable guest path. New hub.StorageTarget.ConfigPath (json:"-" -- that struct is a cross-repo contract pinned by the golden + contract_test key-set comparison, and nothing off-box needs the value), set from s.Path in observe.go, consulted in disks.go only after MountPath so the present-state path is byte-identical, plus a guest-path arm on the union dedup so exactly one row carries the drive. WHY NEITHER OBVIOUS OPTION WAS TAKEN -- both regress R-114, which shipped yesterday. backup_target_offer.go:79 reads (BackupTarget && MountPath != "") as "a real drive with its own mountpoint -- healthy" and returns before its TargetAbsent branch. Back-filling MountPath onto the Observe row (the smallest change, and the spec's lean) and teaching the registry row the flag (its MountPath is non-empty, read from the stale unit file) BOTH manufacture that row while the drive is missing, which would have told the customer the backup target is fine while its drive is gone. R-114's correctness rests on the absent-state rows not combining the flag with a mount path; that coupling was invisible until the payload existed. Pinned by TestAbsentTargetKeepsR114DegradedSignal. Role unchanged, BoundUnderParent conjunction not widened, no wire field changed. Suppressing the registry row in the absent state also removes its false state:"attached" and its root-filesystem-derived total_bytes -- R-118's symptom goes incidentally; R-118 is NOT fixed and stays open. Tests 845 -> 849, suite rc=0 read separately from this commit. Four red-proofs, each mutation asserted to have landed first. NOT live-validated at this commit: publish+vouch, C5, discrimination, over-correction.
119 lines
8.0 KiB
Markdown
119 lines
8.0 KiB
Markdown
# REPORT — v0.116.0: R-116, the flag and the key share a row (2026-07-30)
|
|
|
|
**Overwritten** per the standing rule. Agent-only fix: the controller was **not** touched (one fix, one
|
|
place) and `v0.115.0` was **not** reverted. Mechanism evidence: `felhom.eu`
|
|
`audits/DIAG-r116-disks-payload-2026-07-30.md`.
|
|
|
|
## Baselines as actually running (not as on `main`)
|
|
|
|
| | `main` | running | a FRESH box gets (hub Day-0 manifest) |
|
|
|---|---|---|---|
|
|
| agent | 0.115.0 → **0.116.0** | felhom-pve **0.115.0**, demo-hp **0.113.0** | **0.115.0** |
|
|
| controller | 0.186.0 | felhom-pve **0.186.0**, demo-hp **0.185.1** | golden bakes **0.185.1** |
|
|
| hub | 0.81.0 | 0.81.0 | `min_agent` 0.113.0, `min_controller` 0.156.0 |
|
|
| `felhom.eu` | `1aa1bd1` | — | — |
|
|
|
|
**The fleet is not uniform on either component.** The golden baking controller **0.185.1** while
|
|
demo-felhom runs **0.186.0** is load-bearing for the ruling below: a controller-side fix would not reach
|
|
a fresh box without a re-bake, whereas the agent channel already serves the newest published version.
|
|
|
|
## The ruling: neither (a) nor (b) — a hybrid, because both regress R-114
|
|
|
|
`primaryTargetID` is a PVE storage name (`felhom-backup`); the registry row's `Name` is the drive UUID.
|
|
With the device gone the two records of one drive share **no runtime field** — no mount, no backing
|
|
device, and the Observe row's `DurableID` has degraded off the fs-UUID to `path:/mnt/cel`.
|
|
|
|
**Rejected (a) — back-fill `MountPath` on the Observe row.** The spec's lean, and it does re-activate
|
|
v0.115.0. Rejected on source evidence: `backup_target_offer.go:79` reads
|
|
`d.BackupTarget && d.MountPath != ""` as *"a real drive with its own mountpoint — healthy"* and returns
|
|
**before** its `TargetAbsent` branch. Option (a) manufactures exactly that row while the drive is
|
|
missing, so it would have silently flipped **R-114** — shipped 2026-07-29, proven live — back to a false
|
|
*healthy* banner. Also `MountPath`'s own contract is "the RAW host PVE mount" (`disks.go:152-153`); a path
|
|
that is not mounted is not that, so the downstream reading is correct and the change would be the bug.
|
|
|
|
**Rejected (b) — carry `BackupTarget` on the registry row.** A join **does** exist (below), so this was
|
|
available — but the registry row's `MountPath` is non-empty, read from the by-then-stale `.mount` unit
|
|
file. It trips `:79` identically. Same regression, same reason.
|
|
|
|
**Rejected (c) — resolve controller-side.** The mapping is not there to find, and the *one state, one
|
|
owner* ruling stands: the agent owns drive identity.
|
|
|
|
**Taken (d) — give the Observe row the guest path (and NO MountPath), then dedup the registry row on
|
|
guest path.** The gate keys on **GuestPath**; R-114 keys on **MountPath**. Separating them satisfies both:
|
|
the surviving row carries the flag and the guest path with `mount_path: ""`, so `isTarget[guestPath]` is
|
|
`true` **and** `:79` does not match, leaving R-114's `TargetAbsent` branch reachable.
|
|
|
|
**The join:** with the device gone the shared identity is **configuration**, not state — the storage's
|
|
`path` from `storage.cfg` (Observe side) and the `.mount` unit's `Where` (registry side) are the same
|
|
path, so both derive the same stable guest path. That is the key both sides can still compute.
|
|
|
|
**What (d)'s dedup removes in the absent state**, enumerated: the registry row's `uuid:` `durable_id`, its
|
|
hardcoded and **false** `state: "attached"`, its `role: user-data`, and its `total_bytes`/`used_bytes`
|
|
copied from the **root** filesystem. No consumer found — wipe/decommission require the drive present;
|
|
`firstOfferableDrive` (`:138`) already requires `MountPath != ""` **and** `Role == "user-data"`; `diskKey`
|
|
(`disk_health.go:130`) changes key transiently but the health check excludes UNKNOWN verdicts, so no false
|
|
degradation. **R-118's symptom disappears incidentally — R-118 is NOT fixed and stays open.**
|
|
|
|
**Deliberately unchanged:** `Role` (the absent target still reads `system`), the `BoundUnderParent`
|
|
conjunction, and every wire field.
|
|
|
|
## Files
|
|
|
|
`internal/hub/report.go` (`ConfigPath`, **`json:"-"`** — that struct is a cross-repo contract pinned by
|
|
`testdata/host-report.golden.json` + `contract_test.go`'s key-set comparison, so a wire-visible field
|
|
would have needed a matching `felhom.eu/hub` change; nothing off-box needs the value) ·
|
|
`internal/storage/observe.go` (set from `s.Path`) · `internal/localapi/disks.go` (ConfigPath fallback +
|
|
guest-path dedup) · `internal/localapi/disks_backup_target_row_test.go` (fixture corrected + 4 tests) ·
|
|
`CHANGELOG.md`.
|
|
|
|
`observe.go`'s change is **not** the fallthrough its `:176-183` comment forbids: that prohibition is about
|
|
resolving a *device or UUID* from the **containing** filesystem, which would hand back root's identity and
|
|
mis-target a DR re-attach. `s.Path` is the storage's own declaration, identifies nothing but itself, and is
|
|
used for no resolution. `MountPath` stays empty, which is the truth.
|
|
|
|
## §2 reconfirmation
|
|
|
|
| Cited | Line now | Holds |
|
|
|---|---|---|
|
|
| `mountPath` assigned only on exactMount success | `observe.go:184-190` | **HOLDS** verbatim |
|
|
| empty `backingDevice` ⇒ `RoleSystem` | `role.go:180-181` | **HOLDS** verbatim |
|
|
| guest-path guard skipped for non-user-data | `disks.go:215-217` | **HOLDS** |
|
|
| `Known()` reads unit FILES, never the mount table | `registry_known.go:40-75`, `MountPath: spec.Where` at `:70` | **HOLDS** |
|
|
| `driveTargetByPath` keys only from non-empty fields | `intermediary.go:602-618` (`:611`, `:614`) | **HOLDS** |
|
|
| the dedup keys on `d.MountPath` at `:263` | `:263` is `if t.BackingDevice != ""`. Actual: `seen` built `:290-295`, dedup `:298` | **HOLDS; the line number in the spec is wrong** |
|
|
| "`seen[""]` is set, so the union row is not skipped" | **DOES NOT HOLD as stated** — `seen[d.MountPath] = true` is guarded by `if d.MountPath != ""`, so `seen` never gains `""`. The union row survives because `/mnt/<name>` is *absent* from `seen`, not because `""` is present. Same outcome, different reason | corrected |
|
|
|
|
## Tests — 845 → 849, suite green (`go build ./... && go vet ./... && go test ./...` rc=0)
|
|
|
|
All PASS: `TestAbsentBackupTargetIsResolvableByGuestPath` (rewritten — real shape **and** the union loop),
|
|
`TestAbsentTargetAppearsExactlyOnce` (new, the join), `TestAbsentTargetKeepsR114DegradedSignal` (new, the
|
|
regression guard), `TestPresentTargetPayloadUnchanged` (new, fleet-wide parity),
|
|
`TestAbsentNonTargetKeepsItsRegistryRowAndNoFlag` (new, negative + over-suppression),
|
|
`TestAbsentTargetRowDoesNotRegisterPresence`, `TestCaseBLocalTargetGetsNoGuestPath`,
|
|
`TestSystemBackedTargetUnderMntGetsNoGuestPath`, `TestNonTargetDriveNeverCarriesTheFlag`.
|
|
|
|
**Red-proofs — each mutation asserted to have landed before the run:**
|
|
|
|
1. v0.115.0's MountPath-only fallback → `isTarget[…] = FALSE … rows=2`. **The empirical proof v0.115.0
|
|
was inert**, rather than an argument for it.
|
|
2. Drop the guest-path dedup → `the absent drive is carried by 2 rows, want exactly 1`.
|
|
3. Give the absent row a `MountPath` (what option (a) produces) → the R-114 guard fires.
|
|
4. Over-broad dedup (`gp != ""` without `seenGuest`) → the non-target drive loses its own row, and two
|
|
pre-existing R-113 union tests fail as well.
|
|
|
|
**The corrected fixture is the headline test change.** `theAbsentTarget` supplied
|
|
`MountPath: "/mnt/mentes"`, which production never supplies, and `targetRowServer` left `DriveTargets`
|
|
**nil**, so the union loop never ran and the two-row shape was invisible to the entire suite. Both fixed —
|
|
that combination is why an inert fix shipped green.
|
|
|
|
## Process note
|
|
|
|
One red-proof was restored with `git checkout <file>`, which discarded the whole implementation rather
|
|
than the one-line mutation. Re-applied and re-verified; later red-proofs restored from pristine copies
|
|
kept outside the repo. `git checkout` is not an undo for a mutation on a work-in-progress file.
|
|
|
|
## NOT yet live-validated at the time of this commit
|
|
|
|
Publish + vouch, C5 pairing, discrimination, and the over-correction guard — the session's live half.
|
|
Outcome recorded in the audit under `felhom.eu/documentation/audits/`.
|