hub v0.99.0 — the hub can see whether the operator can get in (R-260); G-1 gate closes, R-247 closes

oobDegraded tested five things and the sixth never arrived.

The agent has emitted `operator_key_configured` on every heartbeat since v0.72.0 — the SAME version
that introduced the `oob` stanza carrying it — and store.HostOOBRow mirrored five of the agent's
eight OOB fields. With no field for it, encoding/json discarded the fact on arrival, so a box with
felhom-sshd active, reachable, a valid config and a configured peer reported `ok` with NO OPERATOR
KEY INSTALLED AT ALL. Not a wrong answer: an answer to a question nobody was asking.
`operator_peer_configured`, which the hub did read, only says the peer IP is in desired-state — that
OOB is MEANT to work, not that entry is possible.

Now decoded: operator_key_configured, plus wg_handshake_age_s and healed_at. The last two ride the
ALERT TEXT and are deliberately NOT in the predicate — widening a check beyond the fact that is now
arriving is how a check stops being read.

SCENARIO F, decided on a measurement rather than a preference. operator_key_configured decodes as a
POINTER: nil = the agent never said, reported distinctly and never as ok. The version gate was
rejected because the field and its stanza shipped in the SAME agent version (v0.72.0), so a stanza
without the field cannot come from any released agent; the fleet is 0.113.0/0.127.0 and the vouched
floor is 0.127.0. Handled explicitly anyway and pinned, because "cannot happen" is a claim this
project has been burned by.

THE MESSAGE NAMES THE FAULT. oobDegradedReason is the single source for both predicate and text, so
the alert can never name a different fault from the one that fired. The old form derived it
separately and had a vocabulary of two — unreachable, or config invalid — with no way to say the key
is missing. The operator reads this at 07:00.

TESTS DRIVE THE DECODE BOUNDARY. Every hub OOB test before this built a HostOOBRow by hand, and a
test written that way CANNOT SEE A FIELD THAT NEVER DECODES — which is how this held a green suite
for five weeks. The pre-existing fixture oobReport() also omitted the field, so those scenarios ran
against a report shape no released agent produces (same family as R-262). Both fixed.

Red-proofs, 8 expected outcomes and 0 wrong, each with the mutation asserted applied: dropping the
field returns the false ok; an unconditional check alerts a healthy box; unknown-as-ok restores the
silent pass.

G-1 CLOSED — scripts/wire_contract_gate.py shipped as ranked, built BEFORE the fixes and seen
failing on 40 fields (documentation/tests/wire-contract-gate-2026-08-08/BEFORE.md). Two instrument
defects the control caught first: a substring false negative (grep -F healed_at matched
privsep_healed_at) and treating dr_recipe as wholly opaque when its top-level sections ARE decoded
through an allow-list that already cost offsite_restic (R-122).

The prompt for this session said "465 emitted tags, eight unreachable". Checked against the repo:
R-260 said "at least eight DECISION-BEARING facts", never eight tags. The real count is 40.

R-260 CLOSED (class gated, sharpest instance fixed). R-247 CLOSED (controller v0.209.0). R-264
MINTED and OPEN — the 21 facts with no consumer, allowlisted with reasons so that gating the class
could not be mistaken for deciding them. Still open and named: R-246, R-255..R-259, R-261..R-263,
and C7's test-comment half.

Capability map checked: it claims OOB access is implemented, never monitored, so no row was untrue;
what was untrue sat one layer down and the row now records it.

repo_gates --fast: all 8 OK. go build/vet/test green in hub, run separately from this commit.
This commit is contained in:
2026-08-08 08:47:02 +02:00
parent 560f0d4451
commit b080ecf411
13 changed files with 836 additions and 58 deletions
+45
View File
@@ -1,3 +1,48 @@
## v0.99.0 — the hub can finally see whether the operator can get in (2026-08-08, R-260 / G-1)
**`oobDegraded` tested five things and the sixth never arrived.** The agent has emitted
`operator_key_configured` on every heartbeat since v0.72.0 — the same version that introduced the
`oob` stanza carrying it — and `store.HostOOBRow` mirrored five of the agent's eight OOB fields. With
no field for it, `encoding/json` discarded the fact on arrival, so a box with felhom-sshd **active,
reachable, a valid config and a configured peer** reported `ok` **with no operator key installed at
all**. That is not a wrong answer; it is an answer to a question nobody was asking.
`operator_peer_configured`, which the hub did read, is not a substitute: it says the peer IP is in
desired-state — that OOB is MEANT to work — not that entry is possible.
**Decoded now:** `operator_key_configured`, plus `wg_handshake_age_s` and `healed_at`. The last two
are carried for the ALERT TEXT and are deliberately NOT in the predicate — a check that starts
failing for reasons nobody asked for is how a check stops being read.
**`operator_key_configured` decodes as a POINTER.** nil = the agent never said, which is not the same
as saying no. Absence must never read as "the key is installed" — that is the same defect returning
through the version door, and this project has watched an absence read as a fact four times. A stanza
without the field cannot come from any released agent (field and stanza shipped together in v0.72.0,
and the vouched floor is far above it), so it is handled explicitly rather than assumed impossible,
and reported DISTINCTLY from a known-missing key.
**The message names the fault.** `oobDegradedReason` is now the single source for both the predicate
and the alert, so the text can never name a different fault from the one that fired. The old form
derived the reason separately and had a vocabulary of exactly two — "unreachable" or "config
invalid" — with no way to say "the key is missing". The operator reads this at 07:00 and needs to
know which of five things is wrong.
**Tests drive the DECODE BOUNDARY, not a hand-built struct.** Every hub OOB test before this one
constructed a `HostOOBRow` itself and asked what the checker did with it — a test written that way
cannot see a field that never decodes, which is exactly why this survived five weeks under a green
suite. `host_oob_decode_test.go` feeds raw report JSON; `TestHostOOB_MissingOperatorKey_EndToEnd` and
`TestHostOOB_NoKeyField_IsNotSilentlyOK_EndToEnd` drive the whole path from JSON to emitted event.
**The existing fixture was part of the problem and was fixed too:** `oobReport()` omitted
`operator_key_configured` entirely, so every pre-existing scenario ran against a report shape **no
released agent produces**. Same family as R-262.
Red-proofs, each with the mutation asserted applied: dropping the field from the decoder returns the
false `ok`; making the check unconditional alerts a healthy box; treating unknown as ok restores the
silent pass.
**Gate:** `felhom.eu/scripts/wire_contract_gate.py` (G-1), `--fast`, registered in `repo_gates.py`.
## v0.98.0 — drop the retained recovery package when the box says its set-aside history is gone (2026-08-07, R-241)
The hub half of the controller's abandonment countdown, and the **only** reason the hub was touched