diff --git a/documentation/tests/wire-contract-gate-2026-08-08/BEFORE.md b/documentation/tests/wire-contract-gate-2026-08-08/BEFORE.md new file mode 100644 index 0000000..72571e0 --- /dev/null +++ b/documentation/tests/wire-contract-gate-2026-08-08/BEFORE.md @@ -0,0 +1,77 @@ +# The wire-contract gate, run against the tree at b7fb211 on 2026-08-08 + +Deliverable 1 of the G-1/R-260 session: the gate FAILING, before any field was fixed. +Captured verbatim. If this had been green, the gate would not work and that would be the finding. + +``` +wire-contract gate — 210 tag(s) checked across 3 declared wire(s); 51 skipped (generic / opaque / allowlisted) + + agent -> hub (POST /host-report) + the RECEIVER (hub) contains no occurrence of: + applied_at (emitted at pbs_dr.applied_at) + checked_at (emitted at guest_net.checked_at) + cpu_temp_c (emitted at host.cpu_temp_c) + damped (emitted at guest_net.guests.damped) + dhclient_alive (emitted at guest_net.guests.dhclient_alive) + disk_bytes (emitted at guests.spec.disk_bytes) + guest_net (emitted at guest_net) + has_route (emitted at guest_net.guests.has_route) + heal_succeeded (emitted at guest_net.guests.heal_succeeded) + healed_at (emitted at oob.healed_at) + healed_recently (emitted at mgmt_plane.healed_recently) + heals_last_hour (emitted at guest_net.guests.heals_last_hour) + last_handshake_age_s (emitted at wireguard.last_handshake_age_s) + last_heal_at (emitted at guest_net.guests.last_heal_at) + loadavg (emitted at host.loadavg) + memory_bytes (emitted at guests.spec.memory_bytes) + memory_total_bytes (emitted at host.memory_total_bytes) + memory_used_bytes (emitted at host.memory_used_bytes) + model_name (emitted at storage_targets.smart.model_name) + mount_inventory (emitted at restore_tests.mount_inventory) + mount_parity (emitted at restore_tests.mount_parity) + operator_key_configured (emitted at oob.operator_key_configured) + selfupdate_pending (emitted at selfupdate_pending) + selfupdate_pending_version (emitted at selfupdate_pending_version) + uptime_seconds (emitted at host.uptime_seconds) + wg_handshake_age_s (emitted at oob.wg_handshake_age_s) + + controller -> hub (POST /report) + the RECEIVER (hub) contains no occurrence of: + config_hash (emitted at config_hash) + last_db_dump (emitted at backup.last_db_dump) + last_integrity_check (emitted at backup.last_integrity_check) + load_avg_1 (emitted at system.load_avg_1) + load_avg_15 (emitted at system.load_avg_15) + load_avg_5 (emitted at system.load_avg_5) + memory_total_mb (emitted at system.memory_total_mb) + memory_used_mb (emitted at system.memory_used_mb) + migrated_to (emitted at storage.migrated_to) + reporting_disabled (emitted at reporting_disabled) + stacks (emitted at stacks) + temperature_celsius (emitted at system.temperature_celsius) + uptime_seconds (emitted at system.uptime_seconds) + + hub -> controller (report ACK, `escrow` object) + the RECEIVER (controller) contains no occurrence of: + escrow_stale (emitted at escrow_stale) + +WIRE-CONTRACT GATE FAILED: 40 emitted field(s) cannot be received. +A tag whose literal string occurs nowhere in the receiving repo cannot be decoded +by any struct, named or anonymous — encoding/json discards it on arrival. +Fix: model the field on the receiving side and say what consults it — or, if it is +deliberately not consumed, add it to ALLOWLIST WITH A REASON. Never a quiet skip. + +Blind spots (a green is not full coverage — see the module docstring): + generic tag names are not checked; reachability of a NAME is not use of a VALUE; + only the declared ROOTS are covered (hub desired-state and the agent local API + are NOT). +exit=1 +``` + +## Self-test (a planted unreachable tag on a real root) +``` + planted an unreachable tag on agent HostReport -> gate exit 1, planted tag convicted: True + unplanted tree -> gate exit 1 +SELFTEST OK — the gate convicts a planted unreachable tag and the plant is the only difference. +exit=0 +``` diff --git a/scripts/repo_gates.py b/scripts/repo_gates.py index 3503f62..97167f7 100644 --- a/scripts/repo_gates.py +++ b/scripts/repo_gates.py @@ -68,6 +68,7 @@ GATES = [ ("reuse-refs", os.path.join(SCRIPTS, "reuse_refs_check.py"), [ROOT], True), ("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True), ("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True), + ("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True), ] VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} diff --git a/scripts/wire_contract_gate.py b/scripts/wire_contract_gate.py new file mode 100644 index 0000000..235add5 --- /dev/null +++ b/scripts/wire_contract_gate.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""wire_contract_gate.py — a field one side emits and the other cannot receive is a DEFECT (G-1). + +Run from the repo root: python3 scripts/wire_contract_gate.py +Exit 0 clean · 1 convicted (an emitted field has no receiver) · 2 inconclusive (a root or a sibling +clone could not be resolved — never a silent pass). + +Self-test: python3 scripts/wire_contract_gate.py --selftest + Plants an unreachable tag on a real root and asserts this gate convicts it, then asserts a clean + run is clean. A gate nobody has watched fail has not been shown to work. + +WHY THIS EXISTS. + +Campaign 12 (2026-08-08, `documentation/audits/CAMPAIGN-12-class-sweep-2026-08-08.md`) swept seven +recurring defect classes. This one — "a field one side sends and the other silently drops" — had +already been found by hand three times, most recently R-247: the hub sends `escrow_stale` in the +report ACK, the controller's `report.EscrowStatus` has no field for it, `encoding/json` discards it, +and the box then told a customer something false about their own recovery package. + +The sweep found more of the same, and Part 4 ranked a gate for this class **first of eight +candidates** — cheapest, definitive, and `--fast`-eligible. The sharpest instance it found: + + the agent emits `operator_key_configured` on every heartbeat (agent hub/report.go, commented + "operator authorized_key installed"); the hub's OOB decoder had no field for it; so + `oobDegraded` — the check that answers "can the operator get into this box right now" — + returned ok for a box with NO OPERATOR KEY INSTALLED. + +The agent knew and said so. The hub threw it away on arrival. + +THE TEST, AND WHY IT IS DEFINITIVE. + +For every json tag reachable from a declared wire ROOT, ask whether that literal tag string occurs +ANYWHERE in the receiving repo's production Go or templates. **A tag that occurs nowhere cannot be +decoded by any struct, named or anonymous.** That last clause is the whole reason this test is used +instead of comparing struct to struct: the campaign's first attempt paired types by shape and +false-positived badly, because the hub decodes one report through SEVERAL ad-hoc anonymous structs. +A string test cannot be fooled that way. + +⚠ WHAT THIS GATE DOES NOT SEE — stated here so nobody reads a green as coverage. Campaign 12's own +C1 guard turned out blind to one of the three shapes it was written for; the lesson taken is that a +gate must publish its holes. + + 1. GENERIC TAG NAMES ARE SKIPPED. `name`, `type`, `state`, `status` and the like occur in every + repo for unrelated reasons, so the string test cannot say anything about them. They are listed + in GENERIC below and are NOT checked — a genuine drop of a generically-named field is MISSED. + This makes the gate conservative: it under-reports and does not over-report. + 2. IT PROVES REACHABILITY OF A NAME, NOT THAT ANYTHING ACTS ON THE VALUE. A tag mentioned once in + a struct nobody consults passes. It answers "can this be decoded at all", not "is it used". + 3. ROOTS ARE DECLARED, NOT DISCOVERED. Only the wires in ROOTS are covered. The hub's + desired-state is served as raw stored JSON (`host.DesiredJSON`) with no typed emitter to walk, + and the agent's local API has no single root type — NEITHER IS COVERED. + 4. IT READS SOURCE, NOT TRAFFIC. A field populated only at runtime under a key built by string + concatenation is invisible. + 5. TEST FILES AND testdata/ ARE EXCLUDED ON THE RECEIVING SIDE, deliberately. A tag present only + in a fixture is not decodable by production — and R-262 is exactly that: the hub's + host-report golden carries `cpu_temp_c` and `loadavg`, which no hub struct reads. +""" +import os +import re +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +WORKSPACE = os.path.dirname(ROOT) + +REPOS = { + "hub": os.path.join(ROOT, "hub"), + "controller": os.path.join(WORKSPACE, "felhom-controller", "controller"), + "agent": os.path.join(WORKSPACE, "felhom-agent"), +} + +# (label, emitter repo, package dir relative to that repo, root type, receiver repo) +# Each row is a CLAIM that this type's json is put on this wire. An unresolvable root is +# INCONCLUSIVE, never a pass. +ROOTS = [ + ("agent -> hub (POST /host-report)", + "agent", "internal/hub", "HostReport", "hub"), + ("controller -> hub (POST /report)", + "controller", "internal/report", "Report", "hub"), + ("hub -> controller (report ACK, `escrow` object)", + "hub", "internal/store", "EscrowStatus", "controller"), +] + +# Tag names whose literal string carries no information in a repo-wide search. NOT CHECKED. +# Listed rather than silently skipped: each one is a hole. +GENERIC = { + "name", "type", "state", "status", "error", "id", "path", "size_bytes", "enabled", + "message", "value", "version", "code", "label", "mode", "total_bytes", "used_bytes", + "avail_bytes", "reason", "detail", "where", "vmid", "target", "content", "apps", "app", + "phase", "warn", "count", "host", "guests", "kind", "url", "port", "user", "role", + "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. +# Keyed by (wire label, dotted emit path). +ALLOWLIST = {} + +# 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). +# +# ⚠ THE DEPTH IS THE WHOLE POINT AND IT IS NOT COSMETIC. The hub stores each DR-recipe half as +# json.RawMessage and re-emits nested shapes verbatim — so the LEAVES genuinely are not on this wire +# in the decoded sense. But the TOP-LEVEL SECTION KEYS of each half are decoded, by +# `hostHalfShape` / `appHalfShape` in hub/internal/store/dr_recipe.go, and those two structs are +# ALLOW-LISTS: a section an emitter adds is silently discarded until it is named in BOTH the shape +# struct and AssembledRecipe. That has already cost one shipped section — `offsite_restic` was +# emitted by the controller from fork-4, stored intact, and never appeared in a delivered recipe +# (R-122). Treating `dr_recipe` as wholly opaque would put exactly that defect back outside this +# gate's reach. +OPAQUE_BELOW = { + ("agent -> hub (POST /host-report)", "dr_recipe"): ( + "the hub stores the agent's half verbatim (`DRRecipe json.RawMessage`) and re-emits nested " + "shapes untouched; its TOP-LEVEL sections are decoded by hostHalfShape and ARE checked"), + ("controller -> hub (POST /report)", "dr_recipe"): ( + "the hub stores the controller's half verbatim (`SaveDRRecipeAppHalf`) and re-emits nested " + "shapes untouched; its TOP-LEVEL sections are decoded by appHalfShape and ARE checked"), +} + +STRUCT_RE = re.compile(r"^type\s+(\w+)\s+struct\s*\{", re.M) +FIELD_RE = re.compile( + r"^\s*(?:(\w+)\s+)?([\[\]\*\w\.]+)\s+`[^`]*json:\"([^\"]*)\"[^`]*`") +INLINE_STRUCT_RE = re.compile(r"^\s*(\w+)\s+(\*?)struct\s*\{") + + +def die(msg, code=2): + print(msg, file=sys.stderr) + sys.exit(code) + + +def go_files(root, include_tests=False): + out = [] + for dp, dn, fn in os.walk(root): + dn[:] = [d for d in dn if d not in (".git", "vendor", "node_modules")] + for f in fn: + if not f.endswith(".go"): + continue + if not include_tests and f.endswith("_test.go"): + continue + out.append(os.path.join(dp, f)) + return out + + +def parse_structs(path): + """name -> (body, dir). Anonymous inline structs are inlined into the parent's field list.""" + src = open(path, encoding="utf-8", errors="replace").read() + out = {} + for m in STRUCT_RE.finditer(src): + i, depth = m.end(), 1 + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + out[m.group(1)] = src[m.end():i - 1] + return out + + +def build_index(repo_root): + """(dir, name) -> body, plus name -> [(dir, body)] for unqualified fallback.""" + by_dir, by_name = {}, {} + for p in go_files(repo_root): + d = os.path.relpath(os.path.dirname(p), repo_root) + for name, body in parse_structs(p).items(): + by_dir[(d, name)] = body + by_name.setdefault(name, []).append((d, body)) + return by_dir, by_name + + +def split_fields(body): + """Yield (json_tag, go_type, inline_body_or_None) for one struct body, handling nested + anonymous structs by capturing their body.""" + lines = body.split("\n") + i = 0 + while i < len(lines): + line = lines[i] + m = INLINE_STRUCT_RE.match(line) + if m: + # capture the inline struct body, then read the tag off its closing line + depth, j, buf = 1, i + 1, [] + while j < len(lines) and depth: + depth += line_delta(lines[j]) + if depth: + buf.append(lines[j]) + j += 1 + closing = lines[j - 1] if j - 1 < len(lines) else "" + tm = re.search(r'json:"([^"]*)"', closing) + tag = tm.group(1).split(",")[0] if tm else None + yield (tag, "INLINE", "\n".join(buf)) + i = j + continue + fm = FIELD_RE.match(line) + if fm: + tag = fm.group(3).split(",")[0] + if tag and tag != "-": + yield (tag, fm.group(2), None) + i += 1 + + +def line_delta(line): + return line.count("{") - line.count("}") + + +def base_type(t): + return t.lstrip("*[]").split(".")[-1] + + +def qualifier(t): + parts = t.lstrip("*[]").split(".") + return parts[0] if len(parts) > 1 else None + + +def walk(by_dir, by_name, start_dir, start_type, seen=None, prefix=""): + """Transitively collect (tag, dotted_path) reachable from a root type.""" + if seen is None: + seen = set() + key = (start_dir, start_type) + if key in seen: + return [] + seen.add(key) + body = by_dir.get(key) + if body is None: + cands = by_name.get(start_type, []) + if not cands: + return [] + body = cands[0][1] + start_dir = cands[0][0] + out = [] + for tag, gotype, inline in split_fields(body): + if tag is None: + continue + dotted = (prefix + "." + tag) if prefix else tag + out.append((tag, dotted)) + # NOTE: the walk descends everywhere; OPAQUE_BELOW filtering happens in run(), so that the + # immediate children of an opaque node are still collected and checked. See OPAQUE_BELOW. + if inline is not None: + for t2, g2, i2 in split_fields(inline): + if t2 is None: + continue + out.append((t2, dotted + "." + t2)) + bt = base_type(g2) + if i2 is None and (start_dir, bt) in by_dir or bt in by_name: + out += walk(by_dir, by_name, start_dir, bt, seen, dotted + "." + t2) + continue + bt = base_type(gotype) + if bt in ("string", "int", "int64", "bool", "float64", "byte", "any", + "RawMessage", "Time", "interface{}", "uint64", "int32", "uint32"): + continue + q = qualifier(gotype) + nd = start_dir + if q: + for (d, n) in by_dir: + if n == bt and os.path.basename(d) == q: + nd = d + break + if (nd, bt) in by_dir or bt in by_name: + out += walk(by_dir, by_name, nd, bt, seen, dotted) + return out + + +def receiver_has(tag, repo_root): + """Whole-token match, NOT substring. + + A plain `grep -F healed_at` also matches `privsep_healed_at`, so a genuinely dropped field is + reported as received. That false NEGATIVE was found by running this gate's control (R-260's + list) and noticing one known field missing from the output — which is the whole argument for + making a gate re-find the instances it was written for before trusting it. + """ + pattern = r"(^|[^A-Za-z0-9_])" + re.escape(tag) + r"([^A-Za-z0-9_]|$)" + r = subprocess.run( + ["grep", "-rnE", "--include=*.go", "--include=*.html", pattern, repo_root], + capture_output=True, text=True) + for line in r.stdout.splitlines(): + if "_test.go" in line or "/testdata/" in line: + continue + return True + return False + + +def run(root_override=None, quiet=False): + """Returns (exit_code, convictions).""" + repos = dict(REPOS) + if root_override: + repos.update(root_override) + for label, path in repos.items(): + if not os.path.isdir(path): + die("wire-contract gate INCONCLUSIVE: sibling clone %r not found at %s\n" + " This gate compares two repositories; it cannot pass without both." % (label, path)) + + indexes = {k: build_index(v) for k, v in repos.items()} + convictions = [] + checked = skipped = 0 + + for label, emitter, pkgdir, rootname, receiver in ROOTS: + by_dir, by_name = indexes[emitter] + if (pkgdir, rootname) not in by_dir: + die("wire-contract gate INCONCLUSIVE: declared root %s.%s not found in %s/%s\n" + " A root that cannot be resolved is not a pass — fix the ROOTS table or the type." + % (emitter, rootname, emitter, pkgdir)) + tags = walk(by_dir, by_name, pkgdir, rootname) + seen_tags = {} + for tag, dotted in tags: + seen_tags.setdefault(tag, dotted) + opaque_roots = [p for (lbl, p) in OPAQUE_BELOW if lbl == label] + missing = [] + for tag, dotted in sorted(seen_tags.items()): + if tag in GENERIC: + skipped += 1 + continue + # opaque BELOW: the node itself and its immediate children are checked, deeper is not + if any(dotted.startswith(o + ".") and dotted.count(".") > o.count(".") + 1 + for o in opaque_roots): + skipped += 1 + continue + if (label, dotted) in ALLOWLIST: + skipped += 1 + continue + checked += 1 + if not receiver_has(tag, repos[receiver]): + missing.append((tag, dotted)) + if missing: + convictions.append((label, receiver, missing)) + + if not quiet: + print("wire-contract gate — %d tag(s) checked across %d declared wire(s); " + "%d skipped (generic / opaque / allowlisted)" % (checked, len(ROOTS), skipped)) + if convictions: + if not quiet: + for label, receiver, missing in convictions: + print("\n %s" % label) + print(" the RECEIVER (%s) contains no occurrence of:" % receiver) + for tag, dotted in missing: + print(" %-32s (emitted at %s)" % (tag, dotted)) + total = sum(len(m) for _, _, m in convictions) + print("\nWIRE-CONTRACT GATE FAILED: %d emitted field(s) cannot be received." % total) + print("A tag whose literal string occurs nowhere in the receiving repo cannot be decoded") + print("by any struct, named or anonymous — encoding/json discards it on arrival.") + print("Fix: model the field on the receiving side and say what consults it — or, if it is") + print("deliberately not consumed, add it to ALLOWLIST WITH A REASON. Never a quiet skip.") + print("\nBlind spots (a green is not full coverage — see the module docstring):") + print(" generic tag names are not checked; reachability of a NAME is not use of a VALUE;") + print(" only the declared ROOTS are covered (hub desired-state and the agent local API") + print(" are NOT).") + return 1, convictions + + if not quiet: + print("wire-contract gate OK — every emitted field is at least decodable by its receiver") + print(" (BLIND SPOTS: generic tag names skipped; name-reachability is not use; only the") + print(" declared ROOTS are covered — hub desired-state and the agent local API are not.)") + return 0, [] + + +def selftest(): + """Plant an unreachable tag on a real root in a THROWAWAY copy and assert conviction. + + A gate nobody has watched fail has not been shown to work — and last night an off-the-shelf tool + for a neighbouring class was rejected because it was made to prove itself and could not. + """ + tmp = tempfile.mkdtemp(prefix="wirecontract-selftest-") + try: + agent = os.path.join(tmp, "agent") + shutil.copytree(REPOS["agent"], agent, symlinks=True, + ignore=shutil.ignore_patterns(".git")) + rp = os.path.join(agent, "internal", "hub", "report.go") + src = open(rp, encoding="utf-8").read() + marker = "type HostReport struct {" + if marker not in src: + die("SELFTEST INCONCLUSIVE: could not find %r to plant into" % marker) + planted = src.replace( + marker, + marker + "\n\tZZProbeUnreceivable string `json:\"zz_probe_unreceivable\"`", 1) + if planted == src: + die("SELFTEST INCONCLUSIVE: the plant did not apply") + open(rp, "w", encoding="utf-8").write(planted) + + code, convictions = run({"agent": agent}, quiet=True) + found = any("zz_probe_unreceivable" == t + for _, _, miss in convictions for t, _ in miss) + print(" planted an unreachable tag on agent HostReport -> gate exit %d, " + "planted tag convicted: %s" % (code, found)) + if code != 1 or not found: + print("SELFTEST FAILED: the gate did not convict a planted unreachable tag.") + return 1 + + code_clean, _ = run(quiet=True) + print(" unplanted tree -> gate exit %d" % code_clean) + if code_clean == 2: + print("SELFTEST FAILED: the unplanted run was INCONCLUSIVE.") + return 1 + print("SELFTEST OK — the gate convicts a planted unreachable tag and the plant is the only " + "difference.") + return 0 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(selftest()) + sys.exit(run()[0])