diff --git a/documentation/audits/I18N-INVENTORY-2026-09-17.md b/documentation/audits/I18N-INVENTORY-2026-09-17.md
new file mode 100644
index 00000000..642d0e5e
--- /dev/null
+++ b/documentation/audits/I18N-INVENTORY-2026-09-17.md
@@ -0,0 +1,220 @@
+# I18N INVENTORY — every Hungarian string a household meets, counted (2026-09-17)
+
+**Run class:** spike, Phase 0 of the localisation starter. **Operator:** Claude Code on DooPlex.
+**Baselines:** felhom-controller `89dd3e94b1de` (v0.246.0) · felhom-agent `d9864a94bf62` (v0.132.0) ·
+felhom.eu `851198af7ff0` (hub v0.117.0) · app-catalog-felhom.eu `94bc5febaca2`.
+**Instrument:** `scripts/i18n_inventory.py`, sha256
+`be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c`. Raw output and per-string JSON:
+`audits/i18n-2026-09-17/inventory.md`, `inventory.json`.
+**What was counted:** the controller at its baseline commit (a clean worktree of `origin/main`, so the
+spike's own conversion is NOT in these numbers), the other three repos at their baselines.
+**Design this feeds:** `architecture/10-localisation.md`.
+
+---
+
+## 0. Claims in the starter prompt that live source disproved
+
+Named first, as the prompt asked. Four were already named in its §1; these are further ones.
+
+1. **„`fmtTime`, `fmtBytes`, `timeAgo` — none of these exist. The FuncMap has one size helper,
+ `fmtMB`."** — **Wrong, and it is the claim that matters most for localisation.** All three exist
+ (`internal/web/funcmap.go` `timeAgo` L265, `fmtTime` L318, `fmtBytes` L403), and so do **nine more
+ helpers whose output is copy or locale-shaped**: `fmtGB`, `humanBytes`, `timeAgoStr`, `fmtTimeStr`,
+ `fmtTimeShort`, `fmtDuration`, `nextRunLabel` („ma"/„holnap"), `pruneLabel` („vasárnap"/„naponta"),
+ `nextPruneLabel`, `statusText`, and `stateLabel` (the app-state words). There are **four** size
+ helpers (`fmtMB`, `fmtGB`, `fmtBytes`, `humanBytes`), not one. `timeAgo` builds Hungarian with `%d`
+ („%d perce") — the plural problem lives there.
+2. **„`fmtMB`, used 10× in templates"** — **8×** (`grep -c` over templates, and the script agrees).
+3. **„~13 `.Format(` calls in `internal/web/*.go` (`2006-01-02` ×6, `2006-01-02 15:04` ×4, `15:04` ×2,
+ `01-02 15:04` ×1)"** — at the baseline: `2006-01-02` **×4**, `2006-01-02 15:04` **×3**, `15:04` **×2**,
+ `01-02 15:04` ×1 = **10** layout literals, plus **8** `time.RFC3339` (machine, locale-free). The two
+ template layouts are as stated and do disagree (`2006. 01. 02. 15:04` vs `2006-01-02 15:04`).
+4. **Template line count „1 613 (afternoon) / ~1 838 (morning)"** — **1 553**, by two independent methods
+ (this script, and `grep -cP` on the concatenated templates under `LC_ALL=C.UTF-8`). Neither prompt
+ figure was reproduced. Tried: adding typographic quotes „ ” (1 556), adding dashes and ellipsis
+ – — … (1 751), counting `style.css` too (1 559). The afternoon figure is **+60 (3.9 %)** and its method
+ is unknown; the morning figure is closest to the dash-inclusive variant. **Lines are the wrong unit
+ anyway** — see §1: the string count is 1 867.
+5. **Go literal count „1 234 in 99 files (afternoon) / ~1 971 in 125 (morning)"** — **1 141 literals in
+ 84 files** by a Go lexer (comments skipped), first-boot wizard excluded. A `grep` of quoted Hungarian
+ on non-comment lines gives 1 106 lines in 85 files, so the two methods agree. Counting lines that
+ hold an accented letter **including comments** gives 1 360 in 107 files; including the wizard and
+ `cmd/`, 1 375 in 108. The prompt's figures sit between the comment-free and comment-inclusive
+ counts; **comments explain most of the gap** (Hungarian quoted in Go comments is common here).
+6. **Console banner „33 Hungarian lines"** — 33 by accented letter, **34** once ASCII-only Hungarian
+ words are counted. Not an error; a method difference, stated.
+
+Confirmed as the prompt states: 36 page templates + `style.css`; 33 carry Hungarian; the second
+template set and its one helper (`internal/setup/handlers.go` L79 `timeNow`); 8 wizard pages, **86**
+Hungarian lines; `customerMessages` in the hub; 53 catalog files with `first_steps` under `app_info`;
+no i18n library, no `Accept-Language`, no bundle anywhere.
+
+---
+
+## 1. Controls — the counts below mean something
+
+Hungarian is matched by accented letter (the letters written as `\u` escapes, so the script itself is
+ASCII) **or** by one of 46 ASCII-only Hungarian words („Fut", „Nincs", „Hiba", „Csak", „Megszakadt"…),
+case-insensitive. Every surface prints a positive control (an ASCII fragment of a Hungarian word known
+to be there, whose lines the matcher must flag) and a negative control (a fragment known absent; an
+English sentence that must not be flagged).
+
+| surface | positive fragment | lines holding it | flagged | negative hits | English line flagged | verdict |
+|---|---|---|---|---|---|---|
+| controller templates | `pult` | 20 | 20 | 0 | no | ok |
+| controller Go | `ment` | 832 | 278 | 0 | no | ok |
+| hub customer Go | `ment` | 35 | 18 | 0 | no | ok |
+| installer banner | `Felhom` | 10 | 5 | 0 | no | ok |
+| catalog | `Alkalmaz` | 5 | 5 | 0 | no | ok |
+| guide | `Felhom` | 14 | 13 | 0 | no | ok |
+
+**Measured limit of the ASCII word list.** The spike's conversion tool first used a 40-word list and
+**missed eight Hungarian strings on the three pages** („Megszakadt:", „Elavult", „Csak x86",
+„Pi kompatibilis", „Rendszermonitor", „Most nem", „sikertelen", „ismeretlen"); a by-eye review of what
+was left found them, and they were added. **An ASCII-only Hungarian word that is not on the list is
+still invisible to every count here.** The counts below are therefore floors, not totals.
+
+---
+
+## 2. The tables
+
+### 2.1 Controller page templates (`internal/web/templates/`, 36 files)
+
+**1 867 strings: 1 161 text runs + 88 attribute values + 618 inline-JS string literals.** 1 553 lines
+carry an accented letter. A *run* is text up to the next block tag or control action — the unit a
+translator needs.
+
+| template | HU lines | text | attr | JS |
+|---|---|---|---|---|
+| deploy.html | 156 | 87 | 8 | 77 |
+| storage.html | 110 | 43 | 9 | 98 |
+| debug.html | 92 | 41 | 16 | 79 |
+| layout.html (on every page) | 88 | 35 | 1 | 70 |
+| backups_remote.html | 77 | 87 | 0 | 10 |
+| settings_system.html | 84 | 58 | 0 | 36 |
+| backups_apps.html | 80 | 79 | 7 | 6 |
+| … 26 more | | | | |
+
+Full per-file table: `audits/i18n-2026-09-17/inventory.md`. **One third of all template copy (618) is
+inside `", re.S | re.I)
+STYLE_RE = re.compile(r"", re.S | re.I)
+TAG_RE = re.compile(r"<[a-zA-Z!/][^>]*>", re.S)
+ATTR_RE = re.compile(r'\s([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*"([^"]*)"', re.S)
+JS_STR_RE = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"")
+NUM_PARAM_RE = re.compile(r"\{\{\s*(len\b|\.\w*(Count|Num|Total|Days|Hours|Minutes|Size|N)\b)")
+
+
+def line_of(src: str, pos: int) -> int:
+ return src.count("\n", 0, pos) + 1
+
+
+def scan_template(f: Path, root: Path):
+ src = read(f)
+ rel = str(f.relative_to(root))
+ out = {"file": rel, "hu_lines": sum(1 for l in src.split("\n") if has_hu(l)),
+ "text": [], "attr": [], "js": [], "compare": [], "numeric": []}
+
+ # Mask script/style bodies (keep newlines so line numbers survive).
+ masked = src
+ for m in list(SCRIPT_RE.finditer(src)):
+ for jm in JS_STR_RE.finditer(m.group(1)):
+ s = jm.group(0)[1:-1]
+ if is_hu_text(ACTION_RE.sub("", s)):
+ out["js"].append({"line": line_of(src, m.start(1) + jm.start()), "text": s[:160]})
+ def blank(m):
+ return re.sub(r"[^\n]", " ", m.group(0))
+ masked = SCRIPT_RE.sub(blank, masked)
+ masked = STYLE_RE.sub(blank, masked)
+
+ # A compare is an eq/ne action whose OWN string arguments are Hungarian -- parsed inside the one
+ # action, never across the text between two actions.
+ for m in ACTION_RE.finditer(masked):
+ act = m.group(0)
+ if not re.search(r"\b(eq|ne)\b", act):
+ continue
+ for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', act):
+ if is_hu_text(lit):
+ out["compare"].append({"line": line_of(src, m.start()), "text": lit})
+
+ for tm in TAG_RE.finditer(masked):
+ for am in ATTR_RE.finditer(tm.group(0)):
+ name, val = am.group(1).lower(), am.group(2)
+ if name in ("class", "id", "href", "src", "style", "name", "type", "for", "action", "method"):
+ continue
+ if is_hu_text(ACTION_RE.sub("", val)):
+ out["attr"].append({"line": line_of(src, tm.start()), "attr": name, "text": val[:160]})
+
+ # Text nodes: what lies between tags, split at control actions (if/range/...), with the
+ # remaining value actions kept in the message (they are its parameters).
+ text_only = TAG_RE.sub(lambda m: "\x00" + re.sub(r"[^\n]", " ", m.group(0))[1:], masked)
+ pos = 0
+ for chunk in text_only.split("\x00"):
+ start = pos
+ pos += len(chunk) + 1
+ for piece in CONTROL_RE.split(chunk):
+ if piece is None:
+ continue
+ p = piece.strip()
+ if not p or p in ("if", "else", "end", "range", "with", "define", "block", "template"):
+ continue
+ visible = ACTION_RE.sub("", p).strip()
+ if visible and is_hu_text(visible):
+ ln = line_of(src, start + max(chunk.find(p[:20]), 0))
+ out["text"].append({"line": ln, "text": " ".join(p.split())[:160]})
+ if NUM_PARAM_RE.search(p):
+ out["numeric"].append({"line": ln, "text": " ".join(p.split())[:160]})
+ return out
+
+
+# ---------------------------------------------------------------------------------------------------
+# Other surfaces
+# ---------------------------------------------------------------------------------------------------
+
+def walk_yaml(node, path=""):
+ if isinstance(node, dict):
+ for k, v in node.items():
+ yield from walk_yaml(v, f"{path}.{k}" if path else str(k))
+ elif isinstance(node, list):
+ for v in node:
+ yield from walk_yaml(v, path + "[]")
+ elif isinstance(node, str):
+ yield path, node
+
+
+def controls(name: str, blobs: list[str], pos_fragment: str):
+ """Positive: pos_fragment (ASCII) occurs, and some line holding it is flagged Hungarian.
+ Negative: NEG_FRAGMENT occurs nowhere, and an ASCII English line is not flagged."""
+ pos_lines = [l for b in blobs for l in b.split("\n") if pos_fragment in l]
+ pos_flagged = sum(1 for l in pos_lines if is_hu_text(l))
+ neg = sum(b.count(NEG_FRAGMENT) for b in blobs)
+ ok = len(pos_lines) > 0 and pos_flagged > 0 and neg == 0 and not is_hu_text(NEG_ASCII_LINE)
+ return {"surface": name, "positive_fragment": pos_fragment, "positive_lines": len(pos_lines),
+ "positive_flagged": pos_flagged, "negative_fragment": NEG_FRAGMENT, "negative_hits": neg,
+ "negative_ascii_line_flagged": is_hu_text(NEG_ASCII_LINE), "ok": ok}
+
+
+def top10(strings):
+ c = collections.Counter(" ".join(s.split()) for s in strings)
+ return [(t, n) for t, n in c.most_common(10) if n > 1]
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--root", default="/mnt/5_hdd/felhom.eu/git")
+ ap.add_argument("--json", default=None)
+ args = ap.parse_args()
+ root = Path(args.root)
+ ctl = root / "felhom-controller" / "controller"
+ hub = root / "felhom.eu" / "hub"
+ report = {"script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "surfaces": {}}
+ ctrls = []
+
+ # 1-3. Controller templates (+ inline JS) -------------------------------------------------------
+ tdir = ctl / "internal" / "web" / "templates"
+ tfiles = sorted(tdir.glob("*.html"))
+ tres = [scan_template(f, root) for f in tfiles]
+ report["surfaces"]["templates"] = tres
+ ctrls.append(controls("controller templates", [read(f) for f in tfiles], "pult"))
+
+ # 2. Controller Go (non-test), excluding the first-boot wizard ------------------------------------
+ gofiles = sorted(p for p in ctl.rglob("*.go")
+ if not p.name.endswith("_test.go") and "/internal/setup/" not in str(p))
+ gores = scan_go_files(gofiles, root)
+ report["surfaces"]["controller_go"] = gores
+ indirect = go_indirect_compares(gofiles, root)
+ report["surfaces"]["controller_go_indirect_compares"] = indirect
+ ctrls.append(controls("controller Go", [read(f) for f in gofiles], "ment"))
+
+ # 4. Hub customer surfaces ------------------------------------------------------------------------
+ hub_customer = sorted(list((hub / "internal" / "notify").glob("*.go"))
+ + list((hub / "internal" / "web").glob("selfbind*.go"))
+ + [hub / "internal" / "web" / "customer_reset.go", hub / "internal" / "claim" / "engine.go"])
+ hub_customer = [p for p in hub_customer if p.exists() and not p.name.endswith("_test.go")]
+ hubres = scan_go_files(hub_customer, root)
+ report["surfaces"]["hub_customer_go"] = hubres
+ hub_all = sorted(p for p in hub.rglob("*.go") if not p.name.endswith("_test.go"))
+ hub_all_rows = scan_go_files(hub_all, root)
+ hub_tpl = [scan_template(f, root) for f in sorted((hub / "internal" / "web" / "templates").glob("*.html"))]
+ report["surfaces"]["hub_operator_templates"] = hub_tpl
+ ctrls.append(controls("hub customer Go", [read(f) for f in hub_customer], "ment"))
+
+ # 5. Installer ------------------------------------------------------------------------------------
+ banner = root / "felhom.eu" / "scripts" / "iso" / "felhom-bootstrap.sh"
+ btext = read(banner)
+ banner_lines = [{"line": i + 1, "text": l.strip()[:160]} for i, l in enumerate(btext.split("\n")) if is_hu_text(l)]
+ dl = root / "felhom.eu" / "website" / "letoltes.html"
+ dlres = scan_template(dl, root) if dl.exists() else None
+ report["surfaces"]["installer_banner"] = banner_lines
+ report["surfaces"]["download_page"] = dlres
+ ctrls.append(controls("installer banner", [btext], "Felhom"))
+
+ # 6. Catalog --------------------------------------------------------------------------------------
+ cat = []
+ catfiles = sorted((root / "app-catalog-felhom.eu" / "templates").glob("*/.felhom.yml"))
+ for f in catfiles:
+ try:
+ doc = yaml.safe_load(read(f)) or {}
+ except yaml.YAMLError as e:
+ cat.append({"file": str(f.relative_to(root)), "path": "", "text": str(e)[:120]})
+ continue
+ for p, v in walk_yaml(doc):
+ if is_hu_text(v):
+ cat.append({"file": str(f.relative_to(root)), "path": re.sub(r"\[\]", "[]", p), "text": v[:160],
+ "words": len(v.split())})
+ report["surfaces"]["catalog"] = cat
+ ctrls.append(controls("catalog", [read(f) for f in catfiles], "Alkalmaz"))
+
+ # 7. Guide ----------------------------------------------------------------------------------------
+ guide = root / "felhom.eu" / "documentation" / "runbooks" / "VOLUNTEER-first-hour.md"
+ gtext = read(guide)
+ paras = [p for p in re.split(r"\n\s*\n", gtext) if p.strip()]
+ report["surfaces"]["guide"] = {"file": str(guide.relative_to(root)), "paragraphs": len(paras),
+ "hu_paragraphs": sum(1 for p in paras if is_hu_text(p)),
+ "words": len(gtext.split())}
+ ctrls.append(controls("guide", [gtext], "Felhom"))
+
+ # 8. First-boot wizard ----------------------------------------------------------------------------
+ sdir = ctl / "internal" / "setup"
+ stpl = [scan_template(f, root) for f in sorted((sdir / "templates").glob("*.html"))]
+ sgo = scan_go_files(sorted(p for p in sdir.rglob("*.go") if not p.name.endswith("_test.go")), root)
+ report["surfaces"]["setup_wizard"] = {"templates": stpl, "go": sgo}
+
+ # Dates and sizes ---------------------------------------------------------------------------------
+ fmt_sites = []
+ for f in sorted((ctl / "internal" / "web").glob("*.go")):
+ if f.name.endswith("_test.go"):
+ continue
+ for i, l in enumerate(read(f).split("\n"), 1):
+ for m in re.finditer(r'\.Format\("([^"]*)"\)', l):
+ fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1)})
+ for f in tfiles:
+ for i, l in enumerate(read(f).split("\n"), 1):
+ for m in re.finditer(r'\.Format\s+"([^"]*)"|Format\s+"([^"]*)"', l):
+ fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1) or m.group(2)})
+ fmtmb = []
+ for f in tfiles + sorted((ctl / "internal" / "web").glob("*.go")):
+ if f.name.endswith("_test.go"):
+ continue
+ for i, l in enumerate(read(f).split("\n"), 1):
+ if "fmtMB" in l:
+ fmtmb.append({"file": str(f.relative_to(root)), "line": i})
+ ctl_fmt_elsewhere = 0
+ for f in ctl.rglob("*.go"):
+ if f.name.endswith("_test.go") or "/internal/web/" in str(f):
+ continue
+ ctl_fmt_elsewhere += len(re.findall(r'\.Format\("', read(f)))
+ report["dates"] = fmt_sites
+ report["fmtMB"] = fmtmb
+ report["format_calls_outside_web"] = ctl_fmt_elsewhere
+ report["controls"] = ctrls
+
+ # ---------------------------------------------------------------------------------------------
+ # Markdown summary
+ # ---------------------------------------------------------------------------------------------
+ P = print
+ P(f"script sha256: {report['script_sha256']}\n")
+ P("## Controls (ASCII fragment, positive + negative)\n")
+ P("| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |")
+ P("|---|---|---|---|---|---|---|")
+ for c in ctrls:
+ P(f"| {c['surface']} | `{c['positive_fragment']}` | {c['positive_lines']} | {c['positive_flagged']} | "
+ f"{c['negative_hits']} | {c['negative_ascii_line_flagged']} | {'ok' if c['ok'] else 'BROKEN'} |")
+
+ def tsum(rs):
+ return (sum(r["hu_lines"] for r in rs), sum(len(r["text"]) for r in rs), sum(len(r["attr"]) for r in rs),
+ sum(len(r["js"]) for r in rs), sum(len(r["compare"]) for r in rs), sum(len(r["numeric"]) for r in rs),
+ sum(1 for r in rs if r["hu_lines"] or r["text"] or r["attr"] or r["js"]))
+
+ P("\n## Controller page templates\n")
+ hl, tx, at, js, cm, nu, withhu = tsum(tres)
+ P(f"{len(tres)} files; {withhu} carry Hungarian. Lines with an accented letter: **{hl}**. "
+ f"Strings: **{tx} text runs + {at} attribute values + {js} inline-JS literals = {tx + at + js}**. "
+ f"Template compares against Hungarian: {cm}. Text runs with a numeric parameter: {nu}.\n")
+ P("| template | HU lines | text | attr | JS | compare |")
+ P("|---|---|---|---|---|---|")
+ for r in sorted(tres, key=lambda r: -(len(r["text"]) + len(r["attr"]) + len(r["js"]))):
+ P(f"| {Path(r['file']).name} | {r['hu_lines']} | {len(r['text'])} | {len(r['attr'])} | {len(r['js'])} | {len(r['compare'])} |")
+ P("\nMost repeated (text + attr):")
+ for t, n in top10([x["text"] for r in tres for x in r["text"] + r["attr"]]):
+ P(f"- {n}x `{t}`")
+
+ def gosum(rows, label):
+ k = collections.Counter(r["kind"] for r in rows)
+ files = len({r["file"] for r in rows})
+ P(f"\n## {label}\n")
+ P(f"**{len(rows)} literals in {files} files.** shown: {k['shown']}, error (may reach a page): {k['error']}, "
+ f"log only: {k['log']}, compared: {k['compare']}. Format strings with parameters (non-log): "
+ f"{sum(1 for r in rows if r['format'] and r['kind'] != 'log')}; numeric %d (non-log, plural risk): "
+ f"{sum(1 for r in rows if r['numeric'] and r['kind'] != 'log')}; concatenated (non-log): "
+ f"{sum(1 for r in rows if r['concat'] and r['kind'] != 'log')}; raw multi-line blocks: "
+ f"{sum(1 for r in rows if r['multiline'])}.\n")
+ byfile = collections.Counter(r["file"] for r in rows if r["kind"] != "log")
+ P("| file (top 15, non-log) | literals |")
+ P("|---|---|")
+ for fname, n in byfile.most_common(15):
+ P(f"| {fname} | {n} |")
+ P("\nMost repeated (non-log):")
+ for t, n in top10([r["text"] for r in rows if r["kind"] != "log"]):
+ P(f"- {n}x `{t}`")
+ return k
+
+ gosum(gores, "Controller Go (non-test, first-boot wizard excluded)")
+ P("\n### Compared, not shown (controller Go)\n")
+ for r in gores:
+ if r["kind"] == "compare":
+ P(f"- {r['file']}:{r['line']} `{r['text']}`")
+ for r in indirect:
+ P(f"- {r['file']}:{r['line']} via `{r['name']}` (defined {r['defined']}) `{r['text']}`")
+ P("\n### Compared, not shown (templates)\n")
+ for r in tres:
+ for x in r["compare"]:
+ P(f"- {r['file']}:{x['line']} `{x['text']}`")
+
+ gosum(hubres, "Hub customer surfaces (notify, self-bind, reset, claim)")
+ hk = collections.Counter(r["kind"] for r in hub_all_rows)
+ hl2, tx2, at2, js2, _, _, _ = tsum(hub_tpl)
+ P(f"\nFor scale, NOT in scope: all hub Go non-test = {len(hub_all_rows)} literals "
+ f"(shown {hk['shown']}, error {hk['error']}, log {hk['log']}, compare {hk['compare']}); "
+ f"hub operator templates = {tx2 + at2 + js2} strings, {hl2} HU lines.")
+
+ P("\n## Installer\n")
+ P(f"Console banner `{banner.relative_to(root)}`: **{len(banner_lines)}** Hungarian lines.")
+ if dlres:
+ P(f"Download page `{dl.relative_to(root)}`: {len(dlres['text'])} text + {len(dlres['attr'])} attr + "
+ f"{len(dlres['js'])} JS = **{len(dlres['text']) + len(dlres['attr']) + len(dlres['js'])}** strings, "
+ f"{dlres['hu_lines']} HU lines.")
+
+ P("\n## Catalog\n")
+ byfield = collections.Counter(re.sub(r"^.*?\b(app_info\.\w+|deploy_fields\[\]\.\w+|\w+)$", r"\1", r["path"]) for r in cat)
+ apps = len({r["file"] for r in cat})
+ words = sum(r.get("words", 0) for r in cat)
+ P(f"{len(catfiles)} `.felhom.yml`; {apps} carry Hungarian; **{len(cat)} strings, ~{words} words**.\n")
+ P("| field | strings |")
+ P("|---|---|")
+ for fld, n in byfield.most_common(20):
+ P(f"| {fld} | {n} |")
+
+ g = report["surfaces"]["guide"]
+ P(f"\n## Guide\n\n`{g['file']}`: {g['paragraphs']} paragraphs ({g['hu_paragraphs']} Hungarian), ~{g['words']} words.")
+
+ P("\n## First-boot wizard (reported separately -- never merged into the dashboard count)\n")
+ shl, stx, sat, sjs, scm, _, _ = tsum(stpl)
+ sk = collections.Counter(r["kind"] for r in sgo)
+ P(f"{len(stpl)} templates: {shl} HU lines; {stx} text + {sat} attr + {sjs} JS = **{stx + sat + sjs}** strings. "
+ f"Go: {len(sgo)} literals (shown {sk['shown']}, error {sk['error']}, log {sk['log']}, compare {sk['compare']}).")
+
+ P("\n## Dates and sizes\n")
+ lay = collections.Counter((Path(x["file"]).suffix, x["layout"]) for x in fmt_sites)
+ P("| where | layout | sites |")
+ P("|---|---|---|")
+ for (suf, l), n in sorted(lay.items(), key=lambda kv: -kv[1]):
+ P(f"| {'template' if suf == '.html' else 'internal/web Go'} | `{l}` | {n} |")
+ P(f"\n`fmtMB` sites: {sum(1 for x in fmtmb if x['file'].endswith('.html'))} in templates, "
+ f"{sum(1 for x in fmtmb if x['file'].endswith('.go'))} in Go. `.Format(\"` calls in the controller outside "
+ f"internal/web: {ctl_fmt_elsewhere}.")
+
+ if args.json:
+ Path(args.json).write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
+ bad = [c for c in ctrls if not c["ok"]]
+ if bad:
+ P("\nCONTROLS BROKEN: " + ", ".join(c["surface"] for c in bad), file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/wire_contract_gate.py b/scripts/wire_contract_gate.py
index a5bde6c5..7313ff31 100644
--- a/scripts/wire_contract_gate.py
+++ b/scripts/wire_contract_gate.py
@@ -208,6 +208,14 @@ ALLOWLIST = {
"reached, never on a skip, a missing snapshot or a restore error, so 'presence is not success' "
"does not bite here. **Delete with its siblings.**"),
+ (_CH, "language"): (
+ "controller v0.247.0 (i18n spike): the household's dashboard language, published so the hub's "
+ "customer e-mails can follow it — plan slice 3 in documentation/architecture/10-localisation.md. "
+ "No hub release reads it yet; the hub stores the raw report, so it is visible there. RECORDED "
+ "HERE ALTHOUGH THE GATE PASSES WITHOUT IT: the token `language` occurs hub-side only inside a "
+ "Go COMMENT (hub/internal/web/configs.go), and receiver_tokens() does not strip comments — the "
+ "row filed 2026-09-17 for that blind spot names this field as its found case. **Delete this "
+ "entry when slice 3 reads the field.**"),
(_CH, "offsite.last_integrity_depth"): (
"R-399, controller v0.228.0: the DEPTH the verdict was reached at, published beside "
"last_integrity_ok above and unconsumed for the identical reason. It matters because 'checked, "