v0.126.0: a fetch failure is not a wrong recovery code (R-224)
gates / gates (push) Successful in 7s

A hub the agent could not reach was reported to the customer as a bad recovery
code. Measured live 2026-08-05 (CAMPAIGN-11 F3): hub firewalled off, a CORRECT
current code, and the customer told it did not open their package — in 0.0556s
against ~1.0s for a real unseal. No unseal was attempted.

The discriminator existed here and this boundary threw it away: recover.go
fails at four distinguishable points and the local-api handler had cases for
two, with a default answering 'the recovery code did not open the sealed
bundle, OR the bundle could not be fetched'.

escrow.ErrBundleFetch now joins the fetch leg and the handler routes it to 502
with its own words — the code was NOT used. 502 not 4xx: the request was not
bad, an upstream dependency failed. Four situations, four statuses: 502 fetch /
400 fetched-and-refused / 404 no bundle / 409 predates the field. The
controller classifies on the STATUS and never parses the sentence.

A GREEN TEST NAMED THIS DEFECT AND DID NOT PREVENT IT.
TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct has said since v0.125.0
that the operator must not be sent to re-read their code because the hub was
unreachable — and passed throughout, because it asserted this package's error
STRING one layer below the merge, and a string is not something a caller can
branch on. Re-pointed at the sentinel, with a consequence-level twin asserting
the status.

Red-proofs: removing the %w join fails the sentinel test; deleting the handler
case makes fetch and wrong-code both answer 400 with the wrong-code sentence.

29 packages ok, vet clean, agent gates OK.
This commit is contained in:
2026-08-06 07:55:15 +02:00
parent 0404f60e6a
commit a2e914f683
6 changed files with 304 additions and 51 deletions
+39 -43
View File
@@ -1,57 +1,53 @@
# REPORT — agent v0.125.0: open the sealed bundle, return one field (2026-08-04, R-199 links 78)
# REPORT — felhom-agent v0.126.0: a fetch failure is not a wrong recovery code (R-224)
**Released and deployed.** Published sha256 `f7d8339b53d92a6c45be7eaf189469a041b6b00b758a64511c0479beae7016b3`,
**verified by independent download** before install; running on demo-felhom (`felhom-agent --version`
`0.125.0`, `systemctl is-active``active`). **NOT vouched** — vouching stays the operator's act.
**Scope: this repo's half of R-224.** The controller half ships as felhom-controller v0.202.0.
## What shipped
## Why the agent changed at all
- `internal/escrow/recover.go``OffsiteKeyRecoverer`: fetch → unseal → extract, with **distinct**
errors for "no blob", "the bundle predates the field", and "the code did not open it".
- `internal/hub/client.go``FetchIdentityEscrow` (link 6's first client, ever).
- `internal/localapi/escrow_recover.go` + route registration — link 7's first production caller.
- `cmd/felhom-agent/main.go` — the production wiring, and the §8.6 selftest message fix.
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.**
## The result
## What changed
**Proven live on demo-felhom, 2026-08-04:** the recovered offsite repository password's sha256 equals
the one on the box's disk, and equals the hash the hub had independently recorded. A deliberately wrong
code five minutes earlier produced
`escrow: the recovery code did not unwrap the identity escrow … exit status 1` and wrote nothing —
which also proves the fetch and the unseal ran independently of the success.
| 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 |
**R persisted nowhere**, searched rather than claimed: 0 agent-journal lines, 0 controller-log lines,
0 files under `/tmp`, `/var/tmp`, `/var/lib/felhom-agent`, `/root`, 0 leftover `felhom-idesc-*` staging
dirs — with a **positive control** (a planted copy found, then removed and not found) so the sweep is a
measurement.
**Four statuses:** `502` fetch failed (the code was **not used**) · `400` fetched and refused ·
`404` no bundle · `409` bundle predates the field.
## Tests
## The finding this turned up
`go build ./... && go vet ./... && go test ./...` — rc=0; `agent_gates.py --fast` — OK.
New: `internal/escrow/recover_test.go` (real `age` crypto) and
`cmd/felhom-agent/escrow_recover_wiring_test.go` (AST).
**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.
**Red-proofs, both observed failing:**
- Returning `bundle.PBSToken` instead of `ResticRepoPassword` → *"the recovered key is not the sealed
repository password (len 24 vs 64)"*. That is the mutation that mattered — every field of that bundle
is a non-empty string that looks like a secret.
- `_ = escrowRecoverer` with the `Options.EscrowRecovery` field dropped → the AST test fails with
*"localapi.Options … carries no EscrowRecovery field"*.
## Green gate
**One red-proof did NOT work on the first attempt and is recorded, not quietly redone:** the R-leak
mutation wrote R into the walked directory and the test still passed, because a later failing call
overwrote the leak file with a different code while the test scanned only for the first. **The test was
wrong.** It now asserts the TMPDIR tree is EMPTY (nothing in the test creates anything there), with the
content scan kept as defence in depth. Re-run: FAIL, *"the unseal left 1 file(s) behind under TMPDIR"*.
A second mutation (commenting the wiring line) failed to compile, which is not a red-proof either; the
compiling version is above.
`go build ./...` clean · `go vet ./...` clean · `go test ./...`**29 packages ok** ·
`python3 scripts/agent_gates.py --fast` → all gates OK.
## Scope
**Red-proofs, each demonstrated failing then restored:**
Links 68 only. **Nothing installs a recovered password, reopens a repository, or restores a file**
R-200's remaining half and R-201.
| Mutation | 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** |
## Observation
## Not changed
`ageBinary` is the hardcoded `/usr/bin/age` with no config override. Present on demo-felhom (1.2.1); a
host that installs `age` elsewhere would fail the unseal at the last step with no configuration escape.
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.