Files
felhom-agent/REPORT.md
T
admin 21b0164fad R-116 (v0.116.0): give the backup-target flag and the gate's key the same row
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.
2026-07-30 08:48:31 +02:00

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/`.