v0.127.0: a mount Felhom itself made is not 'something else' (R-220)
gates / gates (push) Successful in 8s

After a rebuild the customer's own drives could not be re-attached: candidates
returned initialize:[] attach:[] while both drives sat there, and the deploy
refused with 'choose an attached drive from the list' — a list that was empty.
Measured live three times.

Mechanism: enrolment mounts a drive TWICE, at /mnt/felhom-drives/<name> and at
the raw /mnt/<name> it creates on the host. The host survives a guest rebuild;
the controller's registry does not. So classifyClaim saw a mount outside the
managed prefix and concluded 'claimed by something else' — about our own mount.

The fix is CORROBORATED, not a widened prefix: a non-managed mountpoint is
forgiven only when the SAME device is also mounted under the managed path, a
pairing only our enrolment produces. A disk another system uses — /srv/data,
/media/x, even /mnt/someone-elses-disk — has no counterpart and is STILL
refused, with its own test and a red-proof showing an over-wide fix offering it
for formatting.

Read from /proc/mounts deliberately: the lsblk invocation is pinned verbatim in
configs/felhom-agent.sudoers, so using the plural MOUNTPOINTS would have coupled
this to a sudoers rollout. /proc/mounts is world-readable — no sudo, no new
allowlisted command, no config change.

Fail-safe: an unreadable mount table corroborates NOTHING, so the device
classifies exactly as before. 'Could not corroborate' must never read as 'ours'.

29 packages ok, vet clean, agent gates OK.
This commit is contained in:
2026-08-06 12:55:28 +02:00
parent aa74294a7d
commit 703db166e7
5 changed files with 251 additions and 38 deletions
+24 -36
View File
@@ -1,53 +1,41 @@
# REPORT — felhom-agent v0.126.0: a fetch failure is not a wrong recovery code (R-224)
# REPORT — felhom-agent v0.127.0: a mount Felhom made is not foreign (R-220)
**Scope: this repo's half of R-224.** The controller half ships as felhom-controller v0.202.0.
## Why the agent changed at all
The task that commissioned this work scoped `felhom-agent` as **untouched**. It could not be. Its
Scenario A (a hub outage must not blame the customer's code) and Scenario C (a genuine mistype must be
told to re-check the ten words) are **mutually unsatisfiable** while this agent answers both with one
HTTP 400 and one sentence. No value available to the controller separates them. The task's own §5
anticipates this — *"if the step is not recoverable from the value, make it so, and say what that
cost"* — and §4.3 says the source outranks the register's recorded shape. **The cost is this version,
a publish, and a `MinAgent` coupling on the controller side.**
**Scope: the host half of R-220.** The customer-facing refusal message is the controller's half and
ships as felhom-controller v0.203.0.
## What changed
| File | Change |
|---|---|
| `internal/escrow/recover.go` | new `ErrBundleFetch` sentinel; the fetch leg joins it with `%w: %w` so the cause survives for the operator log |
| `internal/localapi/escrow_recover.go` | new `case errors.Is(err, escrow.ErrBundleFetch)`**502** with its own words; the `default` now carries only the wrong-code case and drops the "or" |
| `internal/escrow/recover_test.go` | three new tests; the pre-existing `FetchErrorIsDistinct` re-pointed from a string to the sentinel, with the reason it failed to protect |
| `internal/localapi/escrow_recover_class_test.go` | new — the consequence-level test: four situations, four statuses |
| `internal/storage/claim.go` | `claimFacts.felhomOwnedMounts`; `classifyClaim` forgives a non-managed mountpoint **only when corroborated**; `felhomOwnedMounts()` + `procMounts()` |
| `internal/storage/hostops.go` | `mountTable` seam (nil ⇒ real `/proc/mounts`) |
| `internal/storage/claim_r220_test.go` | new — the own-drive case, the fence, and the corroboration's four edges |
**Four statuses:** `502` fetch failed (the code was **not used**) · `400` fetched and refused ·
`404` no bundle · `409` bundle predates the field.
## The shape chosen, and why (§7.3)
## The finding this turned up
**Candidate (b): the claimed check distinguishes a mount Felhom made from a foreign one** — the task
called it "nearer the truth" and it is, because the host and its knowledge survive the rebuild while
the guest's registry does not. Candidate (a) — having the rebuild path clear the raw mounts — would
have made correctness depend on a cleanup step running, and a cleanup that does not run leaves exactly
today's defect.
**A green test named the defect and did not prevent it.** `TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct`
has asserted since v0.125.0 that *"the operator must not be sent to re-read their recovery code because
the hub was unreachable"*. It passed throughout, because it checked this package's error **string** one
layer below the local-api `default` that did the merging — and a string is not something a caller can
branch on. **Mechanism asserted, consequence unpinned**; the project's own rule names this exact case.
It is also a comment-vs-code entry: `recover.go`'s header said the errors were *"DISTINCT on purpose"*
and named **three** situations while a fourth was silently folded into one of them.
**The discriminator is corroboration, not a path prefix**: the same device must ALSO be mounted under
`/mnt/felhom-drives`. Only enrolment produces that pairing.
**`/proc/mounts` rather than `lsblk MOUNTPOINTS`**, because the lsblk invocation is pinned verbatim in
the sudoers file; changing it would have coupled this fix to a config rollout. `/proc/mounts` is
world-readable and needs neither.
## Green gate
`go build ./...` clean · `go vet ./...` clean · `go test ./...`**29 packages ok** ·
`python3 scripts/agent_gates.py --fast` → all gates OK.
`go build` · `go vet` clean · `go test ./...`**29 packages ok** · `agent_gates.py --fast` → all OK.
**Red-proofs, each demonstrated failing then restored:**
| Mutation | Result |
| Red-proof | Result |
|---|---|
| remove the `%w: %w` join (pre-R-224 wrap) | `FetchFailureIsClassifiedAsFetch` **FAILS** |
| delete the `ErrBundleFetch` handler case | fetch answers `400 "the recovery code did not open the sealed bundle"`**the exact defect**, and both status tests **FAIL** |
| remove the `felhomOwnedMounts` exemption | **FAILS** — "device is mounted at /mnt/adatok (sdb)", the pre-fix refusal |
| over-widen the exemption to any `/mnt/*` | **FAILS** — "/mnt/someone-elses-disk was offered for formatting" |
## Not changed
No Proxmox surface, no privileged path, no report/hub contract, no config schema. The route's
success path, its scoping and its R-handling discipline (`R = ""` on both paths, never logged, never
persisted) are untouched.
No sudoers, no allowlisted command, no PVE surface, no format path. Every other claim signal
(system disk, read-only, LVM PV, ZFS member, member FSTYPEs, empty-topology backstop) is untouched.