hub v0.105.0: the third name, a machine told to be quiet, and a guard for the hub's own words
gates / gates (push) Successful in 17s

Hub only. No controller change, no agent change, no wire change — nothing to bake.
demo-hp untouched: the operator is re-deploying it this evening.

R-323 — the five-word phrase is „Tulajdonosi jelmondat". It was „Visszaállító
jelszó": one word from the name retired last week, and false besides — it restores
nothing, it proves the account owns the box being bound. Five sites, all in the hub;
felhom-controller and felhom-agent carry the name nowhere, so no halt and no bake.
Both suggested names were rejected with reasons: „Fiókjelszó" would collide with the
dashboard login (a DIFFERENT real secret), and „Összekötési jelszó" would leave the
two factors on this page separated only by kód-versus-jelszó — the exact shape being
removed, since the other factor is the „Párosító kód". The chosen name differs on
both axes, stem and noun. Naming only; the acceptance pin drives the real handler.

R-324 — the hub's customer copy is under a guard for the first time. Retired names
banned across all 95 hub files; retrieval stems registered in four declared customer
surfaces. The selftest found a defect in its own instrument on the first run. One
shared vocabulary in scripts/, drift-checked into the controller gate rather than
copied (R-325 removes the scaffold).

R-321 — a machine we told to be quiet is no longer reported as dead, and it was two
doors, not one: because the state is RECORDED rather than deleted, the morning
deadline check can skip it too. A deleted state returns "", which is not "down" —
R-195's shape returning through a second door. The clock runs from the report the hub
can see, so re-enabling starts it there and emits no recovery for an outage that never
happened. Three red-proofs; the one that matters showed a genuinely dead machine
sitting at "disabled" when the suppression was made unconditional.

R-326 — "which claims are unproven" is answerable by a command now. The nine I have
been repeating was the count of claims the 9 August pass DOWNGRADED, not the count of
unproven ones. The real figures: 55 claims, 23 walked, 32 not — and only 6 of those 32
cite evidence. Its first run found a stale claim (R-327).
This commit is contained in:
2026-08-13 15:50:32 +02:00
parent 955a4f07b7
commit b03a105375
14 changed files with 995 additions and 16 deletions
+244
View File
@@ -0,0 +1,244 @@
#!/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 os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from customer_copy_vocab import RETIRED_NAMES, RETRIEVAL_STEMS # noqa: E402
_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"), # every customer e-mail + 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
]
# (file basename, substring that identifies the occurrence) -> why it is allowed.
# Empty today, and that is a measurement rather than an oversight: the hub makes no retrieval promise.
ALLOWLIST = {}
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 = strip_comments(open(path, encoding="utf-8").read(), path)
for stem in RETRIEVAL_STEMS:
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
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 stem(s), %d registered claim(s), none unregistered"
% (n_files, len(RETIRED_NAMES), len(CUSTOMER_SURFACES), len(RETRIEVAL_STEMS), 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 four declared customer surfaces. It cannot")
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())