b080ecf411
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.
481 lines
23 KiB
Python
481 lines
23 KiB
Python
#!/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 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).
|
|
_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).
|
|
#
|
|
# ⚠ 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])
|