Two guards, one number: bound the published check to the retention it must live with
gates / gates (push) Successful in 17s

Gates only. No release, no version bump, no binary published; the agent stays
v0.128.0 at 28ba8593b8 and nothing on a customer's machine changes.

THE COUPLING DEFECT. The registry stopped serving 0.120.0 and older while
check-published-versions.py demanded every tag still be downloadable. Both rules
are sensible and together they are impossible, so CI went red at a commit whose
own run had been GREEN the day before -- and would have gone red again at the
next publish when 0.121.0 was evicted. scripts/retention-policy.json is now THE
number and both readers take it from there.

WHAT CI NO LONGER COVERS, and it prints this on every run rather than leaving it
to be discovered: a released version older than the retention window is no longer
asserted downloadable. Its git tag and its config tree ARE still asserted -- only
the binary's presence is dropped. A missing policy file is INCONCLUSIVE (exit 2),
never silently unbounded.

THE NUMBER IS NOT A LOCATED RULING and the file says so in its own header. Ten is
what the registry demonstrably holds; no register row records a prune, R-210 says
"Nothing was deleted; this is a list, not an action" and concerns local Docker
images, and container packages hold 19 each. The principled bound is the hub's
vouched min_agent floor -- nothing can install below it -- and that is the
recorded follow-up.

check-release-complete.py is the tag half as a machine. release-agent.sh already
warned that "a released version without a git tag 404s a box mid-install, as
root" and the step was still missed, so this is a gate and not a reminder. Legs
1-2 need no network and run in --fast, so the pre-push hook is the earliest
catch. Red-proved by repointing the CHANGELOG head at an unreleased v0.129.0:
both legs convicted and each named its fix command.

Three controls run: green at 10 naming what it dropped; widened to 11 the evicted
version re-enters and convicts; policy removed gives INCONCLUSIVE naming the path.
This commit is contained in:
2026-08-09 19:05:05 +02:00
parent 28ba8593b8
commit 53d047a6c1
6 changed files with 286 additions and 28 deletions
+49 -27
View File
@@ -1,41 +1,63 @@
# REPORT — felhom-agent v0.127.0: a mount Felhom made is not foreign (R-220)
# REPORT — felhom-agent, 2026-08-09 (gates only)
**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.
**No release. No version bump. No binary published. `scripts/` only** — nothing that runs on a
customer's machine changed, and the agent stays **v0.128.0** at `28ba8593b8`.
## What changed
| File | Change |
| file | why |
|---|---|
| `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 |
| `scripts/retention-policy.json` **(new)** | THE retention number, in one place, with its reasoning and its honesty about where the number came from |
| `scripts/check-published-versions.py` | reads that number; bounds its assertion to the newest N; **prints what it stopped covering** |
| `scripts/check-release-complete.py` **(new)** | asserts the CHANGELOG-head version is tagged, placed in this history, and published |
| `scripts/agent_gates.py` | registers the new gate; legs 12 are offline so it runs in `--fast` too |
## The shape chosen, and why (§7.3)
## The coupling defect, and the fix
**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.
The prune keeps the newest N; the published-versions check demanded that **every** tag be
downloadable. Nothing connected them, so CI went red at `28ba8593b8` — a commit whose own run had
been **green the day before** — and would have gone red again at the next publish when `0.121.0` was
evicted. Both now read `generic_versions_kept` from one file.
**The discriminator is corroboration, not a path prefix**: the same device must ALSO be mounted under
`/mnt/felhom-drives`. Only enrolment produces that pairing.
**What CI no longer covers:** a released version **older than the retention window** is no longer
asserted downloadable. Its git tag and its config tree are still asserted; only the binary's presence
is dropped. The check names the dropped versions on every run.
**`/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.
**The number is not a located ruling.** `generic_versions_kept: 10` is what the registry demonstrably
holds; no register row records a prune, and container packages hold 19 each. The file says so in its
own header. The principled bound is the hub's vouched `min_agent` floor — nothing can install below
it — and that is recorded as the follow-up.
## Controls, all three run
| control | expected | got |
|---|---|---|
| live run, policy = 10 | green, and it names `0.120.0` as not asserted | **exit 0**, and it did |
| widen policy to 11 | `0.120.0` re-enters the window and convicts | **exit 1**, `FAIL v0.120.0` |
| policy file removed | INCONCLUSIVE, never silently unbounded | **exit 2**, naming the path it tried |
## Red-proof of the new gate
Mutation: `CHANGELOG.md` head repointed to `## v0.129.0` — never tagged, never published. Asserted
applied (`grep -c '^## v0.129.0'` → 1). Result **exit 1**, both legs convicting:
```
- TAG v0.129.0 DOES NOT EXIST. … without the tag every install 404s mid-run, as root.
Fix: git tag -a v0.129.0 <released-commit> && git push origin v0.129.0
- PACKAGE 0.129.0 IS NOT PUBLISHED (HTTP 404 …).
Fix: bash scripts/release-agent.sh 0.129.0
```
Reverted; `git status` clean on `CHANGELOG.md`.
## Green gate
`go build` · `go vet` clean · `go test ./...`**29 packages ok** · `agent_gates.py --fast` → all OK.
`python3 scripts/agent_gates.py``reuse-refs OK · instructions OK · published OK ·
release-complete OK · all agent gates OK`.
| Red-proof | Result |
|---|---|
| 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 done here
## Not changed
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.
The deleter of `0.120.0` is **still not established** and a second attempt failed — Gitea keeps no
package-deletion trail, its container log no longer reaches the window, and the activity feed carries
no package operation. Recorded in R-287, including the withdrawal of my own earlier over-claim that
the router logs showed no DELETE: they do not cover the window, so they never said anything.