# REPORT — TASK-B: R-39 fleet fix + R-50b(a) · felhom-agent v0.90.0 → **v0.91.2** **Date:** 2026-07-21 · Trunk, pushed to `main`. **Baseline:** `8c55ac7` (clean, == `origin/main`). Companion: hub **v0.68.0** (`felhom.eu`). ## 0. Status | Leg | Status | |---|---| | Part 1 — hub v0.68.0 | **SHIPPED + DEPLOYED** (GitOps sync, Synced/Healthy) | | Part 2 — agent v0.91.2 | **SHIPPED + PUBLISHED + DEPLOYED** to felhom-pve | | **STOP-1** — felhom-pve update | **DONE + VERIFIED** (§5) | | **STOP-3** — manifest save | **DONE by the operator 2026-07-21 08:31:12Z** — agent 0.91.2 + sha + **wrapper sha `104db0a4…`** + MinAgent 0.91.2, all six fields persisted. The agent reports the same wrapper hash, so the R-50b drift gauge reads **ok**. | | **STOP-2** — live re-issue | **NOT DONE — the OFFSITE re-issue was pressed, not the PBS-DR one.** `host_pbs_secrets` untouched (generation 0, consumed 2026-07-18); the descriptor still has no `secret_generation`. Side effect: the offsite restic password rotated, so the escrow blob is now STALE and the recovery-code ceremony must be re-run. PBS-DR itself is unaffected (probe still 200, secret mtime unchanged). | ## 1. Probes - **P1 — version stamping: PASS.** ldflags `-X main.version`; `publish-agent.sh` refuses to publish when the binary's `--version` disagrees, so the stale `0.89.0` default cannot leak. - **P2 — hub deploy path: PASS.** GitOps only: ArgoCD app `felhom`, auto-sync **off**, manifest bump + deliberate sync. The `kubectl set image` line (in the old controller CLAUDE.md *and* printed by `build.sh` itself) was **not** used. - **P3 — felhom-pve before-state: CAPTURED** (§5 table). Notably the DR tier was **healthy**: a live `GET /api2/json/version` with the stored credential returned **200**. This is a fix for a latent defect, not a broken box. - **P4 — fleet: ONE enrolled host** (`demo-felhom-8363b5`, agent 0.90.0). **Peti is not enrolled**, so the MinAgent raise at STOP-3 cannot strand anyone. (Read from a temporary copy of the hub DB, which carries live credentials — shredded immediately after each read.) ## 2. Two spec/reality mismatches, both resolved **(a) The generation source does not exist as specified.** The brief said to return "the new row's id (int64) … no schema change". `host_pbs_secrets` is `host_id PRIMARY KEY`, UPSERTed last-write-wins — **there is no row id and no new row**, and `created_at` collides for two mints in one second. Added an additive `generation` counter column using the repo's existing idempotent `ALTER TABLE` idiom. Same semantics the brief wanted; different mechanism. **(b) A second load-bearing fact the brief did not flag — checked, not trusted.** `Apply` bails out entirely if the storage *status probe* errors, and converges via `adopt` **without consuming the secret** if the storage reads active. So the whole fix depended on what PVE returns for a 401'd PBS storage. Read PVE's own source: `storage_info` wraps `activate_storage` and `$plugin->status` in `eval{}`, warns, and leaves the pre-initialised `active => 0`. **A 401 therefore returns HTTP 200 with `active: 0`, never an API error** — `StorageActive` gives `(false, nil)` and `Apply` falls through to verify → consume → reconcile. Scenario A's chain is sound *by proof*. The re-arm test encodes this fixture shape explicitly so the assumption is visible. ## 3. What shipped (agent) `WirePBSDR.SecretGeneration` (field-exact with the hub) · wrapper `read` verb + **exactly one** sudoers line + a `pbsdr-read` capability row · `pbs.ProbeAuth` + `ErrUnauthorized` · the probe on the **15-minute collect path** feeding a loud `auth_failed` · `readPBSSecret` (direct read, wrapper fallback) · wrapper sha256 in the report (R-50b(a)). **403 is deliberately NOT treated as unauthorized** — re-keying a too-narrow token would mint credentials forever without fixing anything. **A transport error is UNKNOWN, never a rejection** — otherwise every network blip burns a credential. ## 4. Tests + the four red-proofs Full gate green in both repos at every commit. All red-proofs run at the **assertion** level. | # | Red-proof | Result | |---|---|---| | A | Remove `SecretGeneration` from the agent struct | `consume calls=1, want 2` — the converged short-circuit fires; the July-18 defect exactly | | B | Stop stamping the generation in `ReissuePBSDR` | descriptor **byte-identical** across a re-issue, block printed | | D | Remove the id charset guard **and** the prefix assertion | the out-of-tree secret is **printed** (`rc=0 stdout="NOT-FOR-THE-AGENT-…"`) | | E | Swallow the probe result in `NoteAuthResult` | `State:applied AuthFailed:false` — the July-18 shape | **A finding from red-proof D worth keeping:** deleting the charset guard *alone* does **not** open a hole — `readlink -f` plus the prefix assertion still catch it, so the naive single-guard red-proof **passed vacuously**. The layering is real; the isolating proof had to remove both. A red-proof that passes is not automatically reassuring. The wrapper is tested as the artifact it is — executed under real `bash`. The two cases needing a writable allowlisted dir run a **copy with only the prefix constant relocated** (and re-assert that traversal is still refused in the copy), rather than `t.Skip` — a skipped test proves nothing. ## 5. STOP-1 — felhom-pve, before → after | | before (P3) | after | |---|---|---| | agent | 0.90.0 · `18c48272…` | **0.91.2** · `34d309be…` | | wrapper | `2888f2ea…` (the in-place July-18 hotfix) | `104db0a4…` (repo) | | sudoers | `174125ef…` | new, +1 line | | capabilities | 63 | **64/64 ok, 0 degraded, 0 inactive** | | guest 9201 | running | running (untouched) | | `wg-felhom` | handshake `…673` | fresh `…793` | Gates run before install: `bash -n` on the wrapper, `visudo -cf` on the staged sudoers, `visudo -c` on the whole tree **after** install, and the staged binary's own `--version`. **Rollback set retained on the host:** `/usr/local/sbin/felhom-pbs-apply.bak-20260721-101635` (and the older `.bak-20260718-preR39`), `/usr/local/bin/felhom-agent.bak-2026072{1-101635,1-101905,…}`, `/root/felhom-agent.sudoers.bak-20260721-101635`. **The two live proofs that matter:** ``` # the NON-ROOT agent can finally read its own credential sudo -u felhom-agent sudo -n felhom-pbs-apply read felhom-pbs /etc/pve/priv/storage rc=0 secret_len=37 stderr=(empty) # and the probe actually runs (debug enabled briefly, then restored to info) level=DEBUG msg="pbs: credential probe OK" storage=felhom-pbs datastore=felhom-offsite ``` Report verified hub-side: `agent_version 0.91.1→0.91.2`, `wrapper_sha256 104db0a4401f65bbc476e82bfb1796433bcb36f8f8cce69efb3bb5c40fcb16b3`. ## 6. A defect I shipped and caught mid-STOP-1 **v0.91.0 shipped the auth-honesty leg INERT.** I built the `AuthSink` seam and the `NoteAuthResult` consumer, and `main.go` never called `SetAuthSink`. The reporter deliberately skips probing with no sink attached, so there was no probe, no `auth_failed`, no self-heal — and **nothing failed**, because every unit test injected the sink directly. Caught during STOP-1 by checking the wiring instead of trusting it. **This is the same class as the controller v0.154.0 defect the day before: a table test over a seam proves the seam, not the caller.** Fixed in v0.91.1; the published 0.91.0 artifact was **superseded, not overwritten** (a published version stays immutable). **v0.91.2** then made a healthy probe observable at Debug — it previously logged only on failure, so "no `auth_failed`" was indistinguishable from "never probed". That silence is exactly how v0.91.0 shipped inert unnoticed. ## 7. For the operator — STOP-2 and STOP-3 **STOP-2 (one click):** press **Re-issue PBS credentials** for the demo customer. Expected chain: fresh secret row → `secret_generation` **0 → 1** in the descriptor (the current live descriptor has no such key at all) → poke → agent re-applies with **no short-circuit** → fresh secret consumed (`consumed_at` stamped) → reconcile rc-0 → probe 200 → tier `active`. **The July-18 negative — the same click doing nothing — is the historical red-proof this run is measured against.** **STOP-3 (manifest save):** | field | value | |---|---| | Agent version | `0.91.2` | | Agent sha256 | `34d309be429473f3f0ab34e3185e17b22463a341b30bf46e162306bff4aec22a` | | **PBS wrapper sha256** (new field) | `104db0a4401f65bbc476e82bfb1796433bcb36f8f8cce69efb3bb5c40fcb16b3` | | MinAgent | `0.91.2` — safe now: **P4 shows one enrolled host and it already runs 0.91.2** | Superseded, do not vouch: **0.91.0** (inert leg) and **0.91.1**. `0.90.1` correctly remains **404**. ## 8. Residual - **R-50b(b)/(c) NOT solved** — the wrapper is still fetched from `raw/branch/main`, unversioned and unpinned. This release makes drift *visible*; it does not fix the channel. - The **0440 sudoers file is not agent-readable**, so its drift stays invisible — recorded, not solved. - R-39's own residual: the verify loop reads via the wrapper now, but a host whose wrapper predates v0.91.0 has no `read` verb and degrades to the old skip (loudly, via the `pbsdr-read` capability). ## 9. Observations - **The hub's build script prints `kubectl set image` as its deploy hint**, which contradicts the GitOps rule in `felhom.eu/CLAUDE.md`. It is a trap for exactly the kind of session that trusts tool output over the runbook. Worth fixing in `build.sh`. - **The July-18 hotfixed wrapper hashed `2888f2ea…`, matching no commit anyone could name.** That is R-50b in one line, and it is why the manifest field earns its place. - `pvesm config` does not exist (it is `pvesm status` / `/etc/pve/storage.cfg`); PVE's per-storage status endpoint returns `active: 0` rather than erroring — both worth knowing before designing against them.