9167cf53af
gates / gates (push) Successful in 23s
The hub has written every customer e-mail in Hungarian whatever the box was set to. The box has published its language since controller v0.247.0; nothing read it. Now it does. Nothing an operator reads changes. The Hungarian mails are byte-identical, and that is a diff rather than a reading: 56 goldens per language captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after every sentence was routed through the new bundle. - internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0. - customerMessages/severityLabels are DERIVED from the bundle, so a sentence is written in one place and all 40+ tests that read those maps still work. - Language order: last reported -> created-with -> hu. reports.language defaults to EMPTY, never hu: "never told us" is not "chose Hungarian". - message_customer on POST /api/v1/event, additive and optional forever, for the sentences the box composes and the hub cannot translate. - The bind page is per-language, and its `expired` state stays Hungarian: it is the state an unknown token lands in, so rendering a real English customer's token in English would make the LANGUAGE answer what the TEXT refuses to. Two defects found inside the release: - R-581: the newest report was picked by received_at, which has SECOND granularity, so same-second reports tied and the winner was arbitrary. Ordered by the autoincrement id now. GetCustomers() still has the shape - row open. - R-582: the English copy-guard stems, ported word for word from Hungarian, convicted 141 honest sentences. The English claim is a phrase with a modal. R-555 closed: the language allowlist entry is out of wire_contract_gate.py. hub_copy_gate.py follows the sentences into the bundle - without that it would have scanned four files that no longer hold any customer text and reported success. Three new decoys incl. an innocent control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
295 lines
16 KiB
Python
295 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""hub_copy_gate — the hub's customer-facing copy comes under a guard (R-324).
|
|
|
|
THE SCOPE GAP THIS CLOSES. `retrieval_promise_gate.py` lives in `felhom-controller` and scans that
|
|
repo only. It was extended to Go string literals on 2026-08-12 (R-311) on the express ground that the
|
|
recovery screen's messages are Go strings in a handler and "none of it had ever been scanned" — and
|
|
the identical sentence was true one repo over the whole time. **The hub composes every customer
|
|
e-mail and renders the binding pages: the first sentences a customer ever reads, before they have
|
|
seen any box screen.** Nothing looked at them.
|
|
|
|
A hand scan on 2026-08-13 returned zero retrieval claims, so this was a SCOPE gap rather than a live
|
|
defect — which is the moment to close it, before someone writes the first hub-side promise assuming
|
|
the guard has them covered.
|
|
|
|
TWO CHECKS, and the difference is deliberate:
|
|
|
|
1. RETIRED NAMES — banned outright, scanned across the WHOLE hub, not just declared surfaces. A
|
|
name for a secret that a different secret now owns is never correct anywhere, so there is no
|
|
allowlist and no "unless". Comments are stripped: prose explaining a rename is not the rename
|
|
coming back, and the register rows that record these decisions quote the retired names by
|
|
necessity.
|
|
|
|
2. RETRIEVAL STEMS — registered, not banned, in the DECLARED customer-facing surfaces. The claim
|
|
they carry is sometimes true and must stay sayable; what must not happen is a new one appearing
|
|
where nobody was looking. Same contract as the controller gate: an occurrence is allowlisted
|
|
WITH A REASON, and a stale allowlist entry is also a failure.
|
|
|
|
BOTH lists come from `customer_copy_vocab.py` — see that file for why they are not literals here.
|
|
|
|
DRIFT CHECK, and it is load-bearing. The controller gate still owns its own `STEMS` literal (the
|
|
session that wrote this could not touch felhom-controller). This gate reads that literal and FAILS if
|
|
it disagrees with the shared list, so the two cannot silently diverge in the meantime. An ABSENT
|
|
sibling clone is INCONCLUSIVE (exit 2), never a pass — the G-1 lesson: a gate that skips when its
|
|
sibling is missing runs in neither home.
|
|
|
|
Run: python3 scripts/hub_copy_gate.py (from the felhom.eu repo root)
|
|
python3 scripts/hub_copy_gate.py --selftest (plant → convict → remove → pass)
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from customer_copy_vocab import ( # noqa: E402
|
|
RETIRED_NAMES, RETRIEVAL_STEMS, RETRIEVAL_STEMS_EN)
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_REPO = os.path.dirname(_HERE)
|
|
_HUB = os.path.join(_REPO, "hub")
|
|
|
|
# The hub files that carry HUNGARIAN CUSTOMER-FACING text — the surfaces where a retrieval claim
|
|
# could be made to a customer. Enumerated by scanning every non-test .go/.html under hub/internal for
|
|
# accented Hungarian outside comments, then keeping the ones a CUSTOMER (not the operator) reads.
|
|
#
|
|
# A declared file that is missing is a FAILURE, never a skip: the controller gate learned that when a
|
|
# renamed handler would have silently emptied its own scope.
|
|
CUSTOMER_SURFACES = [
|
|
os.path.join("hub", "internal", "notify", "templates.go"), # the mail wrappers + event copy
|
|
os.path.join("hub", "internal", "web", "selfbind.go"), # the binding pages
|
|
os.path.join("hub", "internal", "api", "handler.go"), # customer-visible event messages
|
|
os.path.join("hub", "internal", "notify", "dispatcher.go"), # the customer channel's own wording
|
|
# ── THE BUNDLES (hub v0.118.0, R-558) ────────────────────────────────────────────────────────
|
|
#
|
|
# ⚠ THE SENTENCES MOVED, AND THIS IS THE LINE THAT KEPT THE GATE HONEST. Until v0.118.0 every
|
|
# customer sentence was a Go literal in templates.go and selfbind.go; slice 3 moved all of them
|
|
# into these two JSON files. Without adding them here, the four files above would still all
|
|
# exist, the gate would still report "4 surfaces scanned", and it would be scanning NOTHING —
|
|
# a green gate over an empty scope, which is the exact shape this project has found sixteen
|
|
# times (AUDIT-gate-decoys-2026-09-01, "scope is a fact too").
|
|
os.path.join("hub", "internal", "i18n", "locales", "hu.json"),
|
|
os.path.join("hub", "internal", "i18n", "locales", "en.json"),
|
|
]
|
|
|
|
# (file basename, substring that identifies the occurrence) -> why it is allowed.
|
|
#
|
|
# It was empty until v0.118.0, when the English stems were added (R-558) and found exactly one
|
|
# occurrence. That it found one rather than none is the argument for having added them.
|
|
ALLOWLIST = {
|
|
("handler.go", "that history stays recoverable with the recovery code that sealed it"): (
|
|
"OPERATOR-TIER, and verified so rather than assumed: the sentence is the message of "
|
|
"`offsite_repo_key_changed`, which is listed in notify/dispatcher.go's operatorOnlyEvents "
|
|
"with its own R-197 reasoning — a custody fact about escrow blobs that a customer can take "
|
|
"no action on. It is operator-grade English naming host ids and retained-blob counts, and it "
|
|
"reaches no customer channel. handler.go is a DECLARED customer surface because it also "
|
|
"holds customer-visible event messages, so the file is right to be scanned and this "
|
|
"occurrence is right to be registered. ⚠ If offsite_repo_key_changed is ever REMOVED from "
|
|
"operatorOnlyEvents, this entry becomes false and the claim reaches households — the promise "
|
|
"is one R-304 says the product cannot keep for a customer, because nothing in the product "
|
|
"reads a retained key and a customer's correct old code is reported as wrong."),
|
|
}
|
|
|
|
def scannable_text(path):
|
|
"""The text a CUSTOMER could read, with everything else removed.
|
|
|
|
For a bundle, that is the VALUES ONLY. The keys are identifiers — `mail.event.node_recovered`,
|
|
`disaster_recovery_completed` — and scanning them convicts the machinery for the words it names
|
|
things with. That is not a near miss either: keys accounted for a third of the first run's false
|
|
convictions, and every one of them would have earned a permanent allowlist entry explaining that
|
|
a key is not a sentence.
|
|
"""
|
|
if path.endswith(".json"):
|
|
with open(path, encoding="utf-8") as fh:
|
|
return "\n".join(json.load(fh).values())
|
|
return strip_comments(open(path, encoding="utf-8").read(), path)
|
|
|
|
|
|
GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
|
TPL_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
|
|
HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
|
|
|
|
|
def strip_comments(text, path):
|
|
"""Remove what never reaches a customer.
|
|
|
|
Go `//` and `/* */`, Go-template `{{/* */}}`, and — unlike the controller gate — HTML `<!-- -->`
|
|
too. The controller gate deliberately SCANS HTML comments because its templates ship to a browser
|
|
where View Source is one keystroke. The hub's Hungarian lives in Go string literals that happen
|
|
to contain HTML, and its operator templates are not customer copy, so an HTML comment here is a
|
|
note to the next maintainer. Stated because it is a real difference between the two gates.
|
|
"""
|
|
text = TPL_COMMENT.sub("", text)
|
|
text = HTML_COMMENT.sub("", text)
|
|
if path.endswith(".go"):
|
|
text = GO_COMMENT.sub("", text)
|
|
return text
|
|
|
|
|
|
def _iter_hub_sources():
|
|
for root, _dirs, files in os.walk(os.path.join(_REPO, "hub", "internal")):
|
|
for f in sorted(files):
|
|
if f.endswith("_test.go") or not (f.endswith(".go") or f.endswith(".html")):
|
|
continue
|
|
yield os.path.join(root, f)
|
|
|
|
|
|
def scan_retired(extra_text=None):
|
|
"""Every retired name, everywhere in the hub. Returns a list of convictions."""
|
|
convictions = []
|
|
sources = [(p, open(p, encoding="utf-8").read()) for p in _iter_hub_sources()]
|
|
if extra_text is not None:
|
|
# The synthetic path MUST end in .go: strip_comments keys comment-stripping off the
|
|
# extension, so a selftest source named anything else would be scanned WITH its comments —
|
|
# which is how the first run of this selftest convicted its own step-3 control. The bug was
|
|
# in the instrument, and the control is what found it.
|
|
sources.append((os.path.join(_REPO, "hub", "internal", "selftest_planted.go"), extra_text))
|
|
for path, raw in sources:
|
|
text = strip_comments(raw, path)
|
|
for name in RETIRED_NAMES:
|
|
for m in re.finditer(re.escape(name), text):
|
|
line = text[: m.start()].count("\n") + 1
|
|
ctx = " ".join(text[max(0, m.start() - 70): m.end() + 70].split())[:150]
|
|
convictions.append((os.path.relpath(path, _REPO), line, name, ctx))
|
|
return convictions
|
|
|
|
|
|
def scan_stems():
|
|
"""Retrieval stems in the declared customer surfaces. Returns (convictions, seen_keys)."""
|
|
convictions, seen = [], set()
|
|
for rel in CUSTOMER_SURFACES:
|
|
path = os.path.join(_REPO, rel)
|
|
if not os.path.exists(path):
|
|
raise SystemExit("hub-copy gate: declared customer surface is MISSING: %s" % rel)
|
|
name = os.path.basename(path)
|
|
text = scannable_text(path)
|
|
# Both halves of the claim are scanned in every surface, deliberately rather than per-file:
|
|
# an English sentence can be written into a Go literal and a Hungarian one into en.json, and
|
|
# a gate that assumed each file holds one language would miss exactly the mistake that
|
|
# matters.
|
|
patterns = [(re.escape(st) + r"[a-záéíóöőúüű]*") for st in RETRIEVAL_STEMS]
|
|
patterns += list(RETRIEVAL_STEMS_EN) # already regexes; they carry their own modal
|
|
for pattern in patterns:
|
|
for m in re.finditer(pattern, text, re.I):
|
|
hit = None
|
|
for k in (k for k in ALLOWLIST if k[0] == name):
|
|
for om in re.finditer(re.escape(k[1]), text):
|
|
if om.start() <= m.start() and m.end() <= om.end():
|
|
hit = k
|
|
break
|
|
if hit:
|
|
break
|
|
if hit:
|
|
seen.add(hit)
|
|
else:
|
|
line = text[: m.start()].count("\n") + 1
|
|
ctx = " ".join(text[max(0, m.start() - 90): m.end() + 90].split())[:170]
|
|
convictions.append((name, line, m.group(0), ctx))
|
|
return convictions, seen
|
|
|
|
|
|
def check_drift():
|
|
"""The controller gate's STEMS must equal the shared list. Returns (status, message).
|
|
|
|
status: "ok" | "drift" | "inconclusive"
|
|
"""
|
|
sibling = os.path.join(os.path.dirname(_REPO), "felhom-controller",
|
|
"controller", "scripts", "retrieval_promise_gate.py")
|
|
if not os.path.exists(sibling):
|
|
return "inconclusive", ("the felhom-controller clone is absent, so the shared stem list "
|
|
"could not be compared against the gate that also uses it (%s)" % sibling)
|
|
text = open(sibling, encoding="utf-8").read()
|
|
m = re.search(r"^STEMS\s*=\s*\[(.*?)\]", text, re.M | re.S)
|
|
if not m:
|
|
return "drift", "could not find a STEMS list in the controller gate — its shape changed"
|
|
theirs = re.findall(r"[\"']([^\"']+)[\"']", m.group(1))
|
|
if theirs != RETRIEVAL_STEMS:
|
|
return "drift", ("the controller gate's STEMS have diverged from customer_copy_vocab.py\n"
|
|
" controller : %r\n shared : %r" % (theirs, RETRIEVAL_STEMS))
|
|
return "ok", "controller gate's STEMS match the shared list (%d stem(s))" % len(theirs)
|
|
|
|
|
|
def selftest():
|
|
"""Plant → convict → remove → pass. A guard never seen catching anything proves nothing."""
|
|
print("hub-copy gate SELFTEST")
|
|
baseline = scan_retired()
|
|
if baseline:
|
|
print(" FAIL: the tree is not clean before planting — %d conviction(s)" % len(baseline))
|
|
for c in baseline:
|
|
print(" %s:%d [%s]" % (c[0], c[1], c[2]))
|
|
return 1
|
|
print(" 1. clean tree : 0 conviction(s) OK")
|
|
|
|
planted = 'body := "Add meg a visszaállító jelszavadat a folytatáshoz."\n'
|
|
convicted = scan_retired(extra_text=planted)
|
|
if len(convicted) != 1 or convicted[0][2] != "isszaállító jelsz":
|
|
print(" 2. planted retired name : NOT CONVICTED — the guard is inert")
|
|
print(" got: %r" % (convicted,))
|
|
return 1
|
|
print(" 2. planted „visszaállító jelszavadat”: CONVICTED (%s) OK" % convicted[0][2])
|
|
|
|
commented = '// the old name was „visszaállító jelszó" and is retired\n'
|
|
if scan_retired(extra_text=commented):
|
|
print(" 3. same phrase inside a COMMENT : convicted — comments must not be scanned")
|
|
return 1
|
|
print(" 3. same phrase inside a comment : not convicted OK")
|
|
|
|
if scan_retired():
|
|
print(" 4. planting removed : still convicting — the scan is not deterministic")
|
|
return 1
|
|
print(" 4. planting removed : 0 conviction(s) OK")
|
|
print("hub-copy gate selftest OK — the guard has been watched catching, ignoring and releasing")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
if "--selftest" in sys.argv:
|
|
return selftest()
|
|
|
|
retired = scan_retired()
|
|
stems, seen = scan_stems()
|
|
stale = [k for k in ALLOWLIST if k not in seen]
|
|
drift_status, drift_msg = check_drift()
|
|
|
|
for path, line, name, ctx in retired:
|
|
print(" %s:%d RETIRED NAME in customer copy (%s):\n …%s…" % (path, line, name, ctx))
|
|
print(" reason it is retired: %s" % RETIRED_NAMES[name])
|
|
for name, line, word, ctx in stems:
|
|
print(" %s:%d unregistered retrieval claim (%s):\n …%s…" % (name, line, word, ctx))
|
|
for k in stale:
|
|
print(" STALE ALLOWLIST ENTRY (no longer present): %s :: %r" % (k[0], k[1]))
|
|
|
|
n_files = len(list(_iter_hub_sources()))
|
|
if retired or stems or stale:
|
|
print("\nHUB-COPY GATE FAILED: %d retired name(s), %d unregistered claim(s), %d stale."
|
|
% (len(retired), len(stems), len(stale)))
|
|
print("A retired name is never correct in customer copy — use the current name. If a new")
|
|
print("retrieval claim is genuinely TRUE where it renders, make it conditional on what the")
|
|
print("hub can actually see, then register it in ALLOWLIST with the reason.")
|
|
return 1
|
|
|
|
if drift_status == "drift":
|
|
print(" DRIFT: %s" % drift_msg)
|
|
print("\nHUB-COPY GATE FAILED: the shared vocabulary is no longer shared.")
|
|
return 1
|
|
|
|
print("hub-copy gate OK — %d hub file(s) scanned for %d retired name(s); %d customer surface(s) "
|
|
"scanned for %d retrieval pattern(s) (%d hu + %d en), %d registered claim(s), none unregistered"
|
|
% (n_files, len(RETIRED_NAMES), len(CUSTOMER_SURFACES),
|
|
len(RETRIEVAL_STEMS) + len(RETRIEVAL_STEMS_EN),
|
|
len(RETRIEVAL_STEMS), len(RETRIEVAL_STEMS_EN), len(ALLOWLIST)))
|
|
if drift_status == "inconclusive":
|
|
print(" ⚠ INCONCLUSIVE (exit 2): %s" % drift_msg)
|
|
return 2
|
|
print(" drift: %s" % drift_msg)
|
|
print(" (BLIND SPOT: this checks the WORDS in the %d declared customer surfaces. It cannot"
|
|
% len(CUSTOMER_SURFACES))
|
|
print(" tell whether a true-looking sentence is wired to a predicate that is actually true —")
|
|
print(" that is what render tests are for. And it does not read the operator's screens.)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|