Files
admin daeed27223 R-325 (controller half): retrieval_promise_gate imports the shared stem list
STEMS now comes from felhom.eu/scripts/customer_copy_vocab.py (RETRIEVAL_STEMS), read the
way controller_gates.py reads the other shared checkers (CI clones felhom.eu beside this repo);
an absent or empty vocabulary is INCONCLUSIVE (exit 2), never a pass. Decoys: vocabulary
absent -> rc 2; an invented stem in a doctored shared list convicts a template using it.
hub_copy_gate.check_drift on this file now reads 'ok — single source'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00

306 lines
20 KiB
Python

#!/usr/bin/env python3
"""retrieval_promise_gate — pin the CLAIM, not the word (R-302).
WHY THIS IS NOT A STRING BAN. Five instances of "you can get your old backups back with your recovery
code" have surfaced ONE AT A TIME (R-294, R-299, and the two R-302 fixed this session), each found only
after the previous one was fixed. The obvious guard — forbid the sentence — was tried twice and failed
twice:
* v0.211.0 asserted the SINGULAR „visszaállítható lehet"; the card carried the PLURAL
„visszaállíthatók lehetnek" one paragraph above it and walked straight past (R-299).
* Broadening to the stem `visszaállíthat` then missed the banner entirely, because the banner says
„visszaszerezheted" — a different verb for the same claim.
AND THE STEM CANNOT BE BANNED. The honest replacement copy this session ships *contains the stem*:
„Hogy ezek még visszaszerezhetők-e … azt innen nem tudjuk megállapítani" is a QUESTION about
retrievability, and it is the correct sentence. A guard that forbade the stem would force the product
to avoid a normal Hungarian verb — a guard shaping the product around itself.
SO: every occurrence of a retrieval stem in a customer-facing template must be REGISTERED here with a
reason. Unregistered occurrences fail. The failure mode this actually catches is the real one — a new
claim appearing somewhere nobody was looking — without pretending a word is a claim.
Go template comments ({{/* … */}}) are stripped before scanning: html/template never renders them, so
prose explaining a fix is not a claim. HTML <!-- --> comments DO ship and are deliberately scanned.
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import i18n_bundle # noqa: E402
_HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATES = os.path.join(_HERE, "..", "internal", "web", "templates")
# R-311 — THE GATE HAD A BLIND SPOT THE SIZE OF THE RECOVERY SCREEN.
#
# It scanned `internal/web/templates` only. But every one of the recovery screen's messages is a Go
# STRING in a handler, not template text — including the four R-224 messages and the R-222/R-226 one,
# i.e. the highest-stakes customer copy in the product, on the one screen whose whole purpose is to be
# believed about someone's backups. None of it had ever been scanned.
#
# Go `//` and `/* */` comments are stripped for the same reason template comments are: they never
# reach a customer. (A `//` inside a Hungarian string literal would be stripped too — there are none,
# and a false NEGATIVE there is the safe direction for a guard that convicts on presence.)
GO_SOURCES = [
os.path.join(_HERE, "..", "internal", "web", "recovery_handlers.py".replace(".py", ".go")),
]
# The verbs that carry the claim "your old backups can be got back".
# R-311 adds `visszanyit`: the honest new message says a customer needs support's help „a régebbi
# előzményed visszanyitásához". That is the SAME claim in a fourth verb, and the docstring above
# records what happens when the guard chases words instead of claims — it misses the next one.
#
# R-325: the list is NOT kept here. It lives ONCE in felhom.eu/scripts/customer_copy_vocab.py, which
# the hub's copy gate imports too — two copies of a word list is the R-299 defect exactly. The
# sibling clone is read the way controller_gates.py reads reuse_refs_check.py: CI clones felhom.eu
# beside this repo, and an ABSENT clone is INCONCLUSIVE (exit 2) — never a pass on an empty list.
# FELHOM_SHARED_SCRIPTS overrides the directory (the decoy suite uses it; nothing else should).
_SHARED_SCRIPTS = os.environ.get("FELHOM_SHARED_SCRIPTS") or os.path.normpath(
os.path.join(_HERE, "..", "..", "..", "felhom.eu", "scripts"))
if not os.path.isfile(os.path.join(_SHARED_SCRIPTS, "customer_copy_vocab.py")):
print("retrieval-promise gate INCONCLUSIVE: the shared vocabulary is missing (%s) — the felhom.eu "
"clone must sit beside this repo; without the stems nothing can be judged" %
os.path.join(_SHARED_SCRIPTS, "customer_copy_vocab.py"))
sys.exit(2)
sys.path.insert(0, _SHARED_SCRIPTS)
from customer_copy_vocab import RETRIEVAL_STEMS as STEMS # noqa: E402
if not STEMS:
print("retrieval-promise gate INCONCLUSIVE: customer_copy_vocab.RETRIEVAL_STEMS is empty")
sys.exit(2)
# R-564 — THE SPLIT VERB. Hungarian moves the particle after the verb when something is stressed:
# „csak akkor ÁLLÍTHATÓK VISSZA", „csak a hiányzó fájlokat HOZOD VISSZA". The joined stems above never
# see that form, so seven Hungarian sentences went unscanned until their English translations were
# caught by EN_PATTERNS (slice 1 release B). The same claim, the same registration rule.
SPLIT_PATTERNS = [
r"állíth\w* vissza",
r"(?:hoz|szerez|nyit)\w* vissza",
]
HU_RE = re.compile("|".join([re.escape(s) + r"[a-záéíóöőúüű]*" for s in STEMS] + SPLIT_PATTERNS))
# (template, substring that identifies the occurrence) -> why it is allowed.
# The substring must be specific enough that a DIFFERENT claim in the same file does not match it.
ALLOWLIST = {
("backups.html", "amelyből az egész készülék visszaállítható"):
"the LOCAL whole-device backup, made and held by the host agent. Nothing to do with the "
"off-site escrow claim — no recovery code is involved.",
("backups_apps.html", "Eltávolítva — visszaállítható"):
"R-487: a REMOVED app's kept recovery unit. The row renders only when the unit's manifest "
"is readable on a CONNECTED registered drive (backup.ListRemovedAppUnits), and the claim is "
"the very action beside it — POST /backup/restore from that unit. No recovery code, no "
"off-site store: what the box can see on its own disk.",
("backups_apps.html", "alkalmazásonként visszaállítható"):
"per-app restore from the local app-data backup. Same: local, no recovery code.",
("backups_remote.html", "mentéseid visszaszerezhetők.</strong>"):
"the RecoveryOffer entry point. TRUE where it renders: it is gated on the hub telling this box "
"it holds a sealed package for it, which is the claim being made. Deliberately left alone.",
("backups_remote.html", "a mentéseid visszaszerezhetők, és a törlés elmarad."):
"the abandon block's promise — R-302 made it conditional on AbandonRetrievalOffered; this is "
"the TRUE branch.",
("backups_remote.html", "Hogy ezek még visszaszerezhetők-e"):
"R-302's cautious branch. Contains the stem inside a QUESTION about knowability — the sentence "
"the gate exists to protect, not to forbid.",
("layout.html", "Addig még visszaszerezheted őket a helyreállítási kóddal."):
"the banner's promise — R-302 made it conditional on RecoveryAbandonRetrievalOffered; TRUE branch.",
("layout.html", "Hogy ezek még visszaszerezhetők-e"):
"R-302's cautious branch on the banner. As above.",
("recovery_handlers.go", "A régi előzmény visszanyitása felülírná azt"):
"PRE-EXISTING and never scanned until R-311 extended this gate to Go handlers — which is the "
"point of extending it. It is NOT a promise: it is the reason for a REFUSAL (RecoverRefused, "
"a different repository password is already present), i.e. the sentence says the reopening "
"would overwrite and was therefore not done. Registered as an explanation, not a claim.",
("recovery_handlers.go", "A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell"):
"R-311's truthful message for a code that opens a RETAINED package. It is a claim, and it is "
"TRUE: the drill of 2026-08-12 recovered exactly this by hand (unsealed the retained package, "
"opened the set-aside store, restored planted files byte-identical). It routes to SUPPORT "
"rather than to a button precisely because there is no in-product route yet — the restore "
"machinery resolves its repository from settings and its password from one file. If that route "
"is ever built, this entry changes; if support ever cannot do it, this sentence must go.",
("recovery.html", "a mentéseid visszaszerezhetők, és a törlés elmarad;"):
"the abandon CONFIRMATION screen, shown at the moment of the decision. True by construction "
"there: the package the hub holds right now is the one about to be pinned. Left alone.",
# R-564 — the split-verb occurrences, found the day SPLIT_PATTERNS learnt them. Each one's English
# twin was registered in slice 1 release B (ALLOWLIST_EN below), with the same reason.
("backups_escrow.html", "ezzel a kóddal állíthatók vissza"):
"a PRECONDITION, not a promise: it says the remote backups need THIS code, on the page that "
"creates it. It narrows what is possible; it claims nothing about the old store.",
("backups_remote.html", "A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után"):
"a PRECONDITION: remote backups are restorable after a total failure ONLY if the recovery "
"code exists. It is the reason to create the code, not a claim that a store is retrievable.",
("backups_restore.html", "utána hozhatod vissza az adatait"):
"the order of two actions on the restore page for an app that is not installed: reinstall "
"first, then restore its data from the copy listed on that same row.",
("backups_restore_wizard.html", "csak a hiányzó fájlokat hozod vissza"):
"describes the CHOICE the wizard offers next (missing files only, or a full restore) for a "
"verification copy that is already on the box.",
("backups_restore_wizard.html", "Az alkalmazás beállításait és adatbázisát hozza vissza"):
"describes what the verification restore DOES, into a separate folder, on the page where it "
"is pressed. Nothing about a recovery code.",
("backups_restore_wizard.html", "<strong>nem</strong> hozza vissza"):
"a NEGATIVE: says the verification restore does NOT bring back the household's own files.",
("recovery.html", "melyik alkalmazás mit hozzon vissza"):
"points to the restore page, where the household picks what each app brings back from a "
"store the box has just opened. A route, not a promise.",
}
# ── ENGLISH (localisation slice 1, R-556) ─────────────────────────────────────────────────────────
# The same claim, in the English bundle. Until slice 1 this gate read the Hungarian expansion only, so
# an English promise was unscanned — measured: a planted 'Your old backups can be restored at any
# time.' in en.json passed. English has no single stem; these are the phrasings of "it can be got
# back". Plain nouns and imperatives (Restore, Restoring..., Restore checked) are NOT claims and
# are deliberately not matched. Scanned in the ENGLISH expansion of every template.
EN_PATTERNS = [
r"\brestorable\b", r"\bcan (?:still )?be restored\b", r"\bcan (?:still )?restore\b",
r"\b(?:get|bring|getting|bringing) (?:them |it |these |those |your \w+ )?back\b",
r"\brecoverable\b", r"\bcan (?:still )?be recovered\b", r"\bcan (?:still )?recover\b",
r"\bretrievable\b", r"\bcan (?:still )?(?:be )?retrieved?\b", r"\breopen",
]
EN_RE = re.compile("|".join(EN_PATTERNS), re.I)
# English registrations — each mirrors a Hungarian entry above and carries its reason by reference.
ALLOWLIST_EN = {
("backups.html", "that can bring back the entire device"):
"= backups.html 'amelyből az egész készülék visszaállítható': the LOCAL whole-device backup.",
("layout.html", "Until then you can still get them back with your recovery code."):
"= layout.html 'Addig még visszaszerezheted őket...': R-302's TRUE branch.",
("layout.html", "whether your recovery code can still get them back"):
"= layout.html 'Hogy ezek még visszaszerezhetők-e': the cautious QUESTION branch.",
# slice 1 release B — mirrors of registered Hungarian claims
("backups_apps.html", "it can be restored per app"):
"= backups_apps.html 'alkalmazásonként visszaállítható': per-app LOCAL restore, no recovery code.",
("backups_apps.html", "Removed — restorable"):
"= backups_apps.html 'Eltávolítva — visszaállítható' (R-487): the kept unit on a connected drive.",
("backups_remote.html", "Your earlier off-site backups can be retrieved.</strong>"):
"= backups_remote.html 'mentéseid visszaszerezhetők.</strong>': the RecoveryOffer entry point, gated on the hub.",
("backups_remote.html", "your backups can be retrieved, and the deletion is cancelled."):
"= backups_remote.html 'a mentéseid visszaszerezhetők, és a törlés elmarad.': R-302 TRUE branch.",
("backups_remote.html", "whether your recovery code can still get them back"):
"= backups_remote.html 'Hogy ezek még visszaszerezhetők-e': the QUESTION branch.",
("recovery.html", "your backups can be retrieved, and the deletion is cancelled;"):
"= recovery.html 'a mentéseid visszaszerezhetők, és a törlés elmarad;': the confirmation screen.",
# slice 1 release B — English claims whose HUNGARIAN escaped the Hungarian stems (split verbs:
# „állíthatók vissza", „hozod vissza"). The English gate saw them first; since R-564 the Hungarian
# scan sees them too (SPLIT_PATTERNS) and registers each one above. Each is registered on its own merits:
("backups_remote.html", "Remote backups can be restored after a complete failure only if you create the recovery code."):
"a PRECONDITION, not a promise: restorable only IF the code exists — true by the escrow design (hu: "
"'csak akkor állíthatók vissza … ha létrehozza').",
("backups_escrow.html", "the remote backups can be restored ONLY"):
"the same precondition stated on the ceremony page, restrictive by construction (hu: 'CSAK ezzel a "
"kóddal állíthatók vissza').",
("backups_restore.html", "bringing back only the missing files"):
"describes the three actions offered on the NEXT page for an app with a backup in the store; each is "
"gated on the store's own listing, no recovery code involved.",
("backups_restore_wizard.html", "whether to bring back only the missing files"):
"the wizard's own choice, rendered after preparation; the action beside it is what it names.",
("backups_restore_wizard.html", "does <strong>not</strong> bring back your own files"):
"a NEGATIVE statement — says what the check does not restore.",
("backups_restore_wizard.html", "2. Bring back missing files"):
"a heading for the action directly below it.",
("backups_restore_wizard.html", "Bring back missing files</button>"):
"the button label of that action.",
}
TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
# R-421 (2026-09-01): any depth, was os.listdir (one level). No template subdirectory exists
# today, so this was green and correct — and would have stayed green the moment anyone added
# templates/partials/. Measured: a planted file there passed every listdir-based gate.
def _html_at_any_depth(root):
out = []
for dirpath, _dirs, names in os.walk(root):
for fn in sorted(names):
if fn.endswith('.html'):
out.append(os.path.join(dirpath, fn))
return sorted(out)
def scan():
convictions, seen_keys = [], set()
files = [os.path.relpath(x, TEMPLATES) for x in _html_at_any_depth(TEMPLATES)]
sources = [(f, os.path.join(TEMPLATES, f), TEMPLATE_COMMENT) for f in files]
for gp in GO_SOURCES:
if not os.path.exists(gp):
raise SystemExit(f"retrieval-promise gate: declared Go source is missing: {gp}")
sources.append((os.path.basename(gp), gp, GO_COMMENT))
files = files + [os.path.basename(gp)]
for name, path, stripper in sources:
# v0.247.0: a template's copy lives in the i18n bundle; judge the page as it renders in
# Hungarian (the stems are Hungarian). English retrieval claims are not scanned yet -- R-row
# in 10-localisation.md; the English bundle covers three pages today.
raw = open(path, encoding="utf-8").read()
text = stripper.sub("", i18n_bundle.expand(raw, "hu") if path.endswith(".html") else raw)
if True:
for m in HU_RE.finditer(text):
line = text[: m.start()].count("\n") + 1
# SPAN-based, not window-based. The promise and the cautious disclaimer sit within a
# hundred characters of each other in the same paragraph, so a proximity window matches
# whichever key it tries first and reports the other as stale — which is exactly what a
# first draft of this gate did. An occurrence belongs to an entry only if it falls
# INSIDE that entry's own 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_keys.add(hit)
else:
ctx = text[max(0, m.start() - 100): m.end() + 100]
convictions.append((name, line, m.group(0), " ".join(ctx.split())[:160]))
# English: every template's English expansion, the same span-based registration.
for name, path, stripper in sources:
if not path.endswith(".html"):
continue
text = stripper.sub("", i18n_bundle.expand(open(path, encoding="utf-8").read(), "en"))
# Template actions carry English FIELD names (.Restorable) — code, not copy. Blanked, keeping
# line numbers.
text = re.sub(r"\{\{.*?\}\}", lambda m: re.sub(r"[^\n]", " ", m.group(0)), text, flags=re.S)
for m in EN_RE.finditer(text):
line = text[: m.start()].count("\n") + 1
hit = None
for k in (k for k in ALLOWLIST_EN 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_keys.add(("en",) + hit)
else:
ctx = text[max(0, m.start() - 100): m.end() + 100]
convictions.append((name + " [en]", line, m.group(0), " ".join(ctx.split())[:160]))
return files, convictions, seen_keys
def main():
files, convictions, seen = scan()
stale = [k for k in ALLOWLIST if k not in seen] + [k for k in ALLOWLIST_EN if ("en",) + k not in seen]
for name, line, word, ctx in convictions:
print(f" {name}:{line} unregistered retrieval claim ({word}):\n …{ctx}…")
for k in stale:
print(f" STALE ALLOWLIST ENTRY (no longer present): {k[0]} :: {k[1]!r}")
if convictions or stale:
print(f"\nRETRIEVAL-PROMISE GATE FAILED: {len(convictions)} unregistered, {len(stale)} stale, "
f"across {len(files)} template(s).")
print("Five instances of this claim have surfaced one at a time. If the new text is a genuine")
print("claim, make it CONDITIONAL on what the box can see; if it is a question about")
print("knowability, or an unrelated local-backup sentence, add it to ALLOWLIST with the reason.")
return 1
print(f"retrieval-promise gate OK — {len(files)} surface(s) incl. {len(GO_SOURCES)} Go handler file(s), "
f"{len(ALLOWLIST)} registered claim(s) + {len(ALLOWLIST_EN)} English, none unregistered")
print(" (BLIND SPOT: it registers WHERE the claim is made, not whether each conditional is wired")
print(" to a true predicate — that is what the R-302 render tests are for.)")
return 0
if __name__ == "__main__":
sys.exit(main())