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
+79 -3
View File
@@ -94,10 +94,86 @@ GENERIC = {
"created_at", "updated_at", "started_at", "timestamp", "time", "percent", "used_fraction",
}
# A tag that is emitted and deliberately NOT consumed. Every entry is a claim someone must be able
# to re-check, so each carries a reason. A quiet exclusion would be a dropped field with paperwork.
# A tag that is emitted and NOT consumed, with the reason it is acceptable. Every entry is a claim
# someone must be able to re-check later, so none of them is bare. A quiet exclusion would be a
# dropped field with paperwork, which is worse than the defect.
#
# TWO KINDS OF ENTRY, and the difference is deliberate:
# * "redundant" — the hub already decodes something that answers the same question. No consumer
# is wanted; the entry is the end of the matter.
# * "R-264" — a fact with no consumer that ARGUABLY should have one. The entry does NOT
# close the question; it records it against an OPEN register row so that
# allowlisting cannot be mistaken for deciding. R-260 is closed by the gate plus
# the operator-access fix; the leftover appetite is R-264.
#
# Keyed by (wire label, dotted emit path).
ALLOWLIST = {}
_AH = "agent -> hub (POST /host-report)"
_CH = "controller -> hub (POST /report)"
_REDUNDANT_HOST_METRICS = (
"redundant: the hub decodes host.cpu_percent / memory_percent / disk_percent from the same "
"stanza and every host-health threshold is expressed on those. The absolute figure answers no "
"question the hub asks.")
_R264 = (
"no consumer today, and one is arguably owed — recorded against R-264 (OPEN) rather than "
"decided here. Allowlisting is not deciding.")
ALLOWLIST = {
# ---- redundant: the hub already decodes an equivalent ----
(_AH, "host.cpu_temp_c"): _REDUNDANT_HOST_METRICS,
(_AH, "host.loadavg"): _REDUNDANT_HOST_METRICS,
(_AH, "host.memory_total_bytes"): _REDUNDANT_HOST_METRICS,
(_AH, "host.memory_used_bytes"): _REDUNDANT_HOST_METRICS,
(_AH, "host.uptime_seconds"): _REDUNDANT_HOST_METRICS,
(_CH, "system.load_avg_1"): _REDUNDANT_HOST_METRICS,
(_CH, "system.load_avg_5"): _REDUNDANT_HOST_METRICS,
(_CH, "system.load_avg_15"): _REDUNDANT_HOST_METRICS,
(_CH, "system.memory_total_mb"): _REDUNDANT_HOST_METRICS,
(_CH, "system.memory_used_mb"): _REDUNDANT_HOST_METRICS,
(_CH, "system.temperature_celsius"): _REDUNDANT_HOST_METRICS,
(_CH, "system.uptime_seconds"): _REDUNDANT_HOST_METRICS,
(_AH, "guests.spec.disk_bytes"): (
"redundant: guest SIZING is hub-owned intent (the manifest), not box reality. The hub "
"decodes vmid/name/status/controller_version from the guest list and nothing more."),
(_AH, "guests.spec.memory_bytes"): (
"redundant: as guests.spec.disk_bytes — sizing is hub-owned intent, not mirrored reality."),
(_AH, "storage_targets.smart.model_name"): (
"redundant for a verdict: the hub decodes smart.health plus every counter it bands on. The "
"model string is a display label with no threshold attached to it."),
(_AH, "wireguard.last_handshake_age_s"): (
"redundant: hub-side wgsync reconciles peers from its own state, and the OOB path's own "
"wg_handshake_age_s IS now decoded (into HostOOBRow, for the alert text)."),
# ---- no consumer, and one is arguably owed: R-264, OPEN ----
(_AH, "guest_net"): _R264 + " The R-54 guest-network watchdog stanza (whole object).",
(_AH, "guest_net.checked_at"): _R264,
(_AH, "guest_net.guests.has_route"): _R264,
(_AH, "guest_net.guests.dhclient_alive"): _R264,
(_AH, "guest_net.guests.heal_succeeded"): _R264,
(_AH, "guest_net.guests.heals_last_hour"): _R264,
(_AH, "guest_net.guests.last_heal_at"): _R264,
(_AH, "guest_net.guests.damped"): _R264,
(_AH, "selfupdate_pending"): _R264 + (
" NOTE: the agent's own comment beside this field claimed 'the hub reads an absent field as "
"pending=false, the correct default'. The hub had no field at all, so it read nothing "
"either way. The comment was corrected in the same change as this entry."),
(_AH, "selfupdate_pending_version"): _R264,
(_AH, "mgmt_plane.healed_recently"): _R264 + (
" The hub DOES alarm on mgmt_plane.privsep_healed_at, which is the timestamp beside this "
"boolean, so the recurring-clobber signal is not lost — only this flag is."),
(_AH, "pbs_dr.applied_at"): _R264,
(_AH, "restore_tests.mount_parity"): _R264 + (
" R-262: the hub's own comment claims this contract is mirrored field-for-field and that a "
"key-set test guards drift; it is two fields short and the fixture omits the same two."),
(_AH, "restore_tests.mount_inventory"): _R264 + " R-262, as mount_parity.",
(_CH, "config_hash"): _R264,
(_CH, "reporting_disabled"): _R264,
(_CH, "stacks"): _R264 + (
" The whole per-stack report object; the hub's app view is built from app_telemetry."),
(_CH, "storage.migrated_to"): _R264,
(_CH, "backup.last_db_dump"): _R264,
(_CH, "backup.last_integrity_check"): _R264,
}
# A node whose IMMEDIATE CHILDREN are still checked but whose DEEPER descendants are not, because the
# receiver passes the subtree through without decoding it. Keyed by (wire label, dotted path).