From 560f0d4451dc56b3d023cf93d22305dc091605c7 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 8 Aug 2026 08:28:53 +0200 Subject: [PATCH] =?UTF-8?q?G-1:=20a=20gate=20for=20the=20dropped=20field?= =?UTF-8?q?=20=E2=80=94=20built=20first,=20and=20seen=20failing=20on=2040?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign 12 ranked this first of eight gating candidates. It is built BEFORE the fixes it finds, because last night an off-the-shelf tool for a neighbouring class (deadcode, for C6) was made to prove itself first and found NEITHER of the two defects it was meant for. A gate nobody has watched fail has not been shown to work. scripts/wire_contract_gate.py, registered in repo_gates.py as --fast (no network, no container, so it runs in BOTH the pre-push hook and CI — the R-29 constraint). THE TEST. For every json tag reachable from a declared wire ROOT, does that literal tag occur anywhere in the receiving repo's production Go or templates? A tag occurring nowhere cannot be decoded by any struct, named OR anonymous. That last clause is why a string test is used instead of comparing struct to struct: Campaign 12's first attempt paired types by shape and false-positived badly, because the hub decodes one report through several ad-hoc anonymous structs. RESULT ON TODAY'S TREE: 210 tags checked across 3 declared wires, 51 skipped (generic / opaque / allowlisted), 40 CONVICTED. Captured verbatim in documentation/tests/wire-contract-gate-2026-08-08/ BEFORE.md, which is deliverable 1 of this session. The prompt for this session said "465 emitted tags, eight unreachable". Checked against the repo rather than quoted: R-260's wording was "at least eight DECISION-BEARING facts", not eight tags in total. The real count on the three declared wires is 40, and R-260's own census already listed more than eight. Recorded because this prompt's own rule 6 says not to quote a document as source. TWO THINGS THE CONTROL CAUGHT, both before the gate was trusted: 1. A SUBSTRING FALSE NEGATIVE. `grep -F healed_at` also matches `privsep_healed_at`, so a genuinely dropped field read as received — and R-260 named healed_at, so its absence from the output was the tell. Now a whole-token regex; healed_at is convicted. 2. dr_recipe IS NOT WHOLLY OPAQUE. The hub stores each half as json.RawMessage and re-emits nested shapes verbatim, so the LEAVES are genuinely not on this wire. But the TOP-LEVEL SECTION KEYS are decoded by hostHalfShape/appHalfShape, and those are ALLOW-LISTS: a section an emitter adds is silently dropped until named in both. That already cost `offsite_restic` (R-122). So the gate is opaque BELOW depth 1, not opaque — the sections are checked and pass. Self-test: `--selftest` plants an unreachable tag on a real root in a throwaway copy and asserts conviction. Verified: exit 1, planted tag named. Blind spots are in the module docstring AND in the gate's own output, because Campaign 12's C1 guard turned out blind to one of the three shapes it was written for: generic tag names are not checked; reachability of a NAME is not use of a VALUE; only declared ROOTS are covered, and the hub's desired-state (served as raw stored JSON, no typed emitter) and the agent local API are NOT. Allowlist entries carry a stated reason. A quiet exclusion is a dropped field with paperwork. Not pushed alone: the fixes follow in the next commit so main is never red on this check. --- .../wire-contract-gate-2026-08-08/BEFORE.md | 77 ++++ scripts/repo_gates.py | 1 + scripts/wire_contract_gate.py | 404 ++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 documentation/tests/wire-contract-gate-2026-08-08/BEFORE.md create mode 100644 scripts/wire_contract_gate.py 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])