hub v0.119.0 — English households get English words for their codes (R-597); R-596/R-598 closed
gates / gates (push) Successful in 24s

The setup code and the owner passphrase now follow the household's language,
one word longer in English so the entropy never drops (setup 3 hu / 4 en,
passphrase 5 hu / 6 en). List and count are chosen together so a caller cannot
pair an English list with a Hungarian count. Hungarian is byte-unchanged.

Three claims in the row were wrong and are recorded as such:
  - the RECOVERY CODE is minted by felhom-agent from the EFF list and has
    always been English; the hub does not own it and no row was added.
  - no claim mail states a word count; the only count wording was the bind
    page's passphrase hint, whose English half is now count-free.
  - the proposed phone-safe filter removes 68% of the list (5270 of 7772
    words) and was measured, then declined, with the reason in source.

Also: guide_quote_gate binds the English volunteer guide's three quoted
messages to the controller's English bundle — nothing did, so the guide would
have gone on quoting Hungarian after the fix. Seven decoys, all convicting,
including the name-for-fact one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-21 07:56:56 +02:00
parent a499327236
commit e02bc03819
18 changed files with 8859 additions and 50 deletions
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""guide_quote_gate -- the English guide may only quote what the screen actually says (R-596/R-598).
Run from anywhere: python3 scripts/guide_quote_gate.py
Exit 0 clean * 1 convicted * 2 inconclusive (a file it needs is missing).
WHY THIS EXISTS. `documentation/runbooks/VOLUNTEER-first-hour.en.md` is what a volunteer tester
follows instead of the Hungarian guide. Three of its lines QUOTE messages the dashboard prints:
the claim page's wrong-code and lockout answers, and the Backup page's system-disk warning. Those
three sentences live in the CONTROLLER's English bundle, in a different repo, and nothing bound
them together -- so the guide would have gone on quoting Hungarian for as long as nobody re-walked
it. That is exactly how it read for a day: the 2026-09-20 drill's guide quoted the Hungarian
because the Hungarian was what shipped, and when v0.259.0 changed the screen the guide became wrong
in the other direction.
WHAT IT CHECKS. Each entry below names a bundle KEY and asserts its English value appears verbatim
in the guide. A reworded key, or a reworded guide, convicts and prints both sides.
WHAT IT DOES NOT CHECK, deliberately: that the guide is complete, or that any OTHER sentence in it
is accurate. It binds the three quotes that are quotes. Everything else in that document is prose a
person has to re-walk, and pretending otherwise would be the label without the fact.
SCOPE IS A FACT. The controller clone may not sit beside this one (CI checks out one repo). An
absent sibling is INCONCLUSIVE (exit 2), never a silent pass -- the failure this project keeps
closing is a check that reports green because it could not look.
THE DECOY (R-421). scripts/test_guide_quote_gate.py builds a guide that MENTIONS every key by name
in prose -- the label without the fact -- and asserts this gate still convicts, because it compares
the VALUE, not the key.
"""
from __future__ import annotations
import io
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
WORKSPACE = os.path.dirname(REPO)
GUIDE = os.path.join(REPO, "documentation", "runbooks", "VOLUNTEER-first-hour.en.md")
EN_BUNDLE = os.path.join(
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
)
# key -> why the guide quotes it. The reason is part of the record: a future session deciding to
# drop a quote should have to argue with the reason, not just with the list.
QUOTED = {
"claim.msg.bad_code":
"section 13 -- the answer to a mistyped setup code. This is the sentence the 2026-09-20 "
"English drill stopped on; a tester who reads a different one cannot tell a typo from a "
"dead code, which is the whole point of the section.",
"claim.msg.too_many":
"section 13 -- the lockout answer after five wrong codes. The guide promises 15 minutes; "
"if the message ever says something else, the guide is telling a tester to wait wrongly.",
"claim.msg.password_too_short":
"section 13 -- the minimum-password answer. Quoted with its number filled in, so the guide "
"and claimMinPassword cannot disagree silently.",
"backup.target.degraded":
"section 9 -- the warning that says the backup does NOT survive a disk failure. It is a "
"promise about whether the tester's files are safe.",
}
# Keys whose English carries a printf verb: the guide quotes the rendered form. value -> rendered.
RENDER = {
"claim.msg.password_too_short": lambda v: v % 12,
}
def main() -> int:
if not os.path.exists(GUIDE):
print("guide-quote: INCONCLUSIVE -- no %s" % os.path.relpath(GUIDE, REPO), file=sys.stderr)
return 2
if not os.path.exists(EN_BUNDLE):
print(
"guide-quote: INCONCLUSIVE -- the felhom-controller clone is not beside this one "
"(looked for %s). Not a pass: this gate cannot see what the screen says." % EN_BUNDLE,
file=sys.stderr,
)
return 2
guide = io.open(GUIDE, encoding="utf-8").read()
bundle = json.load(io.open(EN_BUNDLE, encoding="utf-8"))
problems = []
for key, why in sorted(QUOTED.items()):
if key not in bundle:
problems.append("MISSING KEY %s\n en.json does not know it.\n %s" % (key, why))
continue
want = bundle[key]
if key in RENDER:
want = RENDER[key](want)
if want not in guide:
problems.append(
"NOT QUOTED %s\n"
" the screen says: %r\n"
" the guide does not contain that sentence.\n"
" %s" % (key, want, why)
)
print("guide-quote: %d quoted messages checked against the controller's English bundle" % len(QUOTED))
if problems:
print("\nguide-quote gate CONVICTS (%d):" % len(problems))
for p in problems:
print(" " + p)
print(
"\nThe English guide and the English dashboard disagree. Fix whichever is wrong --\n"
"but a tester follows the guide, so a guide that quotes a sentence nobody sees is the\n"
"more expensive of the two."
)
return 1
print("guide-quote gate OK: every quoted message is what the screen says.")
return 0
if __name__ == "__main__":
sys.exit(main())
+6
View File
@@ -17,6 +17,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
7. golden-currency a released controller has a golden carrying it (R-242)
8. wire-contract every emitted field is decodable by its receiver (G-1)
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
9b. guide-quote the English volunteer guide, against the controller's English bundle (R-596)
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
11. one-register open work living outside OPEN-ITEMS.md (R-369)
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
@@ -125,6 +126,11 @@ GATES = [
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
# R-596/R-598 — the English guide QUOTES three dashboard messages that live in the controller's
# bundle, in another repo. Nothing bound them, so the guide quoted Hungarian for as long as the
# Hungarian shipped, and would have quoted it after the fix too. INCONCLUSIVE (exit 2, reported
# not swallowed) when the controller clone is absent. Fast: two file reads.
("guide-quote", os.path.join(SCRIPTS, "guide_quote_gate.py"), [], True, False),
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
# R-369 — two files held open work and only one called itself the source of truth, so a READY
+25
View File
@@ -45,6 +45,12 @@ COVERS = {
"golden-currency": "R-410: an empty directory with a perfect name, checked by what it COUNTED",
"closed-register": "a verdict cell reading open, and a row with no state cell at all",
"decoy-coverage": "a gate registered in a runner with no decoy and no exemption (its red-proof)",
"guide-quote": ("R-596: SEVEN cases in scripts/test_guide_quote_gate.py, run from here so "
"this suite stays the single entry point. The load-bearing one is "
"name-for-fact: a guide that lists every key in a table and quotes none of "
"their sentences. Also: a prefix of the real sentence, the OLD Hungarian "
"quote, a reworded bundle, a deleted key, and the scope case — an absent "
"controller clone must be INCONCLUSIVE, never a pass"),
"hub-copy": ("R-558: an English retrieval promise in the NEW bundle (the sentences moved "
"out of templates.go, so the surface list had to move with them), the same "
"in Hungarian, and an INNOCENT control using the identical verbs without the "
@@ -286,6 +292,25 @@ elif "decoy-red-proof" not in _out:
else:
print(" ok %-20s a new gate with no decoy is convicted BY NAME" % "decoy-coverage")
# ── guide-quote (R-596) ──────────────────────────────────────────────────────────────────────────
#
# Its decoys build whole fake workspaces (a guide plus a sibling controller clone), which does not
# fit the plant/restore shape above — so they live in their own file and are RUN from here. The
# decoy-coverage gate reads COVERS in this file, so this is the seam that keeps that entry honest:
# if the separate suite stops passing, this one fails, and the COVERS line stops being a label.
ran += 1
_gq = subprocess.run([sys.executable, os.path.join("scripts", "test_guide_quote_gate.py")],
cwd=ROOT, capture_output=True, text=True)
if _gq.returncode == 2:
fails.append("guide-quote: its decoy suite could not run (no controller clone beside this one) — "
"INCONCLUSIVE is not coverage\n%s" % (_gq.stdout + _gq.stderr)[-500:])
elif _gq.returncode != 0:
fails.append("guide-quote: its decoy suite FAILED — a decoy did not convict\n%s"
% (_gq.stdout + _gq.stderr)[-800:])
else:
_n = _gq.stdout.strip().splitlines()[-1] if _gq.stdout.strip() else "?"
print(" ok %-20s %s" % ("guide-quote", _n))
print()
if fails:
for f in fails:
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Decoys for guide_quote_gate (R-421). Run: python3 scripts/test_guide_quote_gate.py
A decoy is the LABEL without the FACT. The shape this gate is most at risk of is
**name-for-fact**: matching the KEY NAME where the fact is the key's VALUE. A guide that lists
`claim.msg.bad_code` in a table of "messages covered" would satisfy a name-matching gate and tell a
tester nothing, because a tester reads sentences, not keys.
The second shape checked here is **declaration-for-reachability** in its sibling form: a gate that
reports OK when it could not read the bundle at all. Scope is a fact -- an absent controller clone
must be INCONCLUSIVE (2), never a pass.
Each case runs the real gate against a built tree and asserts the exit code.
"""
from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
WORKSPACE = os.path.dirname(REPO)
REAL_BUNDLE = os.path.join(
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
)
GATE_SRC = os.path.join(HERE, "guide_quote_gate.py")
def build(tmp, guide_text, bundle=None, with_bundle=True):
"""Lay out a fake workspace: <tmp>/felhom.eu/{scripts,documentation/...} + the sibling clone."""
repo = os.path.join(tmp, "felhom.eu")
scripts = os.path.join(repo, "scripts")
runbooks = os.path.join(repo, "documentation", "runbooks")
os.makedirs(scripts)
os.makedirs(runbooks)
shutil.copy(GATE_SRC, os.path.join(scripts, "guide_quote_gate.py"))
io.open(os.path.join(runbooks, "VOLUNTEER-first-hour.en.md"), "w", encoding="utf-8").write(guide_text)
if with_bundle:
locales = os.path.join(tmp, "felhom-controller", "controller", "internal", "i18n", "locales")
os.makedirs(locales)
if bundle is None:
bundle = json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
io.open(os.path.join(locales, "en.json"), "w", encoding="utf-8").write(
json.dumps(bundle, ensure_ascii=False, indent=2)
)
return os.path.join(scripts, "guide_quote_gate.py")
def run(gate):
p = subprocess.run([sys.executable, gate], capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
def real_bundle():
return json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
def honest_guide(b):
return (
"# guide\n\n"
"A wrong code answers **\"%s\"**, and after five tries **\"%s\"**.\n"
"A short password answers **\"%s\"**.\n\n> %s\n"
% (
b["claim.msg.bad_code"],
b["claim.msg.too_many"],
b["claim.msg.password_too_short"] % 12,
b["backup.target.degraded"],
)
)
CASES = []
def case(name):
def deco(fn):
CASES.append((name, fn))
return fn
return deco
@case("control: an honest guide passes")
def _control(tmp):
gate = build(tmp, honest_guide(real_bundle()))
rc, out = run(gate)
return rc == 0, "rc=%d\n%s" % (rc, out)
@case("DECOY name-for-fact: the guide NAMES every key and quotes none")
def _names(tmp):
text = (
"# guide\n\nMessages covered by this guide:\n\n"
"| key | section |\n|---|---|\n"
"| claim.msg.bad_code | 13 |\n"
"| claim.msg.too_many | 13 |\n"
"| claim.msg.password_too_short | 13 |\n"
"| backup.target.degraded | 9 |\n\n"
"All four messages are shown in English.\n"
)
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "NOT QUOTED" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY substring: the guide quotes a PREFIX of the real sentence")
def _prefix(tmp):
b = real_bundle()
text = honest_guide(b).replace(b["backup.target.degraded"], b["backup.target.degraded"][:40])
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "backup.target.degraded" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY the OLD Hungarian quote: the drill-era guide must convict")
def _hungarian(tmp):
b = real_bundle()
text = honest_guide(b).replace(b["claim.msg.bad_code"], "Hibás vagy lejárt kód")
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "claim.msg.bad_code" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY reworded screen: the bundle changes and the guide does not")
def _reworded(tmp):
b = real_bundle()
text = honest_guide(b)
b2 = dict(b)
b2["claim.msg.too_many"] = "Too many attempts - try again later."
gate = build(tmp, text, bundle=b2)
rc, out = run(gate)
return rc == 1 and "claim.msg.too_many" in out, "rc=%d\n%s" % (rc, out)
@case("SCOPE: no controller clone is INCONCLUSIVE (2), never a pass")
def _noclone(tmp):
gate = build(tmp, honest_guide(real_bundle()), with_bundle=False)
rc, out = run(gate)
return rc == 2 and "INCONCLUSIVE" in out, "rc=%d\n%s" % (rc, out)
@case("SCOPE: a key deleted from the bundle convicts, it does not vanish")
def _deleted(tmp):
b = real_bundle()
text = honest_guide(b)
b2 = dict(b)
del b2["claim.msg.bad_code"]
gate = build(tmp, text, bundle=b2)
rc, out = run(gate)
return rc == 1 and "MISSING KEY" in out, "rc=%d\n%s" % (rc, out)
def main():
if not os.path.exists(REAL_BUNDLE):
print("SKIP: the felhom-controller clone is not beside this one", file=sys.stderr)
return 2
failed = 0
for name, fn in CASES:
tmp = tempfile.mkdtemp(prefix="gqg-")
try:
ok, detail = fn(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
print((" PASS " if ok else " FAIL ") + name)
if not ok:
failed += 1
print(" " + detail.replace("\n", "\n "))
print("\nguide-quote decoys: %d/%d" % (len(CASES) - failed, len(CASES)))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())