slice 4: bilingual console + the English download page is live (R-559)

The image is BUILT but NOT PUBLISHED, and that is deliberate: publishing to
iso.felhom.eu is public and irreversible, and the runbook needs a proof install
on BOTH menu entries plus a reboot against the uploaded bytes. That is
supervised, so I stopped there. felhom-installer-1.29.0-pve9.2-1.iso, sha256
c67ceaa3…fb02, with every mechanically checkable criterion passing (G1, G2, G5,
G6, G7, G9, G16 — including both payload files byte-identical to repo HEAD).

The download pages still name 1.28.0, the image that IS published. Pointing
them at a file that is not there would hand every reader a 404. A new site gate
refuses the two pages naming different installer files or checksums, so
whoever publishes 1.29.0 cannot update one and forget the other.

Measured rather than read: the pairing banner is 24 rows on a 25-row console.
One row of margin — so the height is now pinned, because two more lines push
the HUNGARIAN code at row 5 off the top, and a banner whose code has scrolled
away is furniture.

R-587: two root-password files from July sit in the directory the public ISO is
published from. Both 404 on the bucket (against a 200 control), so nothing
leaked — but the only thing keeping them off is an --include pattern they miss
by an accident of naming. A pattern that protects by coincidence is not a
control.

R-588: release records live in two different places, which made me wrongly
conclude 1.28.0's gate had never been run. It had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 17:47:58 +02:00
parent eb1ae37095
commit 5a654ebc9a
10 changed files with 267 additions and 82 deletions
+55 -77
View File
@@ -1,99 +1,77 @@
# REPORT — localisation slice 3 Part A: the hub's e-mails follow the household's language
# REPORT — slice 4: the console banner and the download page (R-559)
**hub v0.118.0** · felhom.eu base `20aafc3dec20` · 2026-09-18 · R-558 (Part A), R-555 closed
**ISO 1.29.0 source + the English download page** · base `183727db9c44` · 2026-09-18
## Claims in the task that turned out wrong, named first
1. **"`customerMessages` 40 entries (L70)"** — **39 entries, at L69.**
2. **"`customers.language`, `appliances.reported_language`"** — **neither table exists.** There is no
`customers` table and no `appliances` table. The real ones are **`customer_configs`** (the customer
record) and **`reports`** (the box's heartbeat, one row per report). The columns added are
`customer_configs.language` and `reports.language`.
3. **"no `ALTER TABLE customers ADD` found by grep — the migration pattern is something else; if it
is not obvious, stop and report."** The pattern is completely obvious and there are ~20 instances:
`s.db.Exec("ALTER TABLE <t> ADD COLUMN <c> <type> NOT NULL DEFAULT <v>")`, error deliberately
ignored so it is idempotent. The grep failed only because it named a table that does not exist.
4. **"`renderBackupRunFailures` gains a `lang` parameter"** — it is **operator-only and already
English**. Its single caller is `FormatOperatorEmail`. Untouched.
5. **"`message_customer`'s length is capped like `message`"** — **`message` has no length cap.** Both
are bounded only by the 1 MB `LimitReader` on the request body. I wrote a cap, then removed it: a
byte-count truncation would also cut a UTF-8 sequence in half.
6. **"`python3 scripts/hub_gates.py` (or the runner the hub uses — name it)"** — there is no
`hub_gates.py`. The hub's gates run from **`scripts/repo_gates.py`**, this repo's single runner.
7. **Line numbers** were mostly off by one or two (`customerMessages` 69 not 70, `severityLabels` 158
not 159, `FormatCustomerEmail` 166 not 167).
1. **"the GRUB menu has one entry"** — the **release** image has **two** (graphical + text mode). The
single-entry template is the appliance image. Both updated.
2. **"27 Hungarian printf lines"** — **25**.
3. **"No test compares the banner text itself"** — one greps the painted banner, and another `cmp`s
`/etc/issue` against the `postinst`'s copy **byte for byte**. The console seam already existed too.
4. **"VM 321/322 were used"** — not confirmed; no VM was created (see Stop).
5. **"the site is published as its CHANGELOG says"** — right: push to `main`, git-sync, ~1–2 min.
Verified live.
6. The publish runbook does exist (`documentation/runbooks/iso-release-gate.md`) and is correctly not
in the ISO README.
Claims that were **right**: no mail goldens existed; the hub read no `language` anywhere outside a
comment; `05-hub-architecture.md` had no customer-mail section; the box has published the field since
v0.247.0. I briefly believed `Report.Language` was never assigned and was wrong — `cmd/` is
gitignored, so `rg` skips `main.go`, where all four assignments live.
## Two defects found before any feature work
**R-586 — the ISO harness had been RED for two days.** Run unchanged at the base commit it failed two
R-496 checks. The script paints with `> "$CONSOLE_DEV"`; on a console that is a device and truncation
is a no-op, but the harness pointed it at a plain FILE, so each banner erased the previous one. ISO
1.28.0's new bound banner (`c033b3b`, 2026-09-16) paints straight after the pairing banner and wiped
it. That commit did not touch the harness, and **the harness is in no gate and no CI run**, so nobody
saw. Fixed with a FIFO; production code untouched. Its being ungated is still open.
**The release gate would have stopped the task the operator asked for.** G16 read *"every
Felhom-authored string is Hungarian — PASS = no English sentence"*, encoding the 2026-07-31 scope.
**Ruling 1b of 2026-09-17 supersedes it.** G16 was **rewritten, not waived**: Hungarian FIRST, pinned
by a golden, each secret named once per language.
## What shipped
The hub reads the language the box reports and writes the household's e-mail in it. **Nothing an
operator reads changed.** Hungarian is byte-identical, measured.
Bilingual pairing banner, bound banner and `/etc/issue` (plus `postinst`'s byte-coupled copy);
English halves on both GRUB entries; `website/en/download.html` **live**, linked both ways with
`hreflang`; `letoltes.html` changed by **four lines** and nothing else.
- **`internal/i18n`** — flat bundle, 79 keys, hu authoritative + hu fallback, missing ceiling 0.
- **56 mail goldens per language**, captured from v0.117.0 **before** any string moved. All 56
Hungarian ones pass unchanged after the rewrite. A `nowFn` seam makes them byte-stable.
- **`customerMessages`/`severityLabels` are derived from the bundle** — one place per sentence, and
all 40+ existing tests and comments that read them still work.
- **Order: last reported → created-with → `hu`.** `reports.language` defaults to **empty**, never
`hu`: "never told us" is not "chose Hungarian".
- **`message_customer`** accepted on `POST /api/v1/event` (additive, optional forever).
- **Per-language bind page**, built the controller's way (substitute markers, then parse).
- Customer form `language` select; `customer.language` in `controller.yaml` (diff: one line).
**The link is not in the nav** — the nav is a shared block `site_gates.py` pins across every page, and
the gate convicted my first attempt, which is how the link found its right home.
## Live evidence, read off the running hub
## Proven, not read
The wire already worked and I measured it before writing anything (read-only copy of `hub.db` + WAL):
Hungarian **goldens** captured before one English line existed; the harness asserts each banner's
first N lines are exactly the golden. English blocks carry no Hungarian letter (Hungarian block =
positive control). Every line ≤ 80 **columns**, counted in characters because the Hungarian lines are
multi-byte. **The whole paint ≤ 25 rows.** All red-proofed — a changed byte, a planted `ó`, an
over-wide line, two extra lines.
```
demo-felhom 2026-09-18 13:42:56 language='hu' controller 0.255.0
demo-hp 2026-09-18 13:45:24 language='en' controller 0.255.0
drill-r50 / peti-felhom / tester-1 language=<ABSENT> (0.213.0 / 0.115.0 / 0.245.0)
```
**The pairing banner is 24 rows on a 25-row console.** One row of margin, which is exactly why the
height is now pinned: two more lines and the **Hungarian** code at row 5 scrolls off the top.
That is the positive and the negative control in one read: boxes ≥ 0.247.0 report the field, older
ones send nothing at all — which is exactly the case the empty default exists for.
Against the built ISO: G1, G2, G5, G6, G7, G9, G16 all pass — including both payload files
**byte-identical to repo HEAD**.
## Two defects found inside this release
## STOP — the image is built, not published
- **R-581 (P2, partly still open).** `CustomerLanguage` picked the newest report by `received_at`,
which has **second** granularity — same-second reports tie and the winner is arbitrary. A household
that had just switched would get the old language back at random. Fixed by ordering on the
autoincrement `id`; caught by a test that failed on the first version. **Still open:
`GetCustomers()` has the same shape** and feeds the whole operator dashboard.
- **R-582 (closed, kept for the lesson).** The English copy-guard stems, ported word-for-word from the
Hungarian, convicted **141 honest sentences**. In Hungarian the stem *is* the claim
(`visszaállíthat` = *can restore*); English splits the modal from the verb, so the claim is a
phrase. Then the decoy suite caught the fix being too narrow — "can **still** be restored" walked
through a pattern written for "can be restored".
`felhom-installer-1.29.0-pve9.2-1.iso`, sha256 `c67ceaa3793b38639d0f24c9c9704270d04e50b74f1ea88b1129fdd1dec6fb02`.
## Gate work
Publishing is public and irreversible and the runbook needs, against the uploaded bytes: a **proof
install on BOTH menu entries**, a reboot (G15), a person choosing the disk (G14) and a round trip
(G11). That is supervised. **The download pages therefore still name 1.28.0** — the published image —
because pointing them at a file that is not there would hand every reader a 404. The new gate stops
anyone updating one page and forgetting the other.
`wire_contract_gate.py`: the R-555 `language` allowlist entry **deleted** — the field is genuinely
decoded now and the gate checks it (202 tags, was 201). `hub_copy_gate.py`: **the sentences moved into
the bundle**, so `CUSTOMER_SURFACES` had to move with them — without that the four declared Go files
would all still exist, the gate would still report success, and it would be scanning nothing. It
learned English, and found one real occurrence (operator-tier, registered with its reason). Three new
decoys including an **innocent control**; the `hub-copy` exemption is removed from
`decoy_coverage_gate.py`.
**No VM created, none destroyed. demo-hp guest 9201 untouched. No floor, no golden.**
## The mails
## Rows
| Group | Count | Golden | English | Live-tested |
|---|---|---|---|---|
| Event types (`mail.event.*`) | 39 | ✅ hu + en | ✅ | pending (§13.1) |
| Severity labels | 4 | ✅ | ✅ | — |
| Customer wrapper (subject, body, 2 lines, sign-off) | 5 | ✅ | ✅ | — |
| Claim arc (claim / reset / reenroll / claimed) | 4×2 | ✅ | ✅ | pending (§13.2) |
| Self-bind mail | 2 | ✅ | ✅ | pending |
| Bind page | 21 | — (render tests) | ✅ | pending |
| Operator mails | 3 | ✅ (unchanged) | n/a — never localised | — |
**R-586** (harness red, ungated) · **R-587** (two root-password files sit in the ISO publish source
directory; both 404 on the bucket against a 200 control, so not leaked — the only thing keeping them
off is an `--include` pattern they miss by naming accident) · **R-588** (release records live in two
different places; it made me wrongly conclude 1.28.0's gate had never been run).
## Green
`go build` / `go vet` / `go test ./...` clean. All 14 felhom.eu gates OK. 15/15 decoys behave.
**Not yet done:** deploy + live proof (§13), and Part B (controller v0.256.0, the box's own sentence).
`bash -n` + `shellcheck` clean on both scripts · harness **71 checks, all pass** · `site_gates.py` OK ·
`repo_gates.py --fast` all 14 OK.
+34 -4
View File
@@ -477,10 +477,6 @@ reported 60 520 changed lines and measured nothing. A line-index compare is not
---
## 11. Operator decisions
These are rulings, not proposals. Anything specced against a different assumption is wrong.
### 10.4 Slice 3 — the hub's e-mails follow the household (hub v0.118.x, controller v0.256.x). SLICE 3 CLOSED.
SHIPPED 2026-09-18. Full design: `05-hub-architecture.md` §15.
@@ -547,3 +543,37 @@ one mail that did not follow the language — and the one an operator would use
7. **Interface nouns translate** (operator: „translate"). „Indítópult" → Launcher, „Vezérlőpult" →
Dashboard, „Biztonsági mentés" → Backup, as the spike built. App names and „Felhom" stay as they
are. Every slice follows this.
### 10.5 Slice 4 — the console banner and the download page (ISO 1.29.0 source, R-559)
SOURCE SHIPPED 2026-09-18. **The image is built but NOT published** — that is an operator step; see
`documentation/audits/i18n-slice4-2026-09-18/README.md`.
**The box's screen is BILINGUAL, and that is a decision rather than a stage.** At the moment the
pairing code is shown, nobody has told the box who owns it — there is no language to follow. So the
three console texts carry the Hungarian block byte-for-byte as before, then one blank line, then an
English block, inside the same frame: `print_pairing_banner`, `print_bound_banner` and
`install_felhom_issue` (with the `postinst`'s byte-coupled copy of the issue text). The GRUB entries
gain an English half. **§16 default taken: bilingual for good** — it needs no hub change, no protocol
field, and it has no way to show the wrong language.
**The Hungarian is a golden, not a grep** (`scripts/iso/test/golden/*.hu.txt`, captured before any
English existed). The harness also pins: no Hungarian letter in the English block (with the Hungarian
block as the positive control), every line ≤ 80 columns, and **the whole paint ≤ 25 rows**. The
pairing banner is **24 rows on a 25-row console** — one row of margin, which is why the height is
pinned: two more lines and the Hungarian pairing code, at row 5, scrolls off the top.
**The download page has an English twin** at `felhom.eu/en/download`, linked both ways with
`hreflang`. The rest of the marketing site stays Hungarian (ruling 1b). A site gate refuses the two
pages naming different installer files or checksums.
**The release gate had to be amended.** G16 required every Felhom-authored string to be Hungarian and
would have stopped this publication; ruling 1b supersedes that scope, so G16 was rewritten rather
than waived — Hungarian FIRST, pinned by the golden.
**Gaps filed:** R-586 (the ISO harness had been red for two days and is in no gate), R-587
(root-password files in the publish source directory), R-588 (release records live in two places).
## 11. Operator decisions
These are rulings, not proposals. Anything specced against a different assumption is wrong.
@@ -0,0 +1,87 @@
# Slice 4 — bilingual console, English download page (R-559). 2026-09-18
**Source shipped. The ISO is BUILT but NOT PUBLISHED — that step is the operator's (§Stop, below).**
## The claim in the task that mattered most, and it was wrong
> *"the GRUB menu has one entry"* — the **release** image has **two** (`grub-release.cfg.tmpl`:
> graphical and text mode). The single-entry template is the appliance image. Both were updated.
Others: **25** Hungarian printf/echo lines, not 27. **A test DOES compare the banner text** — and a
second one `cmp`s `/etc/issue` against the `postinst`'s copy byte for byte. The console seam already
existed (`FELHOM_CONSOLE_DEV`). The publish runbook is `documentation/runbooks/iso-release-gate.md`
(correctly not in the ISO README). **VM 321/322** were not reachable to confirm — no VM was created
(see Stop). **ISO 1.28.0's gate WAS recorded**, under `documentation/audits/evidence-backup-
promise-2026-09-16/phaseD-iso-gate.txt` rather than a `documentation/tests/iso-release-*` directory;
I briefly concluded it had not been, which is why R-588 is about where records live.
## Two defects found before any feature work
**R-586 — the harness had been RED for two days and nobody saw.** Running
`scripts/iso/test/bootstrap-modes.sh` unchanged at the base commit failed two R-496 checks. The
script paints with `> "$CONSOLE_DEV"`: on a console that is a device and truncation is a no-op, but
the harness pointed it at a plain FILE, so each banner erased the one before it. ISO 1.28.0's new
bound banner (commit `c033b3b`, 2026-09-16) paints straight after the pairing banner and wiped it
before the check read it — and that commit did not touch the harness. **The harness is in no gate and
no CI run.** Fixed with a FIFO; production code untouched.
**The release gate would have stopped this task.** `iso-release-gate.md` **G16** read *"every
Felhom-authored string on the volunteer's path is **Hungarian** — PASS = no English sentence"*. That
encodes the 2026-07-31 scope. **Operator ruling 1b of 2026-09-17 supersedes it** ("the console banner
and the download page ARE in scope"). G16 was **rewritten, not waived**: Hungarian FIRST, pinned by a
golden, each secret named once per language. What it protects is now stronger.
## What is proven, and how
| Claim | How |
|---|---|
| The Hungarian is unchanged | **Goldens** captured from the script at `183727db9c44` before one English line existed; the harness asserts each banner's first N lines are exactly the golden. Red-proofed by one changed byte. |
| The English is English | No Hungarian letter in the English block, with the Hungarian block as the **positive control**. Red-proofed by planting an `ó`. |
| It fits the screen | Every line ≤ 80 columns (characters, not bytes), **and** the whole paint ≤ 25 rows. Red-proofed both ways. |
| `/etc/issue` still agrees with `postinst` | The existing `cmp` check, still green — both files were changed identically. |
| The payload in the ISO is the repo's | `cmp` of both files extracted from the built `.deb` against repo HEAD: **IDENTICAL**. |
**The pairing banner is 24 rows on a 25-row console.** One row of margin. That is the measurement the
plan asked for, and it is why the height check exists: two more English lines make it 26 and the
**Hungarian** pairing code — which sits at row 5 — scrolls off the top. A banner whose code has
scrolled away is furniture.
## The built image (not published)
```
felhom-installer-1.29.0-pve9.2-1.iso
sha256 c67ceaa3793b38639d0f24c9c9704270d04e50b74f1ea88b1129fdd1dec6fb02
size 1 705 324 544 bytes
menu 2 entries: 'Felhom telepítés / Install Felhom'
'Felhom telepítés (szöveges mód) / Install Felhom (text mode)'
timeout 15, timeout_style=menu, default=0, banned entries 0
```
Gate criteria run mechanically against the built file: **G1** no `answer.toml` (0 hits) · **G2** no
`.rootpw.txt` emitted · **G5** no credential-shaped literal in the payload (`Bearer` is
`Bearer $token`, a variable) · **G6** two entries, timeout 15, underscore spelling, 0 banned ·
**G7** package present at 1.29.0 · **G9** both payload files byte-identical to repo HEAD · **G16**
`jelszavad` 0, `Tulajdonosi jelmondat` 1, `Owner passphrase` 1, harness green.
## STOP — what is left, and why it is not mine
**The image is not published.** `iso.felhom.eu` is public and irreversible, and the runbook requires,
against the exact uploaded bytes: **G11** a published checksum and a verified round trip, **G15** the
console after a first boot **and one reboot**, **G14** a person choosing the disk, and a **proof
install on BOTH menu entries** (the `felhom-build-deploy` skill is explicit that reasoning from one
entry to the other was tried in Spike 4 and found insufficient in 1.26.0).
No VM was created and none destroyed; demo-hp guest 9201 was not touched at all this task.
**Therefore the download pages still name 1.28.0** — the image that is actually published. Pointing
them at an unpublished file would hand every reader a 404. A new site gate refuses the two pages
naming different files or hashes, so whoever publishes 1.29.0 cannot update one page and forget the
other.
## The English download page IS live
```
https://felhom.eu/en/download 200 <html lang="en">
https://felhom.eu/letoltes 200 links to it, hreflang both ways
both name felhom-installer-1.28.0-pve9.2-1.iso, sha a4cd9b6ddcb5… (verified live)
```
@@ -0,0 +1,15 @@
================================================
Felhom — a doboz össze van kötve. ✔
A beállítás magától folytatódik, ez néhány percig tart.
A vezérlőpult címét az e-mailben kapott levél tartalmazza.
Ezen a gépen nincs több teendőd.
Felhom — the box is linked.
Setup carries on by itself; this takes a few minutes.
Your dashboard address is in the e-mail you received.
There is nothing left to do on this machine.
================================================
@@ -0,0 +1,11 @@
Felhom otthoni szerver
Ezen a gépen most nincs dolgod, és bejelentkezni sem kell.
A beállításhoz kövesd a Felhomtól kapott útmutatót.
Felhom home server
There is nothing to do on this machine, and no need to log in.
Follow the guide you received from Felhom.
@@ -0,0 +1,26 @@
Felhom bare-metal ISO build manifest (R-21 slice A+B+C)
built : 2026-09-18T17:42:21+02:00
iso-version-tag : v1.29.0
pve-version : 9.2-1
source-iso : proxmox-ve_9.2-1.iso
source-iso-sha256 : 4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c
assistant-version : proxmox-installer-common v9.2.7
profile : none (a release image bakes no disk selection)
fqdn : (none — interactive install)
mode : release (PUBLIC image — NO answer.toml, NO baked credential, interactive disk selection)
loader : shim (stock MS-signed chain; Secure Boot OK on compliant firmware)
grub-mkimage : unknown
boot-menu : FELHOM release menu — TWO INTERACTIVE entries ('Felhom telepítés' graphical = default, 'Felhom telepítés (szöveges mód)' = Terminal UI), timeout 15s
menu-entries : 2 (graphical default + Terminal UI; timeout 15s)
menu-removed : debug variants, Rescue Boot, memtest86+, UEFI Firmware Settings
automated-entry : NOT PRESENT — no auto-installer-mode.toml, so the stock grub.cfg does
not emit it. Disk selection is INTERACTIVE by construction.
host-install-url : https://felhom.eu/scripts/felhom-host-install.sh (default)
secret-bearing : no (PUBLIC image — carries NO credential of any kind)
root-password : NONE — not baked. The installer prompts the person installing (release gate G2).
answer-file : NONE — no answer.toml, no auto-installer-mode.toml (release gate G1)
felhom-package : felhom-bootstrap_1.29.0_all.deb sha256=af4100a698239de5124e7e48cfe74d66a04b7c8e2d71621adf1aa77ed636ff38
repo-commit : eb1ae370951f204f6ed27782006246e6a45ea2aa
output : felhom-installer-1.29.0-pve9.2-1.iso
output-sha256 : c67ceaa3793b38639d0f24c9c9704270d04e50b74f1ea88b1129fdd1dec6fb02
output-size-bytes : 1705324544
@@ -0,0 +1 @@
c67ceaa3793b38639d0f24c9c9704270d04e50b74f1ea88b1129fdd1dec6fb02 felhom-installer-1.29.0-pve9.2-1.iso
@@ -0,0 +1,21 @@
================================================
Felhom — a doboz készen áll, és a párosításra vár.
Párosító kód: TST-CDE
Nyisd meg az e-mailben kapott linket, és add meg
ezt a kódot és a Tulajdonosi jelmondatodat
(az 5 szót a Felhom üzemeltetőjétől kaptad).
Ez a képernyő magától frissül — nincs teendő a
doboznál, és nyugodtan itt hagyhatod bekapcsolva.
Pairing code: TST-CDE
Open the link from your e-mail and enter this code
together with your Owner passphrase (the 5 words you
received from your Felhom operator).
This screen refreshes itself. There is nothing to do
at the box; you can leave it switched on.
================================================
+3 -1
View File
@@ -735,7 +735,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-555** | **[P3-LOW] The wire-contract gate counts a field as received when its name appears in a Go COMMENT on the receiving side.** FOUND 2026-09-17 by CC adding the report's `language` field (controller v0.247.0): `scripts/wire_contract_gate.py` passed WITHOUT an allowlist entry, because `receiver_tokens()` tokenises whole files and the word „language" occurs hub-side only in a comment (`hub/internal/web/configs.go:558`, „this page's existing language"). The shape is the gate's own named failure class (name-for-fact, R-421): any English tag name that also appears in hub prose passes unread. The field was allowlisted by hand with this row named. **Fix shape:** strip `//` and `/* */` comments (and template `{{/* */}}`) before tokenising; add the decoy „a tag whose name appears only in a receiver comment must convict"; expect a handful of currently-passing tags to surface — each is a finding, not noise. | **READY - rank P3-LOW; owner: CC** |
| **R-557** | **[P3-LOW] Localisation slice 2 — Go-side customer strings follow the language.** PLAN 2026-09-17 (10 §10). Inventory §2.2: 947 shown + 184 error literals in 84 files; 237 format strings, 111 concatenations, 42 numeric `%d` (English plurals). Flash messages travel inside the redirect URL (`?flash=<Hungarian>`) and must become keys; `cloudflare/countries.go` (113 country names); alert texts; handler errors printed with `err.Error()`. **After R-553** (the four compare-not-show sites). Cost 16–20 CC-hours. **DEPENDENCY added 2026-09-17 (R-553 shipped, v0.251.0):** the behaviour-by-wording sites are fixed, so this slice is unblocked — EXCEPT one producer: `"Sikeres — nincs mentésre jelölt alkalmazás"` (controller/internal/backup/offbox.go) must stay Hungarian until **R-570** closes, because the page's legacy fallback still reads it on boxes that have not run off-site since 0.251.0. Everything else this slice touches is now decided by a kind, not by its words. **RELEASE A SHIPPED 2026-09-18 (controller v0.252.0):** 226 Go literals converted -- flash-as-key (8 writers, 8 readers, legacy prose still shown verbatim), page data and view-model text, the `internal/api` JSON answers, the alert banners (`Alert.MessageKey`), 237 country names at DISPLAY (the cloudflare table is untouched -- only CODES are on the wire, the task claimed otherwise), and the four app-named page titles (R-566 CLOSED). New gate `i18n_go_parity.py` + `i18n_go_base.json` (7 467 base-commit literals, frozen) refuses any key whose Hungarian is not byte-identical; three decoys. Wire goldens freeze `internal/monitor` and `internal/notify` -- the hub MAILS the event message when it has no `customerMessages` entry, so those stay Hungarian until R-558. `HU_FORMAL_CEILING` 16 -> 18 (measured, no word changed). **RELEASE B SHIPPED 2026-09-18 (controller v0.253.0): all 179 error literals carry a key** via `util.MsgError` -- `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND a wrapped cause, an error ARGUMENT renders recursively, and a foreign error (restic/docker/ssh/stdlib) prints verbatim. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error; `UpdateRefusal` gained a `Cause`. Plurals are a BUNDLE rule (a key with `.one`/`.other` takes its count first), not a call-site flag. **ZERO Hungarian error literals remain** (ASCII search with both controls, case-insensitive). Two tooling defects found and filed: R-576 (the converter dropped concatenations and the gate could not see it) and the case-sensitive counter. New gap: R-575. **RELEASE C SHIPPED 2026-09-18 (controller v0.254.0) -- SLICE 2 CLOSED.** ~70 saved-note producers write in the BOX language at write time (s16 option 1; a household that switches sees the previous run's note in the old language until the next run). `EndRestoreOp` takes no Hungarian literal from anywhere. The switch became a GLOBE on the dashboard and on the pages outside the dashboard chrome; a visitor with no session gets a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a successful CLAIM carries it into the setting (s16). Two parity blocks, measured with a real diff: 3 change shapes across 106 fixtures (footer; the recovery globe posting to the household switch; the login/claim globe posting to /lang), 5 byte-identical. **A DEADLOCK was introduced and caught by the suite hanging (R-578).** Gaps filed: R-572..R-578. | **CLOSED 2026-09-18 - controller v0.252.0 + v0.253.0 + v0.254.0** |
| **R-558** | **[P3-LOW] Localisation slice 3 — the hub's customer e-mails follow the household's language; the operator sets it at customer creation.** PLAN 2026-09-17 (10 §3, §10), operator decision 2. The box already reports `"language"` (controller v0.247.0); nothing hub-side reads it. Build: a per-customer language on the hub (default hu) set at creation and rendered into `controller.yaml` beside `customer.*` (`hub/internal/configgen/configgen.go`), used by the box only while `settings.json` has no choice; the dispatcher picks the language the box REPORTS; English for the 39 `customerMessages`, the 4 severity labels, the body wrapper, the claim/re-enroll/reset/claimed/self-bind mails and the public bind page (inventory §2.3). Delete the `language` allowlist entry in `wire_contract_gate.py` when the field is read. Cost 6-8 CC-hours, one hub + one controller release. **THE WIRE SURVEY (R-557 release A, 2026-09-18) HANDS THIS ROW ITS INPUT LIST:** (1) `internal/notify/notifier.go` -- 31 event messages. The task assumed the hub composes its own mail from the event kind; it does NOT. `FormatCustomerEmail` uses `customerMessages[eventType]` when it has one and **falls back to the controller's message when it has none**, and appends it as „- Üzenet: %s” whenever the two differ -- several types carry no entry precisely so the controller's dynamic sentence IS the mail (hub `api/handler.go` L2034/L2045/L2066). So an English household's mail is Hungarian until this row ships. (2) `internal/monitor/healthcheck.go` -- 5 storage sentences in report `health.warnings`/`health.issues`, operator-facing. Both are frozen by wire goldens in those packages, so no later slice can translate them by accident; this row is what unfreezes them. **SLICE 3 SHIPPED AND CLOSED 2026-09-18 — hub v0.118.0/v0.118.1 + controller v0.256.0/v0.256.1.** The hub reads the language the box reports and writes the household's e-mail in it; nothing an operator reads moved. 56 mail goldens per language captured from hub v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged. Order: last reported -> created-with -> hu. `message_customer` carries the sentences the box composes and the hub cannot translate; 19 producers render both from ONE key, and their Hungarian is byte-identical by the Go parity gate. A Hungarian household sends no second copy, so the fleet's payload is unchanged. The bind page is per-language with `expired` pinned to Hungarian (the language would otherwise be the oracle the text refuses to be). Proven live twice: a real English e-mail read in the inbox and the Hungarian one 74 s later (Part A), and the box's push log showing `[hu-only]` then `[+household(en)]` for the same event 57 s apart (Part B). Gaps filed: R-581 (received_at ties), R-582 (the English guard), R-583 (the test mail), R-584 (credential litter), R-585 (six unconverted producers). R-555 closed with it. | **CLOSED 2026-09-18 - hub v0.118.1 + controller v0.256.1** |
| **R-559** | **[P3-LOW] Localisation slice 4 — the console banner and the download page in English.** IN SCOPE — operator ruling 2026-09-17 evening („yes", 10 §11 1b). PLAN 2026-09-17 (10 §10, open decision 1b): the starter listed them; the operator's scope ruling names „the controller, emails, guide, app catalog" and not them. Inventory §2.4: 34 banner lines (`scripts/iso/felhom-bootstrap.sh`, printf to the console; `/etc/issue` byte-coupled to `iso/pkg/debian/postinst`, console font avoids ő/ű) and 32 strings on `website/letoltes.html`. Cost 4–6 CC-hours plus an ISO release train. | **READY - rank P3-LOW; owner: CC** |
| **R-559** | **[P3-LOW] Localisation slice 4 — the console banner and the download page in English.** IN SCOPE — operator ruling 2026-09-17 evening („yes", 10 §11 1b). PLAN 2026-09-17 (10 §10, open decision 1b): the starter listed them; the operator's scope ruling names „the controller, emails, guide, app catalog" and not them. Inventory §2.4: 34 banner lines (`scripts/iso/felhom-bootstrap.sh`, printf to the console; `/etc/issue` byte-coupled to `iso/pkg/debian/postinst`, console font avoids ő/ű) and 32 strings on `website/letoltes.html`. Cost 4–6 CC-hours plus an ISO release train. **SLICE 4 SOURCE SHIPPED 2026-09-18 (ISO 1.29.0 source + the English download page); the IMAGE IS NOT PUBLISHED — that is the operator's step.** The three console texts are bilingual: Hungarian block byte-for-byte as before, then English, inside the same frame; the GRUB entries gain an English half; `postinst`'s byte-coupled copy of the issue text changed identically. The Hungarian is a GOLDEN captured before any English existed, and the harness also pins no-Hungarian-letter-in-English (Hungarian block as the positive control), ≤ 80 columns, and ≤ 25 ROWS — the pairing banner is 24 on a 25-row console, one row of margin. `felhom.eu/en/download` is LIVE, linked both ways, and a site gate refuses the two download pages naming different installer files or checksums. **The release gate's G16 required every Felhom string to be Hungarian and would have stopped this**; ruling 1b (2026-09-17) supersedes that scope, so G16 was REWRITTEN, not waived. Built and gate-checked mechanically: `felhom-installer-1.29.0-pve9.2-1.iso`, sha256 `c67ceaa3…fb02`. Publishing needs a proof install on BOTH menu entries plus a reboot. Gaps filed: R-586, R-587, R-588. | **SOURCE SHIPPED 2026-09-18 — PUBLICATION PENDING (operator: proof install on both menu entries, then publish 1.29.0)** |
| **R-560** | **[P3-LOW] Localisation slice 5 — catalog cards, settings and first steps in English.** PLAN 2026-09-17 (10 §7, §10). Inventory §2.5: 835 strings, ~4 984 words across all 53 `.felhom.yml` (use_cases 262, first_steps 233, deploy_fields descriptions 79 / labels 68, prerequisites 60, description 56, tagline 53). Proposed format: an `i18n: {en: {...}}` block inside each `.felhom.yml` (controller's `yaml.Unmarshal` ignores unknown keys, so older controllers are unaffected), field-by-field fallback. Needs a controller change to read it and catalog copy gates with an English rule. Cost 12–16 CC-hours. | **READY - rank P3-LOW; owner: CC (catalog + controller)** |
| **R-561** | **[P3-LOW] Localisation slice 6 — the volunteer guide in English, then a stranger's first hour in English; closes R-516.** PLAN 2026-09-17 (10 §10). `runbooks/VOLUNTEER-first-hour.md`: 44 paragraphs, ~1 579 words. Then the 2026-09-14 walk repeated by an English speaker on a fresh box, R-516 closed against `audits/I18N-INVENTORY-2026-09-17.md`. Depends on R-556..R-560. Cost 6–8 CC-hours plus the attended walk. | **READY - rank P3-LOW; owner: CC** |
| **R-562** | **[P3-LOW] Dates and sizes are not formatted for any locale — and the Hungarian pages disagree with themselves.** FOUND 2026-09-17 by the i18n inventory §2.8: the two template date layouts differ (`2006. 01. 02. 15:04` Hungarian vs `2006-01-02 15:04` ISO); 10 layout literals in `internal/web` Go and 25 elsewhere pick formats ad hoc; sizes print a decimal POINT (`%.1f GB`, 4 helpers) where Hungarian uses a comma; `timeAgo`/`nextRunLabel`/`pruneLabel` produce Hungarian words outside the three converted pages. Not changed by v0.247.0 (Hungarian bytes are frozen by the parity rule). **Fix shape:** one date and one size formatter per language in `internal/i18n`, the Hungarian output deliberately changed in ONE reviewed release with the parity fixtures re-captured for that release only and the change named in its CHANGELOG. Needs an operator word on the Hungarian format (comma, date style). | **READY - rank P3-LOW; owner: CC** |
@@ -762,6 +762,8 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-584** | **[P2-MED] Credential-bearing probe scripts were left in a live guest's `/tmp` for hours, across three releases.** FOUND 2026-09-18 while cleaning up after slice 3's live proof: `probeB.sh`, `probeB2.sh`, `probeB3.sh`, `probeC.sh` and `probeD.sh` were still in demo-hp guest 9201's `/tmp` from slice 2's releases B, C and D earlier the same session, and all five carried the controller password INLINE (`-d "password=$PW"` with the value substituted). The standing rule in `.claude/rules/ui-hungarian.md` says exactly this: *delete any credential-bearing helper from `/tmp` (host AND guest) when done*. They were not deleted at the end of each phase; they were found by grepping my own litter for secret-shaped strings before removing it, which is a check that happened only because a later phase went looking. All five are shredded. **Why it is a row and not a note: the rule existed, was loaded, and was still not followed three times running — so the rule is not the mechanism.** The password is the shared demo one (Tier-0 boxes, `~/.config/credentials`), unchanged; rotating it is cheap and is the operator's call. **Fix shape:** never inline a credential in a helper — write it to a mode-0600 file and read it with curl's `@file` form, which this run did and which is why this run's own scripts were clean; and make the last act of a phase that pushed a script to a box the `shred -u` of it, the same way R-320 made evidence-copying the last act of a phase. | **READY - rank P2-MED; owner: CC (the mechanism), operator (whether to rotate)** |
| **R-585** | **[P3-LOW] Six event producers still send Hungarian only, so an English household can see one Hungarian line in some mails.** FOUND 2026-09-18 by localisation slice 3 Part B (R-558, controller v0.256.0), which converted 19 of the customer-facing producers and left these: `backup_failed`, `db_dump_failed`, `backup_integrity_ok`, `backup_integrity_failed`, `offbox_enlarge_blocked` and `local_api_endpoint_drift`. **Why they were left:** each receives its sentence already FINISHED from another package, so the key and its arguments no longer exist by the time the notifier sees it — converting them means changing their callers, not the notifier. The 15 operator-tier types are deliberately excluded and are NOT part of this row: the operator reads Hungarian. **Why it matters more than it looks: `offbox_enlarge_blocked` has no `customerMessages` entry on the hub**, so its raw sentence IS the household's mail rather than an extra line under a translated headline — for that one type an English household gets a wholly Hungarian mail, not a mostly-English one. **Fix shape:** push the key and its arguments down from each caller (the shape slice 2 release B already used for errors, `util.MsgError`), then add each to the `convertedProducers` table in `internal/notify/message_customer_test.go`, which is the list both language tests walk. | **READY - rank P3-LOW; owner: CC** |
| **R-586** | **[P2-MED] The ISO bootstrap harness captured the console to a FILE, so each banner erased the one before it — two checks were RED for two days and nobody saw.** FOUND 2026-09-18 starting slice 4 (R-559): running `scripts/iso/test/bootstrap-modes.sh` unchanged at `183727db9c44` reported `FAIL: R-496: banner painted to the console seam` and `FAIL: R-496: banner names the Tulajdonosi jelmondat`. **Cause:** the script paints each banner with `> "$CONSOLE_DEV"`. On a real console that is a character device and truncation is a no-op, so every paint appears; pointed at a plain file, as the harness did, each paint TRUNCATES. Commit `c033b3b` (ISO 1.28.0, R-535, 2026-09-16) added `print_bound_banner`, which paints immediately after the pairing banner in the same invocation — from that commit the pairing banner was wiped before the check read it. `c033b3b` did not touch the harness. **Why it survived: the harness is in NO gate and NO CI run** — not in `repo_gates.py`, not in `.gitea/`; it runs only when a person runs it, and between 09-16 and 09-18 nobody did. FIXED in the same session: the harness points `FELHOM_CONSOLE_DEV` at a FIFO with a background reader, which restores device semantics (opening a FIFO with `>` truncates nothing) and lets a test see EVERY paint — which slice 4's golden checks then needed anyway. Production code untouched. **What is still open: the harness remains outside every gate.** It needs a container, so it cannot join `repo_gates.py --fast`, which is what both the pre-push hook and CI run — meaning a non-fast entry would still never execute. **Fix shape:** either give CI a container-capable job that runs it, or make the ISO release gate's G16 the place it is required (done for G16 this session — so it now runs at least once per ISO release, which is better than never but later than a push). | **READY - rank P2-MED; owner: CC** |
| **R-587** | **[P3-LOW] Two root-password files sit in the directory the public ISO is published FROM.** FOUND 2026-09-18 running the ISO release gate's credential criteria for slice 4: `/mnt/5_hdd/felhom.eu/felhom-iso/out/` holds `felhom-pve-9.2-1-v1.24.0-nested-probe-generic.iso.rootpw.txt` and `felhom-pve-9.2-1-v1.25.0-nested-vm-generic-mkimage.iso.rootpw.txt` from 2026-07-22/23, mode 0600, left by two appliance-mode builds. **They are NOT on the bucket** — both `https://iso.felhom.eu/<name>` return **404**, against a control (`felhom-installer-1.28.0-pve9.2-1.iso.sha256`) that returns 200, so the check is real and not a dead probe. **The only thing keeping them off a PUBLIC bucket is the `--include "felhom-installer-<VER>*"` pattern in the publish command**, and they are named `felhom-pve-*`, so the pattern misses them by an accident of naming rather than by design. A publish typed without the include, or an include widened to `felhom-*`, uploads root passwords to a world-readable bucket. **Fix shape:** shred the two files (they are three months old and their VMs are long gone), and make the release build refuse to run — or the publish step refuse to start — while any `*.rootpw.txt` exists in the out directory. A pattern that protects by coincidence is not a control. | **READY - rank P3-LOW; owner: CC** |
| **R-588** | **[P3-LOW] ISO release records live in two different places, so "was the gate run for this image?" cannot be answered by looking.** FOUND 2026-09-18: the release records for 1.27.0 and 1.27.1 are directories under `documentation/tests/iso-release-<ver>-<date>/`, and there is none for **1.28.0** — which led me to conclude its gate had not been run. It had: the record is `documentation/audits/evidence-backup-promise-2026-09-16/phaseD-iso-gate.txt`, inside an audit about something else. **The gate's own rule is "a criterion with no recorded observation is a criterion that was not run"**, and that rule is unenforceable while the records have no single home — the question it answers has to be settled by a full-text search for a checksum, which is what it took here. **Fix shape:** one home, `documentation/tests/iso-release-<ver>-<date>/`, and a line in `iso-release-gate.md`'s Result-recording section naming it; optionally a check that every published ISO version has a record directory. | **READY - rank P3-LOW; owner: CC** |
| **R-537** | **[P1-HIGH] The app-backup page labels the tier-1 backup „DB + Konfig + Adatok" and prints the app's data-drive size next to it — but the tier-1 unit contains NO drive-side app data at all.** MEASURED 2026-09-16 on the drill box (fresh install, controller 0.243.0, one drive, tier 2 and tier 3 both „Nincs beállítva"): five photos (3 000 000 B) were uploaded into Nextcloud through its own WebDAV interface, then the customer-visible „Mentés most" was pressed (`POST /api/backup/run` → 200, the unit grew 25 337 B → 978 MB). The resulting unit's `manifest.json` lists `db-dumps` + three **docker volume** dumps and nothing else; listing the 781 MB `nextcloud_nextcloud_html.tar` (29 346 entries, positive control `version.php` = 3 hits) gives **`Fotok` = 0 and `nyaralas` = 0**, and `./data/` is the empty bind-mount point. A `find` over the whole `backups/` tree for `*appdata*` / `*Fotok*` returns nothing. The page nevertheless renders „1. mentés … DB + Konfig + Adatok" and „Nextcloud Adatlemez 65.1 MB" — a size measured on exactly the data it does not copy (`internal/web/handlers.go:1176-1178`, `BackupContents`). **This is a truth defect, not a design defect:** `07-backup-architecture.md` §6.2 places nextcloud's file leg at **Tier 2 and Tier 3 only**, and its „[FACT] What the whole-guest tiers do NOT carry" says `mp8 /mnt/felhom-drives` is out of vzdump scope (confirmed live: „excluding bind mount point mp8 … (not a volume)"). So on a one-drive box with no off-site tier — the state every fresh install starts in — the household's files are in **no backup**, while the page says „Adatok". Same family as R-517/R-518. **Fix shape:** render tier-1 contents from the capture set actually written (`ComputeCaptureSet`), so a unit with no file leg reads „DB + Konfig" and the drive size is not shown beside it; and say on the page that the app's files need tier 2 or tier 3. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** The contents label is computed PER TIER from what that tier captures: Tier 1 says „Adatok" only when the app's data really is in the volumes the unit captured, and a class-A app carries one sentence saying where its files ARE protected. Proven on demo-hp through the page the customer opens: Paperless-ngx reads „1. mentés … DB + Konfig" with „Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi …", while its „2. mentés" row still reads „DB + Konfig + Adatok". Red-proof: restoring the old app-shaped label fails `TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold`. **RE-PROVEN 2026-09-16 on a FRESH box** (installed from the built ISO 1.28.0, controller 0.244.0, off-site on by default): the Nextcloud row read „1. mentés … DB + Konfig" with the new sentence, „2. mentés … Nincs 2. (off-drive) másolat", „3. mentés Sikeres restic → …your-storagebox.de"; „DB + Konfig + Adatok" appeared ZERO times while the local unit held no file leg. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-538** | **[P1-HIGH] A tier-1 app restore reports plain success and leaves Nextcloud listing files whose bytes were never in the backup — and it destroys the app's own trash, the customer's last copy.** MEASURED 2026-09-16 on the drill box, F10 („a child deletes the photo folder"): the five photos were deleted through Nextcloud (DELETE 204, PROPFIND 404), then restored through the page exactly as a customer would (`POST /backup/restore` `stack_name=nextcloud` `snapshot_id=helyi` → 302, finished in **35 s**, „A(z) nextcloud: 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."). Afterwards the folder is back and **lists all five photos**, and **none of them opens**: `GET nyaralas-1..5` = 404 / 503×4 with `Sabre\DAV\Exception\NotFound`, while the positive controls at the same moment pass (`status.php` 200, WebDAV PUT 201, GET 200). Cause: the replayed MariaDB dump (11:01:45Z) knows the photos, the bytes live on `mp8` and were never captured (R-537). **Worse:** the bytes were still on the drive in Nextcloud's own trash (`appdata/nextcloud/admin/files_trashbin/files/Fotok.d1789556707/nyaralas-1..5.jpg`, all five present) and the restored database no longer references them — the trash listing comes back **empty**, so „restore from trash", the one route that would have worked, is gone. The customer is left with five unopenable photos, a success message, and no warning. **Fix shape:** before replaying a database whose app has an uncaptured file leg, refuse or warn („ennek az alkalmazásnak a fájljai nincsenek ebben a mentésben — a visszaállítás után a fájlok hiányozni fognak"); and never present a DB-only restore of a class-A app as a complete one. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** A unit restore refuses before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. Fired live on demo-hp: `POST /backup/restore` for paperless-ngx → 302 with „Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza …", and the app read `running` before AND after, so nothing was stopped and no trash was made unreachable. The database-and-settings-only path exists as a separately worded second step. Red-proof: disabling the guard fails `TestUnitRestore_RefusesWhenTheUnitCannotHoldTheFiles`. **RE-PROVEN 2026-09-16 on a FRESH box, and this time the refusal had somewhere to point:** after five photos were deleted, `POST /backup/restore` was refused with „…a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza: … „Teljes visszaállítás (fájlok + adatbázis)"", the app read `running` before AND after, and the wastebasket was untouched. The off-site route then returned all five photos — 200 with the exact uploaded sizes and sha256 IDENTICAL to the originals, 5/5, with a negative control. Evidence: `audits/evidence-backup-promise-2026-09-16/phaseE-photos.txt`. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-525** | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** |
+14
View File
@@ -279,6 +279,20 @@ done
# The pairing code appears once per language — a person reads one block, and must find it there.
check "R-559: the pairing code appears in BOTH blocks" \
"[ \"\$(grep -c 'TST-CDE' /work/b.pairing)\" -eq 2 ]"
# HEIGHT, not just width — the measurement slice 4's plan called for, and the one a width check
# cannot make. A console is 80x25. The pairing banner is now 21 framed lines plus the blank before it
# and the two after: 24 rows, and the console shows the LAST 25. One more line and the paint is 25;
# two more and the top row scrolls away — and the top of this banner is where the HUNGARIAN pairing
# code sits (row 5). The banner exists to be read; a banner whose code has scrolled off is furniture.
#
# 25 is the limit, not 24, because 25 still fits exactly. The margin is one line and this check is
# what makes that fact survive the next person adding a sentence.
banner_rows() { echo $(( $(wc -l < "$1") + 3 )); } # +1 leading blank, +2 trailing
check "R-559: the pairing banner fits a 25-row console (it is $(banner_rows /work/b.pairing))" \
"[ \"\$(banner_rows /work/b.pairing)\" -le 25 ]"
check "R-559: the bound banner fits a 25-row console (it is $(banner_rows /work/b.bound))" \
"[ \"\$(banner_rows /work/b.bound)\" -le 25 ]"
check "R-559: the bound banner carries no pairing code" "! grep -q 'TST-CDE' /work/b.bound"
# /etc/issue: the Hungarian half frozen, the English half added, still no ő/ű and still no admin URL.