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

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

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

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

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

R-326 — "which claims are unproven" is answerable by a command now. The nine I have
been repeating was the count of claims the 9 August pass DOWNGRADED, not the count of
unproven ones. The real figures: 55 claims, 23 walked, 32 not — and only 6 of those 32
cite evidence. Its first run found a stale claim (R-327).
This commit is contained in:
2026-08-13 15:50:32 +02:00
parent 955a4f07b7
commit b03a105375
14 changed files with 995 additions and 16 deletions
+7
View File
@@ -125,6 +125,13 @@ something, not only sessions that touch `documentation/` — which is why it is
the operator in plain language, and deliberately **not** `CONTEXT.md`. the operator in plain language, and deliberately **not** `CONTEXT.md`.
- **The capability map** (`documentation/architecture/00-capability-map.md`), if a capability's - **The capability map** (`documentation/architecture/00-capability-map.md`), if a capability's
status changed — with its new evidence citation. status changed — with its new evidence citation.
- **`python3 scripts/unproven.py --summary`** — one line per status, and the not-walked total. Run it
at the end of any session that shipped, broke or proved something, and **say in the report if a
number moved**. It exists because "which claims are unproven?" was answerable only by a person
reading a page: a session asked for "the nine grey claims" could not determine which nine and
rightly refused to guess (R-326). *Nine was real and answered a different question — it is the
count of claims the 2026-08-09 pass DOWNGRADED. Not-walked is 32 of 55.* A status that moves
without anyone noticing is how the picture stops being true.
- **Confirm your own last push's CI run went green, by run ID.** CI emails on failure, which is a - **Confirm your own last push's CI run went green, by run ID.** CI emails on failure, which is a
PUSH signal; this is the PULL check that catches a lost, filtered or unread mail. Quote the run id PUSH signal; this is the PULL check that catches a lost, filtered or unread mail. Quote the run id
and its conclusion, e.g. and its conclusion, e.g.
+21 -4
View File
@@ -1,6 +1,6 @@
# STATUS — what works, what's broken, what's next # STATUS — what works, what's broken, what's next
**Updated 2026-08-13 (evening — the small debts, and one fact given a reader).** **Updated 2026-08-13 (late — the third name, a machine told to be quiet, and a picture you can query).**
> **A view, not a source.** `documentation/backlog/OPEN-ITEMS.md` is the authority; this page restates > **A view, not a source.** `documentation/backlog/OPEN-ITEMS.md` is the authority; this page restates
> part of it in plain words, and **nothing may exist only here**. **Items, not paragraphs. One screen.** > part of it in plain words, and **nothing may exist only here**. **Items, not paragraphs. One screen.**
@@ -57,6 +57,16 @@ record with no machine** — created 13 August, no host, no backups, nothing to
- **The hub can see whether a machine's guest still has working networking** (R-319, first reader built - **The hub can see whether a machine's guest still has working networking** (R-319, first reader built
against R-264). A machine quietly repairing its own network over and over is now visible instead of against R-264). A machine quietly repairing its own network over and over is now visible instead of
being a green tick; a machine that does not report it is drawn as unknown, never as healthy. being a green tick; a machine that does not report it is drawn as unknown, never as healthy.
- **The third secret has its own name** (R-323, on your ruling). The five-word phrase that proves an
account owns the box being linked is „Tulajdonosi jelmondat". It was „Visszaállító jelszó" — one word
from the name we retired last week, and false besides: it restores nothing. Five places, all in the
hub; no machine touched.
- **A machine we tell to be quiet is no longer reported as dead** (R-321). It went stale, then down,
then e-mailed you twice about a silence you asked for. It turned out to be two alarms, not one — the
morning backup reminder had the same blind spot and is fixed with it.
- **The hub's own words are under a guard** (R-324). Every customer e-mail and the linking pages are
now checked for a retired name, and the guard has been watched catching one, ignoring an
explanation of one, and going quiet again.
- **The countdown on `demo-felhom` is cancelled** on your ruling (R-307). Nothing was deleted. - **The countdown on `demo-felhom` is cancelled** on your ruling (R-307). Nothing was deleted.
## Broken, or knowingly incomplete ## Broken, or knowingly incomplete
@@ -75,9 +85,16 @@ record with no machine** — created 13 August, no host, no backups, nothing to
- **Three more facts the machines send still have no reader** (R-264): a staged-but-unapplied agent - **Three more facts the machines send still have no reader** (R-264): a staged-but-unapplied agent
update, how deep a restore test actually went, and the two backup-integrity timestamps. Five others update, how deep a restore test actually went, and the two backup-integrity timestamps. Five others
are now recorded as deliberately unread, which is honest rather than fixed. are now recorded as deliberately unread, which is honest rather than fixed.
- **Two thirds of the standing picture is still unproven, and now you can ask** (R-326).
`python3 scripts/unproven.py` lists it: of 55 claims, **23 are walked and 32 are not** — and of
those 32, only 6 point at an evidence document. **The "nine" I have been repeating was wrong**: nine
is how many claims the 9 August review *lowered*, which is a different question.
- **The picture still describes one defect we have since fixed twice** (R-327) — the naming claim. Its
status may only be raised after the capability map moves first, which is a separate judgement.
## Working on next ## Working on next
The three remaining R-264 readers, now that one has been built and we know what one costs; then R-317 `demo-hp` is yours this evening — **this session did not touch it**. After that: the three remaining
(one line in the agent); then the 2026-08-09 batch (R-279 … R-292), still untriaged against everything R-264 readers, now that one has been built and we know what one costs; R-317 (one line in the agent);
since. R-327 (decide what the naming claim's status should be); then the 2026-08-09 batch (R-279 … R-292),
still untriaged against everything since.
+7 -2
View File
@@ -621,5 +621,10 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-318** | **No honest marker exists that says Felhom installed dnsmasq on a machine already in the field, and none can be invented.** Established from source and on the box: the Felhom `/etc/dnsmasq.d/felhom-*.conf` snippets are deleted by the uninstall's own loop (`:1162`) BEFORE the ownership decision and do not survive it; the install state file that carries the record is deleted at `:1268`; nothing under `/etc/felhom*` remains. `/var/log/dpkg.log` does record the install — and is a **timestamp**, which the standing rule refuses as a heuristic dressed as a fact. **So for every box installed before v1.27.0 the answer is the preflight message, and that is a mechanism rather than a rule:** it names the finding, keeps its two routes and its promise not to touch DNS on a host we do not own, and adds *"THIS LOOKS LIKE OURS … systemctl disable --now dnsmasq"*. **Judged as a customer would:** it is honest, it hedges correctly (*looks like*), and it names one exact command — the one thing that gets that person moving. Its weakness is that it asks them to answer *"did this host have dnsmasq before Felhom?"*, which is precisely the question we can no longer answer for them | **CLOSED — established, no action possible for existing boxes** | R-300, R-316 | — | CC | | **R-318** | **No honest marker exists that says Felhom installed dnsmasq on a machine already in the field, and none can be invented.** Established from source and on the box: the Felhom `/etc/dnsmasq.d/felhom-*.conf` snippets are deleted by the uninstall's own loop (`:1162`) BEFORE the ownership decision and do not survive it; the install state file that carries the record is deleted at `:1268`; nothing under `/etc/felhom*` remains. `/var/log/dpkg.log` does record the install — and is a **timestamp**, which the standing rule refuses as a heuristic dressed as a fact. **So for every box installed before v1.27.0 the answer is the preflight message, and that is a mechanism rather than a rule:** it names the finding, keeps its two routes and its promise not to touch DNS on a host we do not own, and adds *"THIS LOOKS LIKE OURS … systemctl disable --now dnsmasq"*. **Judged as a customer would:** it is honest, it hedges correctly (*looks like*), and it names one exact command — the one thing that gets that person moving. Its weakness is that it asks them to answer *"did this host have dnsmasq before Felhom?"*, which is precisely the question we can no longer answer for them | **CLOSED — established, no action possible for existing boxes** | R-300, R-316 | — | CC |
| **R-319** | **The guest-network watchdog finally has a reader — the first of R-264's twenty-one, and it is the repair COUNT that matters, not the state.** The agent has reported `guest_net` on every heartbeat since **v0.92.0** (R-54, 2026-07-21) and the string `guest_net` occurred **nowhere** in `felhom.eu/hub/` — stored as raw text inside `report_json`, read by nothing. **Established before anything was built:** the facts DO arrive and ARE persisted — `demo-felhom-8363b5`'s newest row carries `guest_net.checked_at` plus per-guest `vmid/state/mode/ip/has_route/dhclient_alive/checked_at/message`, and the agent's wire type (`felhom-agent/internal/hub/report.go:139-160`) additionally carries `healed`, `heal_succeeded`, `last_heal_at`, `heals_last_hour`, `damped`, absent from the live rows only because they are `omitempty` on a box that has never needed a repair. **So this was hub-only: no wire change, no agent change, no controller change, nothing to bake.** **What was built** (`hub/internal/web/hosts.go` `parseGuestNet`/`guestNet` + the host-detail *Guest network* card): the hub MODELS the stanza instead of storing it as text, renders it where a person looks at a machine, and **surfaces `heals_last_hour` beside the state rather than behind it** — because a guest the watchdog keeps repairing is healthy at every instant anyone looks and is nevertheless failing, which is the exact shape of the failed-disk-drawn-as-a-healthy-empty-disk defect. `heal_succeeded` is decoded too, and deliberately: **R-260 is this project's warning that a decoder short of three fields dropped the one that decided the question**, and six FAILED repairs is a guest that is down while six successful ones is a nuisance. **An unknown is never drawn as healthy**, following the August companion-flag convention — three absences kept apart with three different sentences (agent older than 0.92.0; a capable agent that sent nothing; a guest whose own `state` the watchdog did not assert), and a malformed stanza degrades to unknown without a 500. **ALARM JUDGEMENT: no email, deliberately.** The incident behind this (`INCIDENT-guest-dhclient-killed-2026-07-20.md`, a killed `dhclient` that took a tunnel down for 1 h 15 m) was about nobody being able to SEE the condition, not about nobody being paged; and a new alarm on a fleet of two demo machines is untested noise on a dispatcher whose severity contract is exact-match. **Revisit when a third machine exists or when a repair count is seen climbing on real hardware** — the visible count is what will supply that evidence. **Red-proofs, mutations asserted applied by grep before each run:** (1) the unknown branches replaced by the healthy badge → **both** C sub-cases go red, a silent machine seen rendering as healthy; (2) `RepairCount: 0` in the decoder → B goes red on *"a guest repaired 6 times in an hour is reported as fine"*; (3) `Degraded()` forced true → A goes red on *"a machine that is fine is being alarmed on"*, proving the guard is reachable in both directions. **Positive control that the wiring is real, not just written: the wire-contract gate's checked-tag count rose 182 → 190 and its skipped count fell 88 → 80** as the eight `guest_net` allowlist entries were REMOVED — an allowlisted tag is skipped, so leaving them would have meant the new fields were never checked at all | **CLOSED — shipped hub-side 2026-08-13** | R-54, R-260, R-264 | — | CC | | **R-319** | **The guest-network watchdog finally has a reader — the first of R-264's twenty-one, and it is the repair COUNT that matters, not the state.** The agent has reported `guest_net` on every heartbeat since **v0.92.0** (R-54, 2026-07-21) and the string `guest_net` occurred **nowhere** in `felhom.eu/hub/` — stored as raw text inside `report_json`, read by nothing. **Established before anything was built:** the facts DO arrive and ARE persisted — `demo-felhom-8363b5`'s newest row carries `guest_net.checked_at` plus per-guest `vmid/state/mode/ip/has_route/dhclient_alive/checked_at/message`, and the agent's wire type (`felhom-agent/internal/hub/report.go:139-160`) additionally carries `healed`, `heal_succeeded`, `last_heal_at`, `heals_last_hour`, `damped`, absent from the live rows only because they are `omitempty` on a box that has never needed a repair. **So this was hub-only: no wire change, no agent change, no controller change, nothing to bake.** **What was built** (`hub/internal/web/hosts.go` `parseGuestNet`/`guestNet` + the host-detail *Guest network* card): the hub MODELS the stanza instead of storing it as text, renders it where a person looks at a machine, and **surfaces `heals_last_hour` beside the state rather than behind it** — because a guest the watchdog keeps repairing is healthy at every instant anyone looks and is nevertheless failing, which is the exact shape of the failed-disk-drawn-as-a-healthy-empty-disk defect. `heal_succeeded` is decoded too, and deliberately: **R-260 is this project's warning that a decoder short of three fields dropped the one that decided the question**, and six FAILED repairs is a guest that is down while six successful ones is a nuisance. **An unknown is never drawn as healthy**, following the August companion-flag convention — three absences kept apart with three different sentences (agent older than 0.92.0; a capable agent that sent nothing; a guest whose own `state` the watchdog did not assert), and a malformed stanza degrades to unknown without a 500. **ALARM JUDGEMENT: no email, deliberately.** The incident behind this (`INCIDENT-guest-dhclient-killed-2026-07-20.md`, a killed `dhclient` that took a tunnel down for 1 h 15 m) was about nobody being able to SEE the condition, not about nobody being paged; and a new alarm on a fleet of two demo machines is untested noise on a dispatcher whose severity contract is exact-match. **Revisit when a third machine exists or when a repair count is seen climbing on real hardware** — the visible count is what will supply that evidence. **Red-proofs, mutations asserted applied by grep before each run:** (1) the unknown branches replaced by the healthy badge → **both** C sub-cases go red, a silent machine seen rendering as healthy; (2) `RepairCount: 0` in the decoder → B goes red on *"a guest repaired 6 times in an hour is reported as fine"*; (3) `Degraded()` forced true → A goes red on *"a machine that is fine is being alarmed on"*, proving the guard is reachable in both directions. **Positive control that the wiring is real, not just written: the wire-contract gate's checked-tag count rose 182 → 190 and its skipped count fell 88 → 80** as the eight `guest_net` allowlist entries were REMOVED — an allowlisted tag is skipped, so leaving them would have meant the new fields were never checked at all | **CLOSED — shipped hub-side 2026-08-13** | R-54, R-260, R-264 | — | CC |
| **R-320** | **Evidence has been destroyed twice in three days, in the same place, by the same act.** 2026-08-12, the retained-key drill: the Phase A logs lived on `drill-r50`'s disk and were destroyed by the revert to `virgin` between Phase A and Phase B (`audits/DRILL-retained-key-2026-08-12.md` §11.5). 2026-08-13, R-316: the Part 1 logs, same disk, same revert, between Part 1 and Part 2 (`audits/REPORT-r316-installer-v1.28.0-2026-08-13.md` §9 — *"the same mistake as Tuesday, in the same place"*). **That report was copied into `audits/` by this session on purpose:** it lived in `REPORT.md`, which every session overwrites — so writing tonights report would have destroyed the record of a destroyed record. **Both times the conclusions survived on luck** — the quotations had been read live, and an independent reproduction happened to exist. **Both times the golden-bake runbook's existing "scp the log OUT first" was applied to the FINAL teardown and not the intermediate one**, which is the whole finding: the middle revert is the one that gets forgotten. **The rule, now standing rule 5 in `workspace-CLAUDE.md` (so it loads in every session) and repeated where a session actually meets it — `runbooks/target-selection.md`, `RUNBOOK-rehearsal-v3.md`, and the `PROMPT-TEMPLATE.md` report section: evidence is copied off the machine at the end of the phase that produced it, before any revert, snapshot restore or teardown — not at the end of the session.** **A rule without a mechanism is a wish, so the mechanism is named: the pull is the LAST ACT OF THE PHASE**, not a step to remember later. **And the already-gone case is documented rather than improvised: say so plainly in the report and reproduce the finding independently** — which is what both sessions did, and it should be the expectation rather than a good instinct under pressure | **CLOSED — rule written, four homes** | — | — | CC | | **R-320** | **Evidence has been destroyed twice in three days, in the same place, by the same act.** 2026-08-12, the retained-key drill: the Phase A logs lived on `drill-r50`'s disk and were destroyed by the revert to `virgin` between Phase A and Phase B (`audits/DRILL-retained-key-2026-08-12.md` §11.5). 2026-08-13, R-316: the Part 1 logs, same disk, same revert, between Part 1 and Part 2 (`audits/REPORT-r316-installer-v1.28.0-2026-08-13.md` §9 — *"the same mistake as Tuesday, in the same place"*). **That report was copied into `audits/` by this session on purpose:** it lived in `REPORT.md`, which every session overwrites — so writing tonights report would have destroyed the record of a destroyed record. **Both times the conclusions survived on luck** — the quotations had been read live, and an independent reproduction happened to exist. **Both times the golden-bake runbook's existing "scp the log OUT first" was applied to the FINAL teardown and not the intermediate one**, which is the whole finding: the middle revert is the one that gets forgotten. **The rule, now standing rule 5 in `workspace-CLAUDE.md` (so it loads in every session) and repeated where a session actually meets it — `runbooks/target-selection.md`, `RUNBOOK-rehearsal-v3.md`, and the `PROMPT-TEMPLATE.md` report section: evidence is copied off the machine at the end of the phase that produced it, before any revert, snapshot restore or teardown — not at the end of the session.** **A rule without a mechanism is a wish, so the mechanism is named: the pull is the LAST ACT OF THE PHASE**, not a step to remember later. **And the already-gone case is documented rather than improvised: say so plainly in the report and reproduce the finding independently** — which is what both sessions did, and it should be the expectation rather than a good instinct under pressure | **CLOSED — rule written, four homes** | — | — | CC |
| **R-321** | **A box on which reporting is deliberately switched off still alarms as stale, then down.** Found while deciding `reporting_disabled` on its own merits (R-264). **The product supports the state:** with hub reporting off the controller sends ONE minimal report carrying `reporting_disabled: true` and `health.status: "disabled"` (`felhom-controller/controller/cmd/controller/main.go:1246-1260`) and then goes quiet by design. **The state IS visible**`health_status` is decoded and stored, and `hub/internal/web/rollup.go:25` renders that customer as `disabled`, which is why the `reporting_disabled` FLAG itself is reclassified *redundant* rather than owed a reader: it is a second spelling of a fact already read. **But the alarm does not consult it.** `StalenessChecker.Check` (`hub/internal/monitor/staleness.go:88+`) computes its verdict from report AGE alone; the only skip is `IsCustomerBlocked`. So a deliberately-silent box goes `node_stale` at 30 minutes and `node_down` at 60, and the operator is paged about a machine that is fine — **the classic false alarm that teaches people to ignore the channel**, and a sibling of R-195, where the guard that should have covered a customer was keyed off the wrong fact. **NOT FIXED HERE, deliberately: adding a decoder for the flag would have felt like progress and left the alarm firing.** The fix belongs in the checker, which already holds the health status it needs. **Bounded honestly: no machine is in this state today** (all three known hosts report normally), so this is a latent defect found by reading, not an observed outage | **READY (S) — NEW 2026-08-13, RANK 3** | R-195, R-264 | Skip (or downgrade) the staleness verdict for a customer whose last report declared `health.status = "disabled"`, and **log the deferral with its own counter** — the v0.73.0 precedent: a quiet check must not look like a check that did not run. Fail OPEN on an unreadable status: an unreadable state must never SUPPRESS a real alarm | CC | | **R-321** | **A box on which reporting is deliberately switched off still alarms as stale, then down.** Found while deciding `reporting_disabled` on its own merits (R-264). **The product supports the state:** with hub reporting off the controller sends ONE minimal report carrying `reporting_disabled: true` and `health.status: "disabled"` (`felhom-controller/controller/cmd/controller/main.go:1246-1260`) and then goes quiet by design. **The state IS visible**`health_status` is decoded and stored, and `hub/internal/web/rollup.go:25` renders that customer as `disabled`, which is why the `reporting_disabled` FLAG itself is reclassified *redundant* rather than owed a reader: it is a second spelling of a fact already read. **But the alarm does not consult it.** `StalenessChecker.Check` (`hub/internal/monitor/staleness.go:88+`) computes its verdict from report AGE alone; the only skip is `IsCustomerBlocked`. So a deliberately-silent box goes `node_stale` at 30 minutes and `node_down` at 60, and the operator is paged about a machine that is fine — **the classic false alarm that teaches people to ignore the channel**, and a sibling of R-195, where the guard that should have covered a customer was keyed off the wrong fact. **NOT FIXED HERE, deliberately: adding a decoder for the flag would have felt like progress and left the alarm firing.** The fix belongs in the checker, which already holds the health status it needs. **Bounded honestly: no machine is in this state today** (all three known hosts report normally), so this is a latent defect found by reading, not an observed outage. **FIXED 2026-08-13, hub v0.105.0 — and it turned out to be TWO doors, not one.** **(1) `StalenessChecker.Check`** skips the age transition for a customer whose last report declared `health.status = "disabled"`. The discriminator is the box's OWN last word, not an inference — which is what makes this a suppression rather than a guess, and the §5 halt condition (*"suppressing an alarm on a guess is worse than the false alarm it removes"*) therefore did not fire. **The state is RECORDED (`StateDisabled`), not deleted** — and that choice is load-bearing rather than cosmetic. The `blocked` precedent deletes, but a deleted state returns `""` from `GetState`, and `""` is not `"down"`, so **(2) `CheckBackupDeadlines` would have gone on e-mailing `expected_backup_missed` every morning about the same machine.** That is R-195's shape returning through a second door, which is why both are closed together. Recording it also satisfies the visibility requirement: quiet-on-purpose and quiet-by-accident no longer look identical. `downtimeStart` is cleared **on entry**, so a later genuine outage cannot compute its duration from a clock that started before we asked for the silence — the `blocked` branch does not do this, which is filed as an observation rather than changed under this row. **THE RE-ENABLEMENT CLOCK, stated because it was a judgement:** it runs from the report the hub can actually see, so for a box that reports on re-enabling the clock starts at re-enablement. Timing from the last report BEFORE the switch-off would fire an instant stale/down for a quiet period we requested — a false alarm produced by fixing false alarms. Leaving `disabled` re-enters the same branch as a NEW customer: a first observation, never a `node_recovered` for an outage that did not happen. **LIMIT, stated rather than hidden:** if reporting is re-enabled and the box then fails to report at all, the hub still sees only that final `disabled` report and keeps suppressing. The hub cannot distinguish that from *still switched off* — its view changes only when a report arrives. This is precisely why the state is made visible: an operator who re-enabled a box and still sees `disabled` is being told it has not come back. **Four scenarios and three red-proofs, every mutation asserted applied by grep before its run.** The one that matters: making the suppression unconditional was seen leaving a genuinely dead machine at `"disabled"` instead of `"down"`**a real alarm demonstrably cannot be swallowed unnoticed.** Removing the suppression reproduced today's behaviour, `node_down` and all. Making the state STICKY (remembered instead of re-read from the box) produced the permanent silence — a once-disabled machine never alarming again. **A weakness in the tests themselves was found by a red-proof and fixed:** scenario A originally seeded the customer already-disabled, so the checker's new-customer branch swallowed the first observation and the test passed on its state assertion alone even with the suppression deleted. It now observes the machine HEALTHY first, and the same red-proof then fails on the EVENT — `emitted [node_down]`, the actual false alarm, reproduced | **CLOSED — shipped hub v0.105.0, both doors** | R-195, R-264 | — | CC |
| **R-322** | **The claim guard has never scanned the hub, and the hub sends the customer's first sentence.** `retrieval_promise_gate.py` lives in `felhom-controller/controller/scripts/` and its declared surfaces are that repo's `internal/web/templates` plus ONE Go handler file — extended to Go strings only on 2026-08-12 (R-311), on the ground that *"the highest-stakes customer copy in the product… none of it had ever been scanned"*. **The same sentence is true one repo over.** The hub composes and sends every customer-facing e-mail (`internal/notify/templates.go`) and renders the self-bind pages, i.e. the copy a customer reads BEFORE they ever see a box screen, and no gate in either repo looks at it. **Scanned by hand while shipping R-295's hub half: `felhom.eu/hub/` returns ZERO occurrences of all four stems** (`visszaállíthat`, `visszaszerezhet`, `visszahozhat`, `visszanyit`) across every non-test `.go` and `.html` under `internal/`. **So nothing was hiding, and this is a SCOPE gap rather than a live defect** — which is exactly the moment to record it, before the first hub-side retrieval promise is written by someone who assumes the guard has them covered. **Recommended shape, and the reason it is not one line:** the gate is invoked by `controller_gates.py`, so pointing it at a sibling repo makes a controller gate fail on a felhom.eu edit — the cross-repo lesson from G-1 (a gate needing a sibling passes locally and exits INCONCLUSIVE in CI, and must never SKIP when the sibling is absent). The cheaper honest option is a hub-side sibling registered in `repo_gates.py` that shares the stem list, with ONE list rather than two drifting copies | **READY (S) — NEW 2026-08-13, RANK 3** | R-294, R-299, R-302, R-311 | Scan the hub's customer-facing surfaces with the same stems, from `repo_gates.py`. **Share the stem list — two copies of a word list is how the plural got past the singular (R-299).** Do NOT make a controller gate depend on a felhom.eu clone | CC | | **R-322** | **The claim guard has never scanned the hub, and the hub sends the customer's first sentence.** `retrieval_promise_gate.py` lives in `felhom-controller/controller/scripts/` and its declared surfaces are that repo's `internal/web/templates` plus ONE Go handler file — extended to Go strings only on 2026-08-12 (R-311), on the ground that *"the highest-stakes customer copy in the product… none of it had ever been scanned"*. **The same sentence is true one repo over.** The hub composes and sends every customer-facing e-mail (`internal/notify/templates.go`) and renders the self-bind pages, i.e. the copy a customer reads BEFORE they ever see a box screen, and no gate in either repo looks at it. **Scanned by hand while shipping R-295's hub half: `felhom.eu/hub/` returns ZERO occurrences of all four stems** (`visszaállíthat`, `visszaszerezhet`, `visszahozhat`, `visszanyit`) across every non-test `.go` and `.html` under `internal/`. **So nothing was hiding, and this is a SCOPE gap rather than a live defect** — which is exactly the moment to record it, before the first hub-side retrieval promise is written by someone who assumes the guard has them covered. **Recommended shape, and the reason it is not one line:** the gate is invoked by `controller_gates.py`, so pointing it at a sibling repo makes a controller gate fail on a felhom.eu edit — the cross-repo lesson from G-1 (a gate needing a sibling passes locally and exits INCONCLUSIVE in CI, and must never SKIP when the sibling is absent). The cheaper honest option is a hub-side sibling registered in `repo_gates.py` that shares the stem list, with ONE list rather than two drifting copies | **CLOSED 2026-08-13 by R-324**`scripts/hub_copy_gate.py`, registered in `repo_gates.py`, scanning 95 hub files for retired names and four declared customer surfaces for retrieval stems, with a plant→convict→remove→pass selftest that caught a defect in its own instrument on the first run. The stem list IS shared (`scripts/customer_copy_vocab.py`) and no controller gate was made to depend on a felhom.eu clone; the controller gate's adoption of the shared list is R-325, and until it happens the two are drift-checked rather than left to diverge | R-294, R-299, R-302, R-311, R-324 | — | CC |
| **R-323** | **The third near-homograph — the five-word phrase is „Tulajdonosi jelmondat” now.** Found and deliberately LEFT ALONE while shipping R-295's hub half (it was reported as an observation rather than swept in, which was right — it is a different secret and a different decision), then **RULED by the operator 2026-08-13: rename it on the same reasoning.** **Enumeration first, at `file:line`, and it is small: FIVE customer-facing sites, all in the hub**`web/selfbind.go:255` (lead), `:256` (failure banner), `:261` (field label), `:263` (hint), and `notify/templates.go:331` (the self-bind mail's item 2). **NOTHING in `felhom-controller` or `felhom-agent`** — the only hits there are comments and a test asserting the ALREADY-retired „Visszaállító **kód**” is absent, so the §5 halt did not fire and no bake was needed. Operator-facing surfaces call it *"Retrieval Password"* in English and the installer uses `FELHOM_RETRIEVAL_PASSPHRASE` as an identifier; neither is customer copy and neither was touched. **THE NAME, argued against the three-secret table rather than chosen by habit.** The phrase proves the account owns the box being bound — **it restores nothing, so the old name was simply false.** Both suggested names were rejected with reasons: **„Fiókjelszó” is worse than the trap it fixes**, because there IS an account password (the dashboard login), so it would collide with a *different* real secret; and **„Összekötési jelszó” recreates the trap structurally**, because the OTHER factor on the very same page and in the same mail is the **„Párosító kód”** — naming this one after the same act would leave the two factors a customer types in one sitting separated only by kód-versus-jelszó, which is exactly the „Visszaállító kód”/„Visszaállító jelszó” shape being removed. **„Tulajdonosi jelmondat” is distinct on BOTH axes** — stem (Tulajdonosi vs Beállító / Helyreállítási / Párosító) and noun (jelmondat vs kód / jelszó) — and it says what the phrase does. *If a plainer noun is ever wanted, „Tulajdonosi jelszó” is a one-word change; the stem is what carries the separation.* **Naming only:** the form field is still `name="passphrase"`, no acceptance logic moved, and `TestSelfBindPassphrase_StillAcceptedAfterTheRename` drives the real handler with the same messy human spacing and asserts the appliance still binds. **NO CUSTOMER-FACING DOCUMENT NAMES THE OLD PHRASE** — the tester agreement does not mention it at all and the runbooks call it by its English operator name, so **nothing printed is stranded**; the phrase reaches a customer out-of-band from the operator, which means the only stale copy is whatever was said in a message or on the telephone | **CLOSED — shipped hub v0.105.0** | R-295, R-324 | — | CC |
| **R-324** | **The hub's customer copy is under a guard for the first time — and the guard has been watched catching, ignoring and releasing.** Closes the scope gap filed as R-322. `retrieval_promise_gate.py` lives in `felhom-controller` and scans that repo only; it was extended to Go strings on 2026-08-12 on the express ground that the recovery screen's copy *"had never been scanned"* — and the identical sentence was true one repo over the whole time, for the surface a customer reads FIRST. **`scripts/hub_copy_gate.py`, registered in `repo_gates.py`, two checks with a deliberate difference.** **(1) RETIRED NAMES are banned outright, across the WHOLE hub** (95 files scanned), with no allowlist: a name a different secret now owns is never correct anywhere. Comments are stripped — prose explaining a rename is not the rename returning, and the register rows quote the retired names by necessity. **(2) RETRIEVAL STEMS are registered, not banned**, in four declared customer surfaces (`notify/templates.go`, `web/selfbind.go`, `api/handler.go`, `notify/dispatcher.go`), a missing declared surface being a FAILURE rather than a skip. The allowlist is **empty, and that is a measurement**: the hub makes no retrieval promise today. **POSITIVE CONTROL, because a guard never seen catching anything proves nothing**`--selftest` runs plant → convict → remove → pass, and it **found a defect in its own instrument on the first run**: the synthetic source was named `<selftest>`, comment-stripping keys off the `.go` extension, and step 3's control convicted a comment. The bug was in the guard, and the control is what found it. **Vocabulary is NOT duplicated:** both lists live in `scripts/customer_copy_vocab.py`, the same shared-gate home `reuse_refs_check.py` and `instructions_gate.py` already use from both repos | **CLOSED — shipped, selftest green** | R-295, R-299, R-311, R-322, R-323, R-325 | — | CC |
| **R-325** | **The shared copy vocabulary is imported by ONE of its two consumers, and drift-checked into the other.** `customer_copy_vocab.py` is the single list; `hub_copy_gate.py` imports it. **`felhom-controller/controller/scripts/retrieval_promise_gate.py` still carries its own `STEMS` literal**, because the session that created the shared module was under a hard end-state requirement to leave `felhom-controller` untouched — its target box was being re-deployed the same evening. **Two copies of a word list is not a theoretical risk in this project: it is the R-299 defect exactly**, where a guard asserted one inflection of a Hungarian verb and the plural walked past it. **So the gap is instrumented rather than left open: `hub_copy_gate.py` READS the controller gate's `STEMS` and FAILS if the two disagree** — single-source semantics tonight without a cross-repo edit. **Watched failing:** removing one stem from the shared list produced *"the shared vocabulary is no longer shared"* with both lists printed, and restoring it returned the gate to green. An ABSENT sibling clone is **INCONCLUSIVE (exit 2), never a pass** — the G-1 lesson. **This is a scaffold, not the destination** | **READY (S) — NEW 2026-08-13, RANK 3** | R-299, R-324 | Make `retrieval_promise_gate.py` import `felhom.eu/scripts/customer_copy_vocab.py` and delete its own literal — a felhom-controller change of a few lines, needing no bake (a gate is not shipped code). Then the drift check becomes redundant and should be removed with it, rather than left as a second mechanism nobody re-reads | CC |
| **R-326** | **"Which claims are unproven?" is a question a machine can answer now — and the number everyone was repeating answered a different question.** On 2026-08-13 a session was asked to report on *"the nine grey claims"*, could not determine which nine, and **declined to guess. It was right, and the refusal is the finding.** **WHERE NINE CAME FROM, established rather than assumed: it is real, and it is the count of claims carrying `verdict: downgraded`** — the ones the 2026-08-09 verification pass LOWERED. That is not "unproven"; it is "re-judged". **THE REAL NUMBERS, measured from `where-felhom-stands.yaml`: 55 claims — `walked` 23, `partial` 14, `built` 14, `missing` 4. NOT WALKED: 32 of 55.** And the evidence half, which is the sharper cut: **all 23 walked claims cite an evidence document** (`check_stands.py` convicts a `walked` claim without one), while of the 32 that are not walked **only 6 cite evidence and 26 are prose only**. **`scripts/unproven.py`** prints every not-walked claim with its status, band, verdict and whether it cites evidence; `--summary` prints the counts alone. It reads the dataset ONLY — it opens no evidence, judges nothing and contacts no machine, because a status is the capability map's business and the map moves first. **Wired into the end-of-session checklist** in `CLAUDE.md`, so a status that moves is noticed. **ITS FIRST RUN FOUND A STALE CLAIM:** `claim.code-naming` is still `partial` and its title still describes the defect R-295 and R-323 have now closed — see R-327. **NOT DONE, and deliberately: the capability map is not restructured.** Its illegibility is real and filed with two measured costs, but it is surgery on this project's memory and wants daylight and a session of its own — recorded here so it does not read as forgotten | **CLOSED — shipped** | R-327 | — | CC |
| **R-327** | **The standing picture still describes a defect that has been fixed twice over.** Found by the first run of `unproven.py` (R-326), which is the argument for having built it. `where-felhom-stands.yaml`'s `claim.code-naming` is `status: partial` and its title reads *"The same word is used for two different secrets across three surfaces; the email points at a page a rebuilt machine does not show"***both halves of which are now false.** The box side shipped 2026-08-10 (R-295), the hub half and the page-naming fix on 2026-08-13 (R-295 hub, new `reenroll` mail kind), and the third near-homograph on 2026-08-13 (R-323). **NOT MOVED BY THIS SESSION, deliberately and by the dataset's own rule:** *"A status may not be RAISED here — if the evidence supports a stronger status than the capability map records, the MAP changes first and this file follows it."* Raising it here would be the exact inversion the file's header forbids, and the map edit is a separate judgement about what "walked" means for a naming change that no customer has yet met | **READY (S) — NEW 2026-08-13, RANK 4** | R-295, R-323, R-326 | Decide the capability-map status for the naming arc, then let the dataset follow it. **Note the honest difficulty: no customer has typed „Tulajdonosi jelmondat” yet**, so `walked` would be an over-claim; `built` is probably right, and the title needs rewriting either way because it describes a defect rather than a capability | operator + CC |
+90
View File
@@ -1,3 +1,93 @@
## v0.105.0 — the third name, a machine told to be quiet, and a guard for the hub's own words (2026-08-13)
Hub only. **No controller change, no agent change, no wire change — nothing to bake.** R-323, R-324,
R-321, R-326.
### R-323 — the third near-homograph: „Tulajdonosi jelmondat"
Three secrets a customer can hold, and they must be tellable apart by a hurried reader:
| | what it is | name |
|---|---|---|
| three words | takes control of the dashboard | „Beállító kód" |
| ten words | opens the sealed off-site backups | „Helyreállítási kód" |
| **five words** | **proves the account owns the box being bound** | **„Tulajdonosi jelmondat"** |
The five-word phrase was „Visszaállító jelszó" — **one word from the name retired last week for
colliding with „Helyreállítási kód"**, and false besides: the phrase restores nothing.
**Five customer-facing sites, all here**: `web/selfbind.go:255,256,261,263` and
`notify/templates.go:331`. Nothing in `felhom-controller` or `felhom-agent` — the only hits there are
comments and a test asserting the already-retired „Visszaállító **kód**" is absent.
**Both obvious names were rejected, with reasons.** „Fiókjelszó" is worse than the trap it fixes:
there IS an account password (the dashboard login), so it would collide with a *different* real
secret. „Összekötési jelszó" recreates the trap structurally — the other factor on the same page and
in the same mail is the **„Párosító kód"**, so naming this one after the same act would leave the two
factors a customer types in one sitting separated only by kód-versus-jelszó, which is exactly the
shape being removed. „Tulajdonosi jelmondat" is distinct on **both** axes — stem and noun.
**Naming only.** The form field is still `name="passphrase"`;
`TestSelfBindPassphrase_StillAcceptedAfterTheRename` drives the real handler with the same messy human
spacing and asserts the appliance still binds.
### R-324 — the hub's customer copy comes under a guard, for the first time
`retrieval_promise_gate.py` lives in `felhom-controller` and scans that repo only. It was extended to
Go strings on 2026-08-12 because the recovery screen's copy *"had never been scanned"* — and the same
sentence was true one repo over, for the surface a customer reads **first**.
`scripts/hub_copy_gate.py`, registered in `repo_gates.py`:
- **retired names — banned outright, across all 95 hub files**, no allowlist. Comments stripped.
- **retrieval stems — registered, not banned**, in four declared customer surfaces. A missing declared
surface is a FAILURE, never a skip. The allowlist is empty, and that is a measurement.
**The selftest found a defect in its own instrument on the first run**: the synthetic source was named
`<selftest>`, comment-stripping keys off the `.go` extension, and the step-3 control convicted a
comment. The bug was in the guard; the control is what found it.
Vocabulary is **not duplicated**`scripts/customer_copy_vocab.py` is the one list, in the same
shared-gate home both repos already consume without copying. The controller gate has not yet adopted
it (that would be a felhom-controller change, out of scope tonight), so this gate **reads its `STEMS`
and fails on divergence** — watched failing, and green again when restored. R-325 removes the scaffold.
### R-321 — a machine we told to be quiet is not a machine that died
Switching a box's reporting off is supported: the controller sends one final report carrying
`health.status = "disabled"` and goes quiet by design. The hub stored that, rendered it in the
roll-up, and **alarmed on it anyway** — stale at 30 minutes, down at 60, two e-mails about an outage
we caused on purpose.
**It was two doors, not one.** `StalenessChecker.Check` now skips the age transition; and because the
state is **recorded** (`StateDisabled`) rather than deleted, `CheckBackupDeadlines` can skip it too —
a deleted state returns `""`, which is not `"down"`, so that check would have gone on sending
`expected_backup_missed` every morning. R-195's shape returning through a second door.
The discriminator is the box's own last word, not an inference. `downtimeStart` is cleared on entry.
**The re-enablement clock runs from the report the hub can see**, so a box that reports on re-enabling
starts its clock there; leaving `disabled` re-enters the new-customer branch, so no `node_recovered`
fires for an outage that never happened. **Limit, stated:** a box re-enabled that then fails to report
keeps being suppressed — the hub's view changes only when a report arrives, which is exactly why the
state is made visible.
**Three red-proofs, mutations asserted applied.** The one that matters: an unconditional suppression
was seen leaving a genuinely dead machine at `"disabled"` instead of `"down"`. A test weakness was
found by a red-proof and fixed — scenario A seeded the customer already-disabled, so the new-customer
branch swallowed the first observation and it passed on the state assertion alone even with the
suppression deleted.
### R-326 — "what is unproven" becomes a question a machine can answer
A session asked for *"the nine grey claims"* could not determine which nine and declined to guess.
**Nine is real and answers a different question: it is the count of claims the 2026-08-09 pass
DOWNGRADED.** The real figures, from `where-felhom-stands.yaml`: **55 claims — walked 23, partial 14,
built 14, missing 4; NOT WALKED 32 of 55**, of which only 6 cite evidence and 26 are prose only.
`scripts/unproven.py` prints them; `--summary` prints the counts. Wired into the end-of-session
checklist. Its first run found a stale claim — `claim.code-naming` still describes a defect now fixed
twice over (R-327). The capability map is deliberately **not** restructured.
## v0.104.0 — the hub can see whether a guest's networking works, and one name per secret (2026-08-13, R-319 + R-295 hub half) ## v0.104.0 — the hub can see whether a guest's networking works, and one name per secret (2026-08-13, R-319 + R-295 hub half)
Two things, both hub-only. **No wire change, no agent change, no controller change, nothing to bake.** Two things, both hub-only. **No wire change, no agent change, no controller change, nothing to bake.**
+19 -2
View File
@@ -324,6 +324,16 @@ func init() {
// //
// Customers whose nodes are "down" (no report in >1h) are skipped — they // Customers whose nodes are "down" (no report in >1h) are skipped — they
// already have staleness events. // already have staleness events.
// stalenessState reads a customer's staleness state, tolerating a nil checker. Named rather than
// inlined so the two states this function skips on are visible in one place — a second caller adding
// a third state must not have to rediscover that the nil check exists.
func stalenessState(staleness *StalenessChecker, customerID string) string {
if staleness == nil {
return ""
}
return staleness.GetState(customerID)
}
func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent EventNotifyFunc, logger *log.Logger) { func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent EventNotifyFunc, logger *log.Logger) {
customerIDs, err := s.GetActiveCustomerIDs() customerIDs, err := s.GetActiveCustomerIDs()
if err != nil { if err != nil {
@@ -339,8 +349,15 @@ func CheckBackupDeadlines(s *store.Store, staleness *StalenessChecker, onEvent E
var backupMissed, dbdumpMissed, skipped, deferred, unbound int var backupMissed, dbdumpMissed, skipped, deferred, unbound int
for _, id := range customerIDs { for _, id := range customerIDs {
// Skip nodes that are down — they already have staleness events // Skip nodes that are down — they already have staleness events.
if staleness != nil && staleness.GetState(id) == "down" { //
// R-321 adds StateDisabled to the same skip, and it is NOT cosmetic. This check is a second
// door onto the same false alarm: a box whose reporting we switched off deliberately is not
// "down" (the staleness checker suppresses that), so without this it would keep e-mailing
// `expected_backup_missed` / `expected_dbdump_missed` every morning about a machine we asked
// to be quiet. That is R-195's shape exactly — a skip keyed off the wrong fact missing the
// customer it would most obviously cover — which is why both doors are closed together.
if st := stalenessState(staleness, id); st == "down" || st == StateDisabled {
skipped++ skipped++
continue continue
} }
+57 -2
View File
@@ -3,6 +3,7 @@ package monitor
import ( import (
"fmt" "fmt"
"log" "log"
"strings"
"sync" "sync"
"time" "time"
@@ -13,6 +14,27 @@ import (
// to trigger notification dispatch. Keeps monitor decoupled from notify. // to trigger notification dispatch. Keeps monitor decoupled from notify.
type EventNotifyFunc func(customerID, eventType, severity, message, detailsJSON, source string) type EventNotifyFunc func(customerID, eventType, severity, message, detailsJSON, source string)
// StateDisabled is the state of a customer whose box we DELIBERATELY told to stop reporting.
//
// R-321. Switching a box's hub reporting off is a supported product state: the controller sends one
// final report carrying health.status = "disabled" (felhom-controller
// `cmd/controller/main.go:1253`) and then goes quiet BY DESIGN. That status is parsed and persisted
// (`store.go:953-955` → `reports.health_status`), reaches this checker on every pass inside
// `CustomerSummary.HealthStatus` (`store.go:40`), and the operator roll-up already renders such a
// customer as `disabled` (`web/rollup.go:25`).
//
// This checker ignored it and measured only the AGE of the last report — so a machine we asked to be
// quiet went stale at 30 minutes, down at 60, and e-mailed the operator twice about an outage we
// caused on purpose. That is the false alarm that teaches people to ignore the channel, and it is a
// sibling of R-195, where the guard that should have covered a customer was keyed off the wrong fact.
//
// It is a STATE here rather than a deletion (the `blocked` branch below deletes) because other
// checkers consult GetState: CheckBackupDeadlines skips a customer that is "down", and a DELETED
// state returns "" — which is not "down", so a disabled box would have gone on alarming
// `expected_backup_missed` from a different function. Exactly the R-195 shape returning through a
// second door.
const StateDisabled = "disabled"
// StalenessChecker monitors customer report freshness and generates // StalenessChecker monitors customer report freshness and generates
// node_stale / node_down / node_recovered events on state transitions. // node_stale / node_down / node_recovered events on state transitions.
type StalenessChecker struct { type StalenessChecker struct {
@@ -94,6 +116,27 @@ func (sc *StalenessChecker) Check() {
continue continue
} }
// R-321 — a machine we told to be quiet is not a machine that died.
//
// The age-based transition is skipped entirely; no stale, no down, no e-mail. The state is
// RECORDED rather than deleted so the deliberate silence is visible to anything that asks
// (see StateDisabled). `downtimeStart` is cleared on ENTRY so that a later, genuine outage
// cannot compute its duration from a clock that started before we asked for the silence.
//
// The discriminator is the box's OWN last word, not an inference: it said `disabled` on the
// way out. LIMIT, stated rather than hidden: if reporting is re-enabled and the box then
// fails to report at all, the hub still sees only that final `disabled` report and keeps
// suppressing. The hub cannot distinguish that from "still switched off" — its view changes
// only when a report arrives. This is why the state is made VISIBLE: an operator who
// re-enabled a box and still sees `disabled` is being told it has not come back.
if strings.EqualFold(c.HealthStatus, StateDisabled) {
if sc.states[c.CustomerID] != StateDisabled {
sc.states[c.CustomerID] = StateDisabled
delete(sc.downtimeStart, c.CustomerID)
}
continue
}
age := time.Since(c.ReceivedAt) age := time.Since(c.ReceivedAt)
var newState string var newState string
switch { switch {
@@ -106,8 +149,20 @@ func (sc *StalenessChecker) Check() {
} }
oldState := sc.states[c.CustomerID] oldState := sc.states[c.CustomerID]
if oldState == "" { if oldState == "" || oldState == StateDisabled {
// New customer — set state without event // New customer — set state without event.
//
// R-321 adds the re-enabled case to the SAME branch, deliberately. A box coming back
// from a deliberate silence is a first observation, not a recovery: emitting here would
// send `node_recovered` for an outage that never happened — and would do it every time
// an operator switched reporting back on.
//
// THE RE-ENABLEMENT CLOCK, and this is the judgement: it is the age of the report the
// hub can actually see. For a box that reports on re-enabling, that report IS the
// re-enablement, so the clock starts there and scenario D (re-enabled, then genuinely
// quiet) alarms on a normal schedule timed from the moment it came back. Timing from the
// last report BEFORE the switch-off would fire an instant stale/down for a quiet period
// we asked for — a false alarm produced by fixing false alarms.
sc.states[c.CustomerID] = newState sc.states[c.CustomerID] = newState
continue continue
} }
@@ -0,0 +1,226 @@
package monitor
// R-321 — A MACHINE WE TOLD TO BE QUIET IS NOT A MACHINE THAT DIED.
//
// Switching a box's hub reporting off is a supported product state. The controller sends one final
// report carrying health.status = "disabled" and then goes quiet BY DESIGN. The hub parses and stores
// that status, and the operator roll-up already renders such a customer as `disabled` — but this
// checker measured only the AGE of the last report, so the machine went stale at 30 minutes, down at
// 60, and e-mailed the operator twice about an outage we caused on purpose.
//
// The discriminator is the box's OWN last word, not an inference, and it is in the data this checker
// already reads (`CustomerSummary.HealthStatus`). Suppressing on a guess would be worse than the
// false alarm it removes; suppressing on the machine's own declaration is not a guess.
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
// seedStalenessCustomer creates a store with one customer and one report of the given health status,
// backdated by `age`. Reports are inserted through the REAL SaveReport path so the health_status
// denormalization is exercised rather than hand-set — a test that writes the column directly cannot
// see a decode that never happens, which is the R-260 class.
func seedStalenessCustomer(t *testing.T, health string, age time.Duration) (*store.Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "t.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p", Status: "active",
}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
saveReportAged(t, st, path, health, age)
return st, path
}
// saveReportAged saves a controller report with the given health status and then backdates its
// received_at. The backdate is a raw UPDATE because there is no production path that writes an old
// timestamp — which is exactly why it is confined to this one helper.
func saveReportAged(t *testing.T, st *store.Store, path, health string, age time.Duration) {
t.Helper()
body := `{"customer_id":"c1","health":{"status":"` + health + `"}}`
if err := st.SaveReport("c1", []byte(body)); err != nil {
t.Fatalf("SaveReport: %v", err)
}
if age <= 0 {
return
}
// Backdating goes through a SECOND connection to the same file rather than a test-only method on
// Store. No production path writes an old received_at, so exposing one would widen the store's
// API for a fixture — and this keeps the ageing confined to the one helper that needs it.
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("open for backdate: %v", err)
}
defer db.Close()
// GetCustomers selects the report with MAX(received_at), not MAX(id) — so backdating only the
// row just written would leave an EARLIER, fresher-looking row as the one the checker reads, and
// the fixture would quietly test the wrong report. (Found by two scenarios failing on a change
// that was correct: the instrument was wrong, not the code.) Every older row is therefore pushed
// further back, so the row just written is unambiguously the latest.
when := time.Now().UTC().Add(-age)
newest := when.Format("2006-01-02 15:04:05")
older := when.Add(-time.Hour).Format("2006-01-02 15:04:05")
if _, err := db.Exec(
`UPDATE reports SET received_at = ? WHERE customer_id = 'c1' AND id < (SELECT MAX(id) FROM reports WHERE customer_id = 'c1')`,
older); err != nil {
t.Fatalf("age the older rows: %v", err)
}
if _, err := db.Exec(
`UPDATE reports SET received_at = ? WHERE id = (SELECT MAX(id) FROM reports WHERE customer_id = 'c1')`,
newest); err != nil {
t.Fatalf("backdate: %v", err)
}
}
// newChecker builds a checker with a 30-minute threshold and records every event it emits.
func newChecker(t *testing.T, st *store.Store) (*StalenessChecker, *[]string) {
t.Helper()
var events []string
sc := NewStalenessChecker(st, 30*time.Minute, func(cid, et, sev, msg, det, src string) {
events = append(events, et)
}, log.New(io.Discard, "", 0))
return sc, &events
}
// ── A — a machine deliberately silent for days ──────────────────────────────────────────────────
//
// WRONG OUTCOME GUARDED: stale, then down, then two e-mails — which is what happens today.
func TestStaleness_A_DeliberatelySilentDoesNotAlarm(t *testing.T) {
// The box is HEALTHY and OBSERVED first, then switched off. Seeding it already-disabled would
// make this test pass vacuously: the checker's new-customer branch sets the first state without
// an event, so a customer that was never seen healthy can never emit a transition — and the
// assertion below would hold even with the suppression deleted. (Confirmed: it did. The
// red-proof for this scenario passed on the state assertion alone until this line was added.)
st, path := seedStalenessCustomer(t, "ok", 0)
sc, events := newChecker(t, st)
sc.Check()
if sc.GetState("c1") != "ok" {
t.Fatalf("setup: the machine should start observed-healthy, got %q", sc.GetState("c1"))
}
// Reporting is switched off. The box says so on the way out, then goes quiet for three days.
saveReportAged(t, st, path, "disabled", 72*time.Hour)
for i := 0; i < 3; i++ { // several passes: a suppression that only holds once is not a suppression
sc.Check()
}
if len(*events) != 0 {
t.Fatalf("a deliberately-disabled machine emitted %v — three days quiet BY REQUEST", *events)
}
// The silence must be VISIBLE, not merely un-alarmed: quiet-on-purpose and quiet-by-accident
// must not look identical, and an unknown is never drawn as healthy.
if got := sc.GetState("c1"); got != StateDisabled {
t.Errorf("state = %q, want %q — the deliberate silence is invisible", got, StateDisabled)
}
}
// ── B — a machine that simply stopped reporting ─────────────────────────────────────────────────
//
// WRONG OUTCOME GUARDED: silence mistaken for a deliberate switch-off — THE ALARM THAT MATTERS,
// suppressed. This is the one that must never regress.
func TestStaleness_B_GenuineSilenceStillAlarms(t *testing.T) {
st, path := seedStalenessCustomer(t, "ok", 0)
sc, events := newChecker(t, st)
sc.Check() // first observation: healthy, no event
// The same customer now goes quiet for real.
saveReportAged(t, st, path, "ok", 3*time.Hour)
sc.Check()
if got := sc.GetState("c1"); got != "down" {
t.Fatalf("a genuinely silent machine is %q, want \"down\" — a real alarm was swallowed", got)
}
if len(*events) == 0 {
t.Fatal("a genuinely silent machine emitted NO event — the suppression is over-broad")
}
}
// ── C — a disabled machine that is re-enabled and reports promptly ──────────────────────────────
//
// WRONG OUTCOME GUARDED: a recovery e-mail for an outage that never happened.
func TestStaleness_C_ReEnabledReportsCleanlyWithNoRecoveryEvent(t *testing.T) {
st, path := seedStalenessCustomer(t, "disabled", 72*time.Hour)
sc, events := newChecker(t, st)
sc.Check()
if sc.GetState("c1") != StateDisabled {
t.Fatalf("setup: expected the disabled state, got %q", sc.GetState("c1"))
}
// Reporting is switched back on and the box reports immediately.
saveReportAged(t, st, path, "ok", 0)
sc.Check()
if len(*events) != 0 {
t.Fatalf("re-enabling emitted %v — there was no outage to recover from", *events)
}
if got := sc.GetState("c1"); got != "ok" {
t.Errorf("state after re-enable = %q, want \"ok\"", got)
}
}
// ── D — a disabled machine that is re-enabled and then goes genuinely quiet ─────────────────────
//
// WRONG OUTCOME GUARDED: permanently silenced because it was once disabled. Also pins THE CLOCK
// JUDGEMENT: timing runs from the report the box sent on re-enabling, not from the last report
// before the switch-off — otherwise coming back would fire an instant false alarm about a quiet
// period we asked for.
func TestStaleness_D_ReEnabledThenQuietAlarmsFromReEnablement(t *testing.T) {
st, path := seedStalenessCustomer(t, "disabled", 72*time.Hour)
sc, events := newChecker(t, st)
sc.Check()
// Re-enabled, reports promptly — the clean first observation.
saveReportAged(t, st, path, "ok", 0)
sc.Check()
if len(*events) != 0 {
t.Fatalf("the re-enable itself emitted %v", *events)
}
// …and then goes quiet for real. Timed from THIS report, it is down.
saveReportAged(t, st, path, "ok", 3*time.Hour)
sc.Check()
if got := sc.GetState("c1"); got != "down" {
t.Fatalf("state = %q, want \"down\" — a once-disabled machine was silenced for ever", got)
}
if len(*events) == 0 {
t.Fatal("no event for a machine that genuinely died after being re-enabled")
}
}
// The deadline check is a SECOND DOOR onto the same false alarm: a disabled box is not "down", so
// without its own skip it would keep e-mailing expected_backup_missed every morning. R-195's shape,
// which is why both doors are closed together.
func TestStaleness_DisabledIsAlsoSkippedByTheDeadlineCheck(t *testing.T) {
st, _ := seedStalenessCustomer(t, "disabled", 72*time.Hour)
if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"}); err != nil {
t.Fatalf("UpsertHost: %v", err)
}
sc, _ := newChecker(t, st)
sc.Check()
var events []string
CheckBackupDeadlines(st, sc, func(cid, et, sev, msg, det, src string) {
events = append(events, et)
}, log.New(io.Discard, "", 0))
for _, e := range events {
if e == "expected_backup_missed" || e == "expected_dbdump_missed" {
t.Fatalf("the deadline check alarmed on a deliberately-disabled machine: %v", events)
}
}
}
+20 -2
View File
@@ -316,6 +316,24 @@ Felhom.eu`, code, dashboardURL)
// link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the // link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the
// mail (the customer already holds it), and the console pairing code is read off the box screen. The // mail (the customer already holds it), and the console pairing code is read off the box screen. The
// copy tells the customer they will need both factors on the page. Adult tone, no emoji. // copy tells the customer they will need both factors on the page. Adult tone, no emoji.
//
// R-323, THE THIRD NAME (2026-08-13). This mail used to call the five-word phrase „visszaállító
// jelszó" — one word away from „Visszaállító kód", which had just been retired for colliding with
// „Helyreállítási kód". It is „Tulajdonosi jelmondat" now. Two reasons, and the second is the one
// that rules out the obvious alternatives:
//
// 1. THE OLD NAME WAS FALSE. The phrase restores nothing. It proves the account owns the box being
// linked — so the name says that.
// 2. IT MUST NOT COLLIDE WITH THE OTHER FACTOR ON THE SAME PAGE. Item 1 below is the „Párosító
// kód". Naming this one after the same act („Összekötési jelszó") would leave the two factors a
// customer types in one sitting distinguished only by kód-versus-jelszó — which is EXACTLY the
// „Visszaállító kód" / „Visszaállító jelszó" shape being removed. „Fiókjelszó" is worse still:
// there IS an account password (the dashboard login), so it would collide with a different real
// secret. „Tulajdonosi jelmondat" is distinct from all three on BOTH axes — the stem
// (Tulajdonosi vs Beállító / Helyreállítási / Párosító) and the noun (jelmondat vs kód / jelszó).
//
// Naming only: no acceptance logic moved, and the same phrase is still accepted. Pinned by
// TestSelfBind_ThirdSecretNaming and TestSelfBindPassphrase_StillAcceptedAfterTheRename.
func FormatSelfBindEmail(customerID, link string) (string, string) { func FormatSelfBindEmail(customerID, link string) (string, string) {
subject := "[Felhom] Kösd össze a Felhom dobozodat" subject := "[Felhom] Kösd össze a Felhom dobozodat"
body := fmt.Sprintf(`Kedves Ügyfél! body := fmt.Sprintf(`Kedves Ügyfél!
@@ -328,8 +346,8 @@ tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre:
A hivatkozás megnyitása után két adatot kell megadnod: A hivatkozás megnyitása után két adatot kell megadnod:
1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható. 1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható.
2. A visszaállító jelszavadat (az 5 szóból álló kifejezést), amelyet a 2. A tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a
beállításkor kaptál. beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.
A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után
zárolódik ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal. zárolódik ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.
+4 -4
View File
@@ -252,15 +252,15 @@ const bindPageHTML = `<!DOCTYPE html>
<div class="card"> <div class="card">
<h1>Felhom <span>doboz</span> összekötése</h1> <h1>Felhom <span>doboz</span> összekötése</h1>
{{if eq .State "form"}} {{if eq .State "form"}}
<p class="lead">Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a visszaállító jelszavadat.</p> <p class="lead">Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a tulajdonosi jelmondatodat.</p>
{{if .Failed}}<div class="banner">A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a jelszót, majd próbáld újra.</div>{{end}} {{if .Failed}}<div class="banner">A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a tulajdonosi jelmondatot, majd próbáld újra.</div>{{end}}
<form method="POST" action="/bind/{{.Token}}"> <form method="POST" action="/bind/{{.Token}}">
<label for="pairing_code">Párosító kód</label> <label for="pairing_code">Párosító kód</label>
<input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="ABC-234"> <input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="ABC-234">
<p class="hint">A doboz monitorán jelenik meg, a telepítés után.</p> <p class="hint">A doboz monitorán jelenik meg, a telepítés után.</p>
<label for="passphrase">Visszaállító jelszó</label> <label for="passphrase">Tulajdonosi jelmondat</label>
<input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="öt szó, kötőjellel vagy szóközzel"> <input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="öt szó, kötőjellel vagy szóközzel">
<p class="hint">Az öt szóból álló kifejezés, amelyet a beállításkor kaptál.</p> <p class="hint">Az öt szóból álló kifejezés, amelyet a beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.</p>
<button type="submit">Összekötés</button> <button type="submit">Összekötés</button>
</form> </form>
<p class="note">Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.</p> <p class="note">Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
+132
View File
@@ -0,0 +1,132 @@
package web
// R-323 — THE THIRD NAME. One secret, one name, and none of them a near-homograph of another.
//
// Three secrets a customer can hold, and they must be tellable apart by a hurried reader:
//
// three words takes control of the dashboard „Beállító kód"
// ten words opens the sealed off-site backups „Helyreállítási kód"
// five words proves the account owns the box being bound „Tulajdonosi jelmondat" ← this one
//
// The five-word phrase used to be „Visszaállító jelszó" — one word away from „Visszaállító kód",
// which R-295 had just retired for colliding with „Helyreállítási kód". Found while shipping that
// rename and deliberately NOT swept in with it; ruled on separately by the operator 2026-08-13.
//
// WHY NOT THE OBVIOUS CANDIDATES, since a test is where the reasoning survives:
//
// „Összekötési jelszó" — the OTHER factor on this very page is the „Párosító kód". Naming this one
// after the same act would leave the two factors a customer types in one sitting separated
// only by kód-versus-jelszó, which is structurally the „Visszaállító kód"/„Visszaállító
// jelszó" trap being removed.
// „Fiókjelszó" — there IS an account password (the dashboard login). This would collide
// with a DIFFERENT real secret, i.e. trade one homograph for a worse one.
//
// „Tulajdonosi jelmondat" is distinct on BOTH axes — stem (Tulajdonosi vs Beállító / Helyreállítási
// / Párosító) and noun (jelmondat vs kód / jelszó) — and it says what the phrase actually does.
import (
"net/http"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// retiredThirdName is the name this change removes. It is a SUBSTRING check on the stem so that a
// possessive or accusative form („visszaállító jelszavadat") cannot slip past a check written
// against the nominative — the R-299 lesson, where a guard matched one inflection of a Hungarian
// verb and the plural walked straight by.
const retiredThirdName = "isszaállító jelsz"
// The binding page names the secret „Tulajdonosi jelmondat" and nowhere carries the retired name.
// Driven through the real handler, so a template that never renders is visible here.
func TestSelfBind_ThirdSecretNaming(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
body := bindGET(t, s, token).Body.String()
if !strings.Contains(body, "Tulajdonosi jelmondat") {
t.Errorf("the field label must name the secret „Tulajdonosi jelmondat”:\n%s", body)
}
if strings.Contains(body, retiredThirdName) {
t.Error("the retired name „Visszaállító jelszó” is back on the binding page")
}
// The two factors on this page must not be near-homographs of each other. The pairing code keeps
// its name; what matters is that the SECOND factor no longer differs from it by one noun.
if !strings.Contains(body, "Párosító kód") {
t.Error("the pairing code lost its name — the two factors must both be named")
}
// Neither of the OTHER two secrets may be named on this page: a customer who reads „kód" here
// twice is the failure this whole arc exists to prevent.
for _, other := range []string{"Beállító kód", "Helyreállítási kód"} {
if strings.Contains(body, other) {
t.Errorf("the binding page names a different secret (%q) — that is the collision", other)
}
}
}
// The failure banner must name the secret the same way the label does. A page that asks for a
// „Tulajdonosi jelmondat" and then complains about „a jelszó" has two names for one secret again,
// which is the defect in miniature.
func TestSelfBind_FailureBannerUsesTheSameName(t *testing.T) {
s, st := newTestServer(t)
selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
rr := bindPOST(t, s, token, testCodeFmt, "wrong wrong wrong wrong wrong")
body := rr.Body.String()
if !strings.Contains(body, "tulajdonosi jelmondat") {
t.Errorf("the failure banner does not name the secret the way the label does:\n%s", body)
}
if strings.Contains(body, retiredThirdName) {
t.Error("the retired name is on the failure banner")
}
}
// The e-mail that carries the link names it identically. The mail is read BEFORE the page, so a
// mismatch here is the customer's first impression of two different secrets.
func TestSelfBindEmail_UsesTheSameName(t *testing.T) {
subject, body := notify.FormatSelfBindEmail("acme", "https://hub.example/bind/tok")
if !strings.Contains(body, "tulajdonosi jelmondatodat") {
t.Errorf("the self-bind mail does not name the secret „Tulajdonosi jelmondat”:\n%s", body)
}
if strings.Contains(subject+body, retiredThirdName) {
t.Error("the retired name is still in the self-bind mail")
}
// It must still say what the thing IS — a rename that leaves the customer unable to recognise
// what they are holding has fixed a collision and broken the delivery.
if !strings.Contains(body, "5 szóból álló kifejezést") {
t.Error("the mail no longer says the phrase is five words")
}
}
// THE ACCEPTANCE PIN — the whole point of calling this naming rather than function.
//
// The SAME phrase, typed the same messy way a human types it (odd spacing, mixed case, mixed
// separators), still binds the appliance after the rename. If this ever goes red, the rename stopped
// being a rename.
func TestSelfBindPassphrase_StillAcceptedAfterTheRename(t *testing.T) {
s, st := newTestServer(t)
id := selfBindSetup(t, st, "acme", testCode)
token := mintLink(t, st, "acme", selfBindTTL)
rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") {
t.Fatalf("the same passphrase stopped being accepted: code=%d body=%q", rr.Code, rr.Body.String())
}
if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound {
t.Fatalf("the appliance did not bind: %+v", a)
}
// The form field NAME is deliberately still `passphrase`: renaming customer copy must not touch
// the wire. A changed field name would be an acceptance change wearing a rename's clothes.
body := bindGET(t, s, mintLink(t, st, "acme", selfBindTTL)).Body.String()
if !strings.Contains(body, `name="passphrase"`) {
t.Error("the form field name changed — that is not a rename, that is a wire change")
}
}
+64
View File
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""customer_copy_vocab — THE ONE list of customer-copy vocabulary, shared by both repos.
WHY THIS FILE EXISTS AT ALL. Two guards need the same words: the controller's
`retrieval_promise_gate.py` (which scans the box's own screens) and this repo's `hub_copy_gate.py`
(which scans the hub's e-mails and binding pages). Two copies of a word list is not a theoretical
risk here it is the R-299 defect exactly: a guard asserted one inflection of a Hungarian verb and
the plural, one paragraph above, walked straight past it. A second list drifts the same way.
So the list lives HERE, in `felhom.eu/scripts/`, which is already the established home for gates both
repos consume without copying (`reuse_refs_check.py`, `instructions_gate.py` the controller's own
`controller_gates.py` runs them from this directory and FAILS when the sibling clone is absent, which
is the fail-CLOSED shape a shared gate needs).
ADOPTION IS HALF DONE, AND SAYING SO IS THE POINT. `hub_copy_gate.py` imports this module.
`felhom-controller/controller/scripts/retrieval_promise_gate.py` still carries its own `STEMS`
literal, because the session that wrote this module was under a hard end-state requirement to leave
felhom-controller untouched (its target box was being re-deployed the same evening). Until that gate
imports this file, `hub_copy_gate.py` DRIFT-CHECKS it: it reads the controller gate's `STEMS` and
fails if the two lists disagree. That gives single-source semantics tonight without a cross-repo
edit but it is a scaffold, not the destination. Register row: R-325.
"""
# ── The retired names ────────────────────────────────────────────────────────────────────────────
#
# A name here is BANNED from customer-facing copy outright. Unlike the stems below, these are not
# claims that might be true in context — they are names for a secret that a different secret now
# owns, and a customer meeting one is being told two things have the same name.
#
# Each entry is a SUBSTRING, deliberately shorn of its leading capital and its ending, so a
# possessive or accusative form cannot slip past a check written against the nominative:
# „visszaállító jelszavadat" must convict as surely as „Visszaállító jelszó". That is the R-299
# lesson applied to nouns instead of verbs.
RETIRED_NAMES = {
"isszaállító kód": (
"R-295 (2026-08-10 box side, 2026-08-13 hub side). The THREE-word code that takes control of "
"the dashboard is „Beállító kód”. „Visszaállító kód” was a near-homograph of the escrow "
"„Helyreállítási kód” and the collision cost a real code."),
"isszaállító jelsz": (
"R-323 (2026-08-13). The FIVE-word phrase that proves the account owns the box being bound is "
"„Tulajdonosi jelmondat”. „Visszaállító jelszó” was one word from the name retired above, and "
"it was false besides — the phrase restores nothing."),
}
# ── The retrieval-promise stems ──────────────────────────────────────────────────────────────────
#
# These are NOT banned. Each carries the claim "your old backups can be got back", which is sometimes
# TRUE and must then be sayable. An occurrence must be REGISTERED with a reason in the consuming
# gate's allowlist; an unregistered one fails.
#
# The docstring of the controller gate is the long version and is worth reading before adding a stem:
# five instances of this claim surfaced ONE AT A TIME, and the obvious guard (ban the sentence) was
# tried twice and failed twice.
RETRIEVAL_STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"]
# ── The three secrets, so a reviewer can check a name against the THING ───────────────────────────
#
# Not consumed by any check — it is here because every argument about these names has had to be
# reconstructed from register rows, and the table is the argument.
THE_THREE_SECRETS = [
("three words", "takes control of the dashboard", "Beállító kód"),
("ten words", "opens the sealed off-site backups", "Helyreállítási kód"),
("five words", "proves the account owns the box being bound", "Tulajdonosi jelmondat"),
]
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""hub_copy_gate — the hub's customer-facing copy comes under a guard (R-324).
THE SCOPE GAP THIS CLOSES. `retrieval_promise_gate.py` lives in `felhom-controller` and scans that
repo only. It was extended to Go string literals on 2026-08-12 (R-311) on the express ground that the
recovery screen's messages are Go strings in a handler and "none of it had ever been scanned" — and
the identical sentence was true one repo over the whole time. **The hub composes every customer
e-mail and renders the binding pages: the first sentences a customer ever reads, before they have
seen any box screen.** Nothing looked at them.
A hand scan on 2026-08-13 returned zero retrieval claims, so this was a SCOPE gap rather than a live
defect which is the moment to close it, before someone writes the first hub-side promise assuming
the guard has them covered.
TWO CHECKS, and the difference is deliberate:
1. RETIRED NAMES banned outright, scanned across the WHOLE hub, not just declared surfaces. A
name for a secret that a different secret now owns is never correct anywhere, so there is no
allowlist and no "unless". Comments are stripped: prose explaining a rename is not the rename
coming back, and the register rows that record these decisions quote the retired names by
necessity.
2. RETRIEVAL STEMS registered, not banned, in the DECLARED customer-facing surfaces. The claim
they carry is sometimes true and must stay sayable; what must not happen is a new one appearing
where nobody was looking. Same contract as the controller gate: an occurrence is allowlisted
WITH A REASON, and a stale allowlist entry is also a failure.
BOTH lists come from `customer_copy_vocab.py` see that file for why they are not literals here.
DRIFT CHECK, and it is load-bearing. The controller gate still owns its own `STEMS` literal (the
session that wrote this could not touch felhom-controller). This gate reads that literal and FAILS if
it disagrees with the shared list, so the two cannot silently diverge in the meantime. An ABSENT
sibling clone is INCONCLUSIVE (exit 2), never a pass the G-1 lesson: a gate that skips when its
sibling is missing runs in neither home.
Run: python3 scripts/hub_copy_gate.py (from the felhom.eu repo root)
python3 scripts/hub_copy_gate.py --selftest (plant convict remove pass)
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from customer_copy_vocab import RETIRED_NAMES, RETRIEVAL_STEMS # noqa: E402
_HERE = os.path.dirname(os.path.abspath(__file__))
_REPO = os.path.dirname(_HERE)
_HUB = os.path.join(_REPO, "hub")
# The hub files that carry HUNGARIAN CUSTOMER-FACING text — the surfaces where a retrieval claim
# could be made to a customer. Enumerated by scanning every non-test .go/.html under hub/internal for
# accented Hungarian outside comments, then keeping the ones a CUSTOMER (not the operator) reads.
#
# A declared file that is missing is a FAILURE, never a skip: the controller gate learned that when a
# renamed handler would have silently emptied its own scope.
CUSTOMER_SURFACES = [
os.path.join("hub", "internal", "notify", "templates.go"), # every customer e-mail + event copy
os.path.join("hub", "internal", "web", "selfbind.go"), # the binding pages
os.path.join("hub", "internal", "api", "handler.go"), # customer-visible event messages
os.path.join("hub", "internal", "notify", "dispatcher.go"), # the customer channel's own wording
]
# (file basename, substring that identifies the occurrence) -> why it is allowed.
# Empty today, and that is a measurement rather than an oversight: the hub makes no retrieval promise.
ALLOWLIST = {}
GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
TPL_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
def strip_comments(text, path):
"""Remove what never reaches a customer.
Go `//` and `/* */`, Go-template `{{/* */}}`, and unlike the controller gate HTML `<!-- -->`
too. The controller gate deliberately SCANS HTML comments because its templates ship to a browser
where View Source is one keystroke. The hub's Hungarian lives in Go string literals that happen
to contain HTML, and its operator templates are not customer copy, so an HTML comment here is a
note to the next maintainer. Stated because it is a real difference between the two gates.
"""
text = TPL_COMMENT.sub("", text)
text = HTML_COMMENT.sub("", text)
if path.endswith(".go"):
text = GO_COMMENT.sub("", text)
return text
def _iter_hub_sources():
for root, _dirs, files in os.walk(os.path.join(_REPO, "hub", "internal")):
for f in sorted(files):
if f.endswith("_test.go") or not (f.endswith(".go") or f.endswith(".html")):
continue
yield os.path.join(root, f)
def scan_retired(extra_text=None):
"""Every retired name, everywhere in the hub. Returns a list of convictions."""
convictions = []
sources = [(p, open(p, encoding="utf-8").read()) for p in _iter_hub_sources()]
if extra_text is not None:
# The synthetic path MUST end in .go: strip_comments keys comment-stripping off the
# extension, so a selftest source named anything else would be scanned WITH its comments —
# which is how the first run of this selftest convicted its own step-3 control. The bug was
# in the instrument, and the control is what found it.
sources.append((os.path.join(_REPO, "hub", "internal", "selftest_planted.go"), extra_text))
for path, raw in sources:
text = strip_comments(raw, path)
for name in RETIRED_NAMES:
for m in re.finditer(re.escape(name), text):
line = text[: m.start()].count("\n") + 1
ctx = " ".join(text[max(0, m.start() - 70): m.end() + 70].split())[:150]
convictions.append((os.path.relpath(path, _REPO), line, name, ctx))
return convictions
def scan_stems():
"""Retrieval stems in the declared customer surfaces. Returns (convictions, seen_keys)."""
convictions, seen = [], set()
for rel in CUSTOMER_SURFACES:
path = os.path.join(_REPO, rel)
if not os.path.exists(path):
raise SystemExit("hub-copy gate: declared customer surface is MISSING: %s" % rel)
name = os.path.basename(path)
text = strip_comments(open(path, encoding="utf-8").read(), path)
for stem in RETRIEVAL_STEMS:
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
hit = None
for k in (k for k in ALLOWLIST if k[0] == name):
for om in re.finditer(re.escape(k[1]), text):
if om.start() <= m.start() and m.end() <= om.end():
hit = k
break
if hit:
break
if hit:
seen.add(hit)
else:
line = text[: m.start()].count("\n") + 1
ctx = " ".join(text[max(0, m.start() - 90): m.end() + 90].split())[:170]
convictions.append((name, line, m.group(0), ctx))
return convictions, seen
def check_drift():
"""The controller gate's STEMS must equal the shared list. Returns (status, message).
status: "ok" | "drift" | "inconclusive"
"""
sibling = os.path.join(os.path.dirname(_REPO), "felhom-controller",
"controller", "scripts", "retrieval_promise_gate.py")
if not os.path.exists(sibling):
return "inconclusive", ("the felhom-controller clone is absent, so the shared stem list "
"could not be compared against the gate that also uses it (%s)" % sibling)
text = open(sibling, encoding="utf-8").read()
m = re.search(r"^STEMS\s*=\s*\[(.*?)\]", text, re.M | re.S)
if not m:
return "drift", "could not find a STEMS list in the controller gate — its shape changed"
theirs = re.findall(r"[\"']([^\"']+)[\"']", m.group(1))
if theirs != RETRIEVAL_STEMS:
return "drift", ("the controller gate's STEMS have diverged from customer_copy_vocab.py\n"
" controller : %r\n shared : %r" % (theirs, RETRIEVAL_STEMS))
return "ok", "controller gate's STEMS match the shared list (%d stem(s))" % len(theirs)
def selftest():
"""Plant → convict → remove → pass. A guard never seen catching anything proves nothing."""
print("hub-copy gate SELFTEST")
baseline = scan_retired()
if baseline:
print(" FAIL: the tree is not clean before planting — %d conviction(s)" % len(baseline))
for c in baseline:
print(" %s:%d [%s]" % (c[0], c[1], c[2]))
return 1
print(" 1. clean tree : 0 conviction(s) OK")
planted = 'body := "Add meg a visszaállító jelszavadat a folytatáshoz."\n'
convicted = scan_retired(extra_text=planted)
if len(convicted) != 1 or convicted[0][2] != "isszaállító jelsz":
print(" 2. planted retired name : NOT CONVICTED — the guard is inert")
print(" got: %r" % (convicted,))
return 1
print(" 2. planted „visszaállító jelszavadat”: CONVICTED (%s) OK" % convicted[0][2])
commented = '// the old name was „visszaállító jelszó" and is retired\n'
if scan_retired(extra_text=commented):
print(" 3. same phrase inside a COMMENT : convicted — comments must not be scanned")
return 1
print(" 3. same phrase inside a comment : not convicted OK")
if scan_retired():
print(" 4. planting removed : still convicting — the scan is not deterministic")
return 1
print(" 4. planting removed : 0 conviction(s) OK")
print("hub-copy gate selftest OK — the guard has been watched catching, ignoring and releasing")
return 0
def main():
if "--selftest" in sys.argv:
return selftest()
retired = scan_retired()
stems, seen = scan_stems()
stale = [k for k in ALLOWLIST if k not in seen]
drift_status, drift_msg = check_drift()
for path, line, name, ctx in retired:
print(" %s:%d RETIRED NAME in customer copy (%s):\n%s" % (path, line, name, ctx))
print(" reason it is retired: %s" % RETIRED_NAMES[name])
for name, line, word, ctx in stems:
print(" %s:%d unregistered retrieval claim (%s):\n%s" % (name, line, word, ctx))
for k in stale:
print(" STALE ALLOWLIST ENTRY (no longer present): %s :: %r" % (k[0], k[1]))
n_files = len(list(_iter_hub_sources()))
if retired or stems or stale:
print("\nHUB-COPY GATE FAILED: %d retired name(s), %d unregistered claim(s), %d stale."
% (len(retired), len(stems), len(stale)))
print("A retired name is never correct in customer copy — use the current name. If a new")
print("retrieval claim is genuinely TRUE where it renders, make it conditional on what the")
print("hub can actually see, then register it in ALLOWLIST with the reason.")
return 1
if drift_status == "drift":
print(" DRIFT: %s" % drift_msg)
print("\nHUB-COPY GATE FAILED: the shared vocabulary is no longer shared.")
return 1
print("hub-copy gate OK — %d hub file(s) scanned for %d retired name(s); %d customer surface(s) "
"scanned for %d retrieval stem(s), %d registered claim(s), none unregistered"
% (n_files, len(RETIRED_NAMES), len(CUSTOMER_SURFACES), len(RETRIEVAL_STEMS), len(ALLOWLIST)))
if drift_status == "inconclusive":
print(" ⚠ INCONCLUSIVE (exit 2): %s" % drift_msg)
return 2
print(" drift: %s" % drift_msg)
print(" (BLIND SPOT: this checks the WORDS in the four declared customer surfaces. It cannot")
print(" tell whether a true-looking sentence is wired to a predicate that is actually true —")
print(" that is what render tests are for. And it does not read the operator's screens.)")
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -69,6 +69,9 @@ GATES = [
("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True), ("instructions", os.path.join(SCRIPTS, "instructions_gate.py"), [ROOT], True),
("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True), ("golden-currency", os.path.join(SCRIPTS, "golden_currency_gate.py"), [], True),
("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True), ("wire-contract", os.path.join(SCRIPTS, "wire_contract_gate.py"), [], True),
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True),
] ]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""unproven.py — "which claims are not proven?", as a question a machine can answer (R-326).
WHY THIS EXISTS. The operator asked on 2026-08-04 to see what is built and what is still unproven.
The picture was built (`where-felhom-stands.yaml` `.html`) and it is good but the QUESTION could
only be answered by a person reading it. On 2026-08-13 a session was asked for "the nine grey claims",
could not determine which nine, and declined to guess. It was right to decline, and the refusal is
the finding this closes.
WHERE "NINE" CAME FROM, since a wrong number that matches nothing is worse than no number. It is
real, and it answers a DIFFERENT question: nine claims carry `verdict: downgraded` the count the
2026-08-09 verification pass LOWERED. The count of claims that are not walked is 32. Both are true;
only one of them is "what is unproven".
This reads the dataset only. It makes no judgement, opens no evidence and contacts no machine a
claim's status is the capability map's business (the map changes first; the dataset follows it), and
`check_stands.py` is the gate that keeps the citations honest. This just answers the question.
Run: python3 scripts/unproven.py # every claim that is not walked
python3 scripts/unproven.py --summary # the counts only
"""
import io
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA = os.path.join(ROOT, "documentation", "architecture", "where-felhom-stands.yaml")
# The status that means "done end to end on real hardware, with evidence on file". Everything else is
# a degree of not-that, which is the whole point of the question.
PROVEN = "walked"
# Widest first, so the output reads as a ladder down from nearly-there to not-started.
ORDER = ["partial", "built", "missing"]
def field(entry, name):
m = re.search(r"^\s*%s:\s*\"?(.*?)\"?\s*$" % name, entry, re.M)
return m.group(1) if m else ""
def load():
if not os.path.exists(DATA):
sys.exit("unproven: the dataset is missing: %s" % DATA)
text = io.open(DATA, encoding="utf-8").read()
parts = re.split(r"\n - id: ", text)
header, entries = parts[0], parts[1:]
if not entries:
sys.exit("unproven: no claims parsed from %s — the file's shape changed" % DATA)
claims = []
for e in entries:
claims.append({
"id": e.split("\n", 1)[0].strip(),
"status": field(e, "status") or "(none)",
"title": field(e, "title"),
"band": field(e, "band"),
"verdict": field(e, "verdict"),
"evidence": "evidence:" in e,
})
return field(header, "verified_on"), claims
def main():
verified_on, claims = load()
not_walked = [c for c in claims if c["status"] != PROVEN]
counts = {}
for c in claims:
counts[c["status"]] = counts.get(c["status"], 0) + 1
print("where felhom stands — %d claims, verified_on %s" % (len(claims), verified_on))
print(" %-8s %d" % (PROVEN, counts.get(PROVEN, 0)))
for s in ORDER:
n = counts.get(s, 0)
with_ev = sum(1 for c in claims if c["status"] == s and c["evidence"])
print(" %-8s %d (%d cite evidence, %d prose only)" % (s, n, with_ev, n - with_ev))
for s in sorted(k for k in counts if k not in ORDER + [PROVEN]):
print(" %-8s %d ⚠ status not known to this script" % (s, counts[s]))
print(" NOT WALKED: %d of %d" % (len(not_walked), len(claims)))
if "--summary" in sys.argv:
return 0
print()
for s in ORDER + sorted(k for k in counts if k not in ORDER + [PROVEN]):
rows = [c for c in not_walked if c["status"] == s]
if not rows:
continue
print("%s (%d)" % (s.upper(), len(rows)))
for c in sorted(rows, key=lambda c: c["id"]):
ev = "evidence" if c["evidence"] else "PROSE ONLY"
vd = (" [%s]" % c["verdict"]) if c["verdict"] else ""
print(" %-40s %-10s %s%s" % (c["id"], c["band"], ev, vd))
print(" %s" % c["title"][:110])
print()
return 0
if __name__ == "__main__":
sys.exit(main())