Verify the standing picture against source: 12 downgrades, and the decay ran both ways
gates / gates (push) Successful in 21s

55 claims verified. Twelve moved, all downwards: walked 32 -> 20, built 5 -> 17.
Register ceiling R-284 -> R-290.

THE RULE DID NOT FIRE THE WAY IT WAS EXPECTED TO. Not one downgrade came from
code moving under an old proof. All twelve came from step 1 of the same rule --
the cited evidence does not exist. Measured: of the 28 capability-map rows
behind the page's claims, 8 carry a tests/ or audits/ path and 20 carry prose
only. The green dots were drawn from rows that cite an argument, not a walk
(R-290). The map, not the dataset, is what needs fixing -- it still says
PROVEN-LIVE for all twelve.

And once it ran backwards: fault.operator-email looked contradicted by R-182,
but live source shows the backup_run_failures digest allowlisted, operator-only
and templated, with recovery_unit_capture_failed now record-only. The claim is
right and the REGISTER ROW is stale (R-289). The session went looking for stale
proofs and found a stale defect.

R-281 WITHDRAWN -- wrong in both directions, settled by the operator's mailbox.
The tripwire DID fire (escrow_blob_served 10:19:41Z = 12:19 CEST) and false
error-severity alarms fired too, for deliberate attended work (R-285). The
measurement's cause is ESTABLISHED: the P7 query copied hub.db without hub.db-wal,
and the signature is exact -- it reported "2 events all day, newest 00:30:07",
and the rows at or before 00:30:07 number exactly 2. Timezone and wrong-key were
tested and refuted. The control had been drawn from the same stale snapshot as
the measurement, which is why it agreed (R-286).

Part 4: NO WORKFLOW CHANGED, deliberately. The gate is not ref-sensitive -- it
enumerates from the Gitea tags API, and both previous tag pushes passed. The red
is TRUE: run 267 saw v0.120.0 downloadable, run 284 on the same commit saw 404.
Who deleted the package is NOT established and is not guessed (R-287).

The page is now generated from where-felhom-stands.yaml by scripts/render_stands.py:
static, zero script tags, every moved status carrying a visible "changed, was X"
chip. The React bundle -- whose content was gzip+base64 inside a JS module map --
is kept as a dated snapshot. scripts/check_stands.py gates the data and convicted
51 problems in my own first draft before the staged positive control ever ran.
This commit is contained in:
2026-08-09 18:40:49 +02:00
parent a199c492f4
commit 6088afcbed
8 changed files with 1274 additions and 148 deletions
+154 -147
View File
@@ -1,184 +1,191 @@
# REPORT — the seed that never ran twice, and three pictures that were not true (2026-08-08)
# REPORT — making the picture true (2026-08-09, unattended)
Four defects of one family, each with a source-verified mechanism, tests and red-proofs.
Agent **v0.128.0** · controller **v0.210.0** · `gates.yml` (workflow only). **The hub was not touched,
not bumped and not deployed.**
Read-only against all live infrastructure. **Both demo machines were powered off and in transit; no
box was probed, woken or waited on.** Claims that only a running box could settle are marked
`needs-hardware`, which is a verdict, not a gap.
## 1. Part 1's live sequence — RUN, OPERATOR-PRESENT, PASSED
---
On `demo-felhom` guest 9201, agent 0.128.0. Backup taken first and verified byte-identical
(`sha256 eff18437…`), pbsdr marker sha recorded and **unchanged at every step**
(`fd4c97b5…`).
## 1. The verdict table
**Step 3 — the preflight with the key removed (today's defect, live):**
**55 claims. Statuses moved on 12 of them — all downwards.** `walked 32 → 20`, `built 5 → 17`;
`partial 14` and `missing 4` unchanged. Full per-claim detail with sources is in
`documentation/architecture/where-felhom-stands.yaml`.
```
{"id": "pbs_storage_id", "ok": false, "detail": "escrow.pbs_storage_id not configured"}
```
**Step 4 — one 60 s tick later, same daemon:**
```
{"id": "pbs_storage_id", "ok": true, "detail": "felhom-pbs"}
```
**No restart:** agent MainPID **1993397 before and 1993397 after** — the same process. The wizard
would have refused at 18:04:16Z and would pass at 18:05:44Z, with nobody touching the box.
**Step 5 — nothing else moved:** 45 keys in the backup, 45 now, **added none / removed none /
changed none**. The one key is back with its original value.
**Positive control:** the preflight was exercised BEFORE the change and reported `ok: true` on the
same row, so a green afterwards is a measurement and not an artefact of the probe.
## 2. The writer of `agent.json` — ESTABLISHED
`step_agent_config`, **`felhom.eu/scripts/felhom-host-install.sh:2396`**; the Python render at
**`:2449`**; the `O_TRUNC` write at **`:2579`**. `PRESERVE_FROM` defaults empty (**`:256`**) and is set
only by an explicit `--preserve-from` (**`:1246`**). **The render never writes an `escrow` section at
all** — grep over the whole heredoc: zero hits. The pbsdr marker is host-side
(`<agent-state>/pbsdr/marker.json`) and survives. **R-221's attribution was correct.**
A rebuild is only the case that was *measured*; the same hole opens for a hand-edited or restored
config, which is the honest reason the fix is at the seam rather than in the installer.
## 3. Red-proofs — 6 of 6, each with the mutation asserted applied
| # | mutation | assertion it applied | outcome |
| claim | was | now | why |
|---|---|---|---|
| 1 | **Part 1 / Scenario A: remove the new seed call** | marker `MUTATED: the R-221 re-assert removed` present | **RED** — and **yes, it failed against today's tree**, with the intended message ✔ |
| 2 | Part 1: remove the early return as well | marker `MUTATED: early return deleted` present | **RED** — the zero-Proxmox-calls assertion is load-bearing, not decorative ✔ |
| 3 | Part 3 / F: revert to `status.LastDBDump.Success` | marker present | **RED** — app X's false green returns ✔ |
| 4 | Part 3 / G: map "no result" to `ok` | marker present | **RED** — green-on-presence returns ✔ |
| 5 | Part 2 / D: ignore `DiskKnown` in the template | marker present | **RED** — „0.0 GB / 0.0 GB (0%)" in the nominal colour returns ✔ |
| 6 | Part 2 / E: force the flag false | marker present | **RED** — a healthy box is shown losing its numbers ✔ |
| `install.installer-by-tag` | walked | **built** | gate 6 asserts the manifest names an installer tag; no walk of a rollback on file |
| `use.lifecycle` | walked | **built** | no walk document cited |
| `drives.enrol` | walked | **built** | the 08-09 walk exercised RE-attach (which failed, R-280); first enrolment of a NEW drive has no walk |
| `drives.migrate` | walked | **built** | no walk document cited |
| `backup.tier1` | walked | **built** | no walk document cited |
| `backup.whole-machine` | walked | **built** | no walk document cited |
| `backup.restore-proof` | walked | **built** | no walk cited, **and the last recorded restore-test on demo-hp FAILED** (2026-08-05) |
| `fault.selfheal` | walked | **built** | no walk document cited |
| `fault.operator-email` | walked | **built** | source-verified as correct, but no run observed delivering |
| `fail.drive-filling` | walked | **built** | no walk document cited |
| `fail.lost-recovery-code` | walked | **built** | by-design refusal; no walk document cited |
| `fail.hub-down` | walked | **built** | no walk document cited |
All six restored and re-verified green. **Answer to the question asked directly: the Part 1 test DID
fail against today's tree.**
**Upgraded: 1.** `install.byo` — the page said *"the first real one has not happened"*. A real
`--mode byo` install completed on demo-hp on 2026-08-09 (`Day-0 provision SUCCESS`, 3 m 49 s). Still
not a customer's own hardware, so not *walked*, but the sentence was false.
## 4. The Hungarian strings as shipped
**`needs-hardware`: 4** — `use.lan-fallback`, `backup.restore-proof`, `fail.disk-failing`,
`fail.internet-down`. Each needs an observation on a running box; each says which.
- `„A tárhely mérete most nem olvasható ki."` — the disk caveat line
- `„nem ismert"` — the short label in the value slot
- `„Erről a mentésről nincs eredményünk."` — the `title` on the no-verdict backup mark
**Confirmed: 38.** Seven of those were re-confirmed against live source or the live hub tonight rather
than against paperwork: the tripwire, the off-site repository, the claim path, the catalogue, the
tunnel, the reset code and the operator-email digest.
## 5. The §7.3 truth table as implemented
## 2. Every downgrade, with the coupling that broke
| this app's own most recent dump result | restore point | verdict |
|---|---|---|
| any of its databases failed | yes | `error` |
| all clean | yes | `ok` |
| none recorded | yes | **no icon**, time only, with the title above |
| any | no | no tier-1 row, unchanged |
The rule is *"a proof is about the code that existed when it ran"*. **It did not fire the way the task
expected.** Not one downgrade came from code moving under an old proof. **All twelve came from step 1
of the same rule — the cited evidence does not exist.**
**Recency was left alone**, deliberately: an age threshold means inventing a number, and the time is
already printed beside the icon. Recorded as an observation.
Measured: of the 28 capability-map rows behind the page's claims, **8 carry a `tests/` or `audits/`
path in their evidence column and 20 carry prose only.** The green dots were being drawn from rows
that cite an argument, not a walk. Filed as **R-290**.
## 6. The CI timeout, and what is still unknown
**And the decay ran the other way once.** `fault.operator-email` — *"one mail per run, every failing
app named"* — I first took to be contradicted by R-182 (open, *"tells the operator about ONE app and
silently swallows every other"*). Reading live source: the digest `backup_run_failures` is allowlisted
(`hub/internal/api/handler.go:1837`), operator-only (`notify/dispatcher.go:423`) and templated
(`notify/templates.go:48`); `recovery_unit_capture_failed` is record-only (`dispatcher.go:376`); a
cooldown drop now logs a `suppressed` row (`dispatcher.go:314-330`). **The claim is right and the
register row is stale** — filed as **R-289**. The session went looking for stale proofs and found a
stale defect.
**`timeout-minutes: 5`** — every honest run in the observed session finished in **1834 s**, so 5 min
is ~9× the slowest honest run and far under whatever reaped run 264 at 834 s. The alarm mail now
carries **`Elapsed`** (a start stamp in step 1 via `$GITHUB_ENV`; an absent stamp prints
`unknown (no start stamp)`, never a bogus number), and its "names itself in the run log" sentence is
qualified so it cannot mislead when there is no log.
## 3. The positive control
**THE UNKNOWN IS NOT CLOSED.** Whether the `if: failure()` alarm fires at all for a *reaped* job is
**still unverified**. The timeout makes the reap unreachable in practice; it does not answer what
happens inside one. Demonstrating it would mean deliberately hanging a run on `main`, which would
leave the branch red for a parallel session, so it was not done. Said in the workflow comment, the
changelog, R-265 and here — four places, none of them claiming it is answered.
```
1 BASELINE real dataset -> OK, exit 0
2 PLANT scratch copy: use.dlna missing -> walked -> CONVICTED, exit 1
"use.dlna: status 'walked' but NO evidence document cited"
3 REMOVE scratch copy deleted; committed dataset never touched
4 RE-RUN real dataset -> OK, exit 0
```
## 7. Tests
Plant → convicted → removed → clean. The gate also convicted **51 problems in my own first draft** of
the dataset (bad anchors, register ids that are not in `OPEN-ITEMS.md`, an evidence path that does not
exist) before any of this — which is the more convincing demonstration, because it was not staged.
| | before | after |
|---|---|---|
| controller | — | **1355** total (`+7` this session: 4 verdict, 3 disk-meter) |
| agent | — | **947** total (`+5` this session) |
## 4. The two known disagreements — both settled, and neither document was wrong
`go build ./... && go vet ./... && go test ./...` **green in both repos** (controller 28 packages,
agent 29), run separately from every commit. `controller_gates.py --fast` all OK; `agent_gates.py
--fast` all OK; `repo_gates.py --fast` **all 8 OK**.
**"A customer restores their own data with no help."** The map says **MISSING (as evidence)**; the
2026-08-07 walk records a customer route completed with no shell. **Not a contradiction.** The map's
row is *"A customer (**not the operator**) performs a restore via UI alone"* — it is about *who*. The
walk proves the *route*. No non-operator has ever done it, which is what the page's own neighbouring
claim already says.
The dashboard test **extracts** the meter block from the shipped template rather than copying it — a
copied block drifts and then passes while the page it covers has changed.
**The reinstall story.** The map's `PROVEN-LIVE (2026-08-04 night drill)` row is scoped in its own text
to *"a controller-data-volume rebuild — NOT a total host loss"*. The 2026-08-09 rehearsal was a
whole-host uninstall and reinstall. **The map has no row for that case at all** — a gap, not a
disagreement.
## 8. Deploy
**Would anything here have caught either one? No — and it could not have, because neither was false.**
Both are collisions of vocabulary: "customer" meaning *the route* or *a person*, "rebuild" meaning
*the guest* or *the host*. No gate detects an ambiguity that makes two true sentences look
contradictory. They surfaced only when someone tried to state them side by side. **That is the
argument for the dataset** — one id, one scope, one status — and against prose rows.
| | version | evidence |
|---|---|---|
| agent | **0.128.0** | `felhom-agent --version` on `felhom-pve`; service `active`; prior binary kept as `felhom-agent.bak-0.127.0` |
| controller | **0.210.0** | `docker ps` on guest 9201: `felhom-controller:0.210.0 Up (healthy)` |
## 5. The data file
**Endpoint-level validation of the controller UI was ATTEMPTED AND DID NOT SUCCEED — stated rather
than skipped.** What was tried: login POST against the container IP `172.17.0.2:8080` with the
mandatory `Host:` header, first with curl's cookie jar and then with the `Set-Cookie` handled
explicitly (the known `felhom_session` jar trap). Login returned `302` and `/` returned `302` back to
login both times, so the dashboard was never rendered. **Fallback observable, on the deployed
artifact rather than the source** — `grep -a` inside the running container's binary:
`SystemInfo.DiskKnown` ×1, the disk caveat line ×6, the no-verdict title ×1, `--version`
`0.210.0 (commit c732fe1)`. That proves the shipped bytes carry both fixes; it does **not** prove the
rendered page, and the template-level tests are what stand for that.
`documentation/architecture/where-felhom-stands.yaml`, 55 entries. **Every entry cites at least one
source and the gate proves it** (`check_stands.py` rule 1). YAML rather than JSON because statuses move
one line at a time and a YAML diff shows which claim moved; a JSON re-dump reflows.
## 9. The bake, and the three Day-0 values
Rules honoured: it is a **view** (every entry cites map / register / evidence); **no status was raised
in it** — the one upgrade is recorded against evidence and the map is named as the thing that must
change; and it is **regenerated, not hand-edited** for the page.
`documentation/tests/golden-0.210.0-2026-08-08/` — golden **0.210.0**, **656 787 777 B**, sha256
`b9f701fa…4c0a00`, round-trip verified, `./etc/felhom-controller-image` read **out of the downloaded
archive** → `felhom-controller:0.210.0`. Markers all green, token grep 0 with a control returning 1,
bake VM destroyed, drill disk restored to `virgin`.
## 6. The page
| field | set to | verified |
|---|---|---|
| `golden_version` | **0.210.0** | package `GET` **200**; hub dropdown offers it, `data-sha` matches the bake |
| `agent_version` | **0.128.0** | package `GET` **200**; hub dropdown offers it, `data-sha` matches the deployed binary |
| `min_agent` | **0.127.0** (unchanged) | read from the CHANGELOG header written this session |
- `where-felhom-stands.html`**generated, 54 KB, zero `<script>` tags**, same palette
(`#0b1220` / `#121b2c` / `#34d399` `#60a5fa` `#fbbf24` `#64748b`), 1600 px, A3 landscape print rules.
- Every moved status carries a visible **`changed 2026-08-09, was walked`** chip plus a *why it moved*
line — 12 of them, no diffing required.
- `Where Felhom Stands.html`**`where-felhom-stands-2026-08-09-snapshot.html`** (`git mv`, so the
space is out of every shell path), with a line in `documentation/README.md` calling it a dated
snapshot that is not maintained.
- **What the old bundle actually was**, since it aimed the fix: not merely minified — the content sat
**gzip+base64 inside a JS module map**, and the three blobs decompress to the bundler and React, with
the document itself in a JSON-escaped string on line 393. It rendered and nothing else.
**⚠ The agent was NOT published until this session checked, and it mattered.** R-221's fix is in the
**agent**; the binary had been hand-deployed and never published, so `agent_version 0.128.0` was not
selectable and a fresh install would have received 0.127.0 — the golden would have carried the
controller fixes and **not** the one the headline defect needed. Caught by checking each value was
*fetchable* instead of assuming. Published from the **live-deployed bytes**, sha-verified across the
hop first (`c6eba73b…` identical on both sides).
## 7. The corrected silence rows
**`min_agent` stays 0.127.0 deliberately:** `MinAgent` declares what the *controller* requires, and
v0.210.0 requires nothing new from the agent. R-221 is delivered by `agent_version`, not by the floor.
**R-281 is WITHDRAWN. It was wrong in both directions**, and the operator's mailbox is what settled it.
**Nothing was vouched. No hub setting was touched.** The Save is the operator's.
- **The tripwire DID fire**: `escrow_blob_served` at 10:19:41 UTC = **12:19 CEST**, eight minutes before
the verified restore.
- **False alarms fired too**: `host_down` 09:28 UTC and `node_down` 09:30 UTC, both error severity, both
`sent`, for deliberate attended work — eight operator mails in all. → **R-285**, the opposite gap
from the one filed.
## 10. Gate failures remaining
**The measurement's cause IS established.** The P7 query copied `/data/hub.db` **without
`hub.db-wal`**; the hub runs SQLite in WAL mode, so everything after the last checkpoint was invisible.
**Signature, exact:** P7 reported *"2 events all day, newest `db_dump_completed` 00:30:07"*, and the
number of rows on 08-09 at or before 00:30:07 is **exactly 2**.
**None.** `golden_currency_gate.py` went red the moment the controller was bumped — correct, and
closed by the bake, not by `--no-verify`. **No `--no-verify` anywhere in this session.**
The two obvious alternatives were **tested and refuted**, not waved away: a **timezone offset** — all
nine mailbox stamps equal the hub's UTC + 2 h exactly, so the window was right; and a **wrong key or
wrong store** — the same table and key return the correct rows now. A live re-run **cannot** reproduce
the fault because the WAL has since been checkpointed, and that is stated rather than dressed up as a
reproduction.
## 11. Register
**The lesson, filed as R-286:** the control was drawn from the *same stale snapshot* as the
measurement, so it agreed. **A control must come from a different channel.** The independent channel —
the mailbox — was available the whole time. This is also a trap `operations/nodes.md` already
documents, and which I had avoided correctly earlier in the same session.
**Closed:** R-221, R-259, R-258, R-265 (the last with its unknown explicitly still open).
**Minted:** **R-266** — the failed root `statfs` still travels to the hub as a 0-of-0 disk; ranked
low because it is the quiet direction, and now a two-repo wire change governed by G-1's gate.
**Highest ID moved R-265 → R-266.** **G-3 unblocked** in `ROADMAP.md`; **CONTEXT S-39** rules the
convention.
## 8. Register
**Still open, untouched:** R-246, R-255, R-256, R-257, R-261, R-262, R-263, R-264, R-240, R-243,
R-202, R-213, R-244, R-214/R-235, C7's test-comment half, and G-8's other half.
**Ceiling moved R-284 → R-290.** Opened: **R-285** (planned reinstall pages the operator), **R-286**
(same-channel control), **R-287** (the CI red is true), **R-288** (the capability map is unreadable),
**R-289** (R-182's row is stale), **R-290** (map rows cite no evidence). Withdrawn: **R-281**.
## 12. The capability-map row
## 9. Part 4 — and I did not change the workflow, on purpose
`00-capability-map.md:93`*"A failed per-app Tier-1 backup reaches the OPERATOR"*. **Checked, and
it was NOT claiming something untrue:** it is about the operator notification path and claims nothing
about what `/backups/apps` draws. But its narrative — *"the page you open to ask whether ONE app is
backed up"* — invites the wrong reading, and the adjacent thing WAS false: the page's tick was green
on presence until v0.210.0, so the two halves disagreed and only the operator half was true. The row
now records that.
**Which gate:** `published` / `scripts/check-published-versions.py`.
## 13. Observations — noticed, NOT acted on
**The premise is wrong in every particular. It is not ref-sensitive.** The gate enumerates releases
from the **Gitea tags API** (`main()`, `/api/v1/repos/admin/felhom-agent/tags?limit=200`), so the
checked-out ref is irrelevant — and the two previous tag pushes **passed** (run 190 `v0.126.0`, run 216
`v0.127.0`).
1. **Other collectors in `info_linux.go` return silently on error**`readLoadAvg`, `readMemInfo`
and the temperature read. Only the disk one was traced to a customer-visible surface, and the
change was deliberately not widened into a refactor of that file.
2. **The tick's recency weakness stands.** A tick over a three-week-old restore point is still a
tick. Adding an age threshold means inventing a number; the time is printed beside it.
3. **The controller UI could not be driven headlessly this session** (§8). Worth one session to
re-establish the documented headless login, because "invoke the endpoint the UI invokes" is this
project's standard validation method and it is currently unavailable for the controller.
4. **`HDDKnown` is wired but has no template consumer yet** — the HDD path renders through
`StorageBars`, which has its own `Disconnected` state. Adding the flag there is the natural next
step of the S-39 conversion and is part of G-3's survey, not this session.
**What is true:** run 267 (main, `28ba8593b8`, 08-08 14:29 UTC) printed `ok v0.120.0: binary
downloadable`. Run 284 (**the same commit**, on the tag, 08-09 09:30 UTC) printed
`FAIL v0.120.0 — HTTP 404`. A published release became uninstallable between the two. The registry now
holds exactly the ten newest versions; `0.128.0` was published **14:47 UTC, eighteen minutes after run
267**.
**Who removed `0.120.0` is NOT established, and I will not guess:** `package_cleanup_rule` is empty
(queried in Postgres), `app.ini` sets no limit, `publish-agent.sh:77` only pre-deletes the version it
is publishing, the Gitea pod has 53 days uptime and 0 restarts, and **no `DELETE` on the packages API
appears in 48 h of router logs**. The internal `[cron.cleanup_packages]` `@midnight` job falls in the
window and would leave no router line — **a leading candidate, not a conclusion.**
**So nothing was silenced and no workflow file was changed.** The red is a **true positive** — a tagged
version that cannot be installed is the exact R-115 defect the gate exists to catch, and muting it
would hide the next one. **What it therefore still does not check: nothing. Nothing was disabled.**
The honest fixes — bound the gate to versions at or above the vouched `min_agent` floor (0.127.0 today;
nothing can install 0.120.0), or retire tags whose packages go — are release decisions, and §8 forbids
fixing findings here. Filed as **R-287**.
**Also measured, and it is good news:** the failure alarm did send —
`RESEND-ACCEPTED id=fa1a7a83-714f-4357-b0ca-d3c4bb7ae73f`.
## 10. Observations — noticed, not acted on
- **`fail.app-crash` and `fault.operator-email` are the same unobserved thing** seen from two sides:
the digest is wired and correct in source, and no one has watched it arrive.
- **The verification depth is recorded per claim** (`depth: source-read | register+map |
needs-hardware`). 23 of 55 got a source or evidence read; the rest were checked against the register
and map only. That is on the face of the data rather than implied by a green tick.
- **The capability map is the thing that actually needs fixing.** The dataset now disagrees with it for
twelve rows, and the dataset is only a view — **the map still says PROVEN-LIVE for all twelve** (R-290).
- `documentation/audits/` holds 131 files and `tests/` 37; the evidence exists in quantity. The gap is
that the map does not point at it.
- **Out of scope and left alone, as instructed:** every finding above, the capability-map restructure
(R-288), and the two guards owed from yesterday.
+6
View File
@@ -17,6 +17,12 @@ The Docker-only app-domain controller. Full per-area docs grounded in current so
→ [`controller/README.md`](controller/README.md): module map, deploy & stack lifecycle, backup
architecture, storage/monitoring/metrics, auth/hub/sync/integrations.
### Where we stand — `architecture/where-felhom-stands.*`
The operator's one-page picture of what is proven, built, partial and missing.
- [`architecture/where-felhom-stands.html`](architecture/where-felhom-stands.html) — **generated**; do not hand-edit
- [`architecture/where-felhom-stands.yaml`](architecture/where-felhom-stands.yaml) — the data behind it; every claim cites its source. Gate: `scripts/check_stands.py`; regenerate with `scripts/render_stands.py`
- [`architecture/where-felhom-stands-2026-08-09-snapshot.html`](architecture/where-felhom-stands-2026-08-09-snapshot.html) — **a dated snapshot, NOT maintained.** The original React bundle, kept for the record; its statuses are those of 2026-08-09 before the verification pass
### Host agent & platform — `architecture/`, `proxmox-platform.md`
The operator-tier agent and the Proxmox platform.
- [`architecture/01-topology-and-trust.md`](architecture/01-topology-and-trust.md) — topology & trust model
File diff suppressed because one or more lines are too long
@@ -0,0 +1,727 @@
# where-felhom-stands.yaml — the data behind documentation/architecture/where-felhom-stands.html
#
# THIS IS A VIEW, NEVER A SOURCE. Every entry cites the capability-map row, register row or
# evidence document it derives from; an entry with no source is a defect, not a claim.
# 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.
# Regenerate the page after any status move: python3 scripts/render_stands.py
#
# YAML rather than JSON, deliberately: statuses move one line at a time and a YAML diff shows
# which claim moved. A JSON re-dump reflows and shows the whole file.
#
# verdict vocabulary: confirmed | downgraded | upgraded | contested | needs-hardware
# depth: source-read (opened live source or evidence) | register+map (checked against the
# register and capability map only) | needs-hardware (cannot be settled off-box)
verified_on: 2026-08-09
verified_against:
felhom-agent: 28ba8593b8
felhom-controller: c732fe1283
hub: 56f8aa611c
claims:
- id: install.iso-selfregister
band: journey
stage: 1
title: "A blank machine installs itself from our own boot image and registers itself as unclaimed — proven on two different boards"
status: walked
note: "Two boards: N100 2026-07-18, HP t740 2026-07-21."
sources:
- capability-map: "Bare-metal Felhom ISO (blank hardware → zero-touch auto-install → first-boot host-install)"
- evidence: "tests/VALIDATION-n100-rehearsal-2026-07-18.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: install.installer-by-tag
band: journey
stage: 1
title: "The installer is published rather than pushed: rolling it back is one act"
status: built
note: "Gate 6 of hostinstall_gates asserts the manifest names an installer-v tag; ran green tonight."
sources:
- capability-map: "The installer is PUBLISHED, not pushed"
- register: "R-110"
changed:
from: walked
reason: "gate 6 asserts the manifest names an installer tag, but no walk of a rollback is on file"
verified:
date: 2026-08-09
verdict: downgraded
depth: source-read
- id: install.byo
band: journey
stage: 1
title: "Installing onto hardware the customer already owns — the path exists, the first real one has not happened"
status: partial
note: "UPGRADED: a real --mode byo install completed on demo-hp 2026-08-09 (Day-0 provision SUCCESS, 3m49s). Still not a customer's own hardware, so not 'walked' — but 'has not happened' is now false."
sources:
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: upgraded
depth: source-read
- id: install.nic-selfheal
band: journey
stage: 1
title: "A machine that ends up on the wrong network port explains itself on screen and finds its way back — proven in a virtual drill, never on metal"
status: partial
note: "Map says the same: PROVEN-LIVE (nested drill — nested != metal)."
sources:
- capability-map: "Box survives a wrong-NIC install"
- evidence: "audits/SPIKE-firstboot-nic-sweep-2026-07-22.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: install.reinstall-refuses
band: journey
stage: 1
title: "Reinstalling a machine we previously installed refuses, because our own removal leaves a name service holding the port our own installer checks"
status: partial
note: "Warning card. Confirmed live 2026-08-09; dnsmasq restarted by --uninstall seizes :53."
sources:
- register: "R-272"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: claim.one-time-code
band: journey
stage: 2
title: "An emailed one-time code; the customer sets their own password and the operator never sees it"
status: walked
note: "Exercised live 2026-08-09: reset code accepted at /claim, 302 + session."
sources:
- capability-map: "Customer claim: one-time emailed code → customer sets own password"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: claim.selfbind
band: journey
stage: 2
title: "The customer can bind their own machine from a link, with no operator present — done once, for real"
status: walked
note: "attempts=0, locked=0, source customer_selfbind, 2026-07-18."
sources:
- capability-map: "Customer binds their own appliance (self-service)"
- evidence: "tests/VALIDATION-n100-rehearsal-2026-07-18.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: claim.never-by-non-operator
band: journey
stage: 2
title: "It has never been done by a person who is not the operator"
status: partial
note: "Map records MISSING (as evidence). Still true after 2026-08-09."
sources:
- capability-map: "A customer (not the operator) performs a restore via UI alone"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: claim.code-naming
band: journey
stage: 2
title: "The same word is used for two different secrets across three surfaces; the email points at a page a rebuilt machine does not show"
status: partial
note: "Warning card. Cost a real code on 2026-08-09."
sources:
- register: "R-282"
- register: "R-283"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: use.catalog
band: journey
stage: 3
title: "Apps installed from a catalog of about 52, with a memory guard and health-aware progress"
status: walked
note: "53 templates listed on the rebuilt box 2026-08-09; deploy driven live."
sources:
- capability-map: "Deploy an app from the catalog (env config, memory guard, health-aware progress)"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: use.lifecycle
band: journey
stage: 3
title: "Start, stop, restart, update, logs, remove — and the parts that must not be stopped cannot be"
status: built
sources:
- capability-map: "App lifecycle: start/stop/restart/update/logs/remove/redeploy"
- register: "R-108"
changed:
from: walked
reason: "no walk document cited by the map row or anywhere else"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: use.tunnel
band: journey
stage: 3
title: "Reachable from anywhere through a tunnel, per-app addresses"
status: walked
note: "Verified tonight: the rebuilt box answered on its public URL from outside."
sources:
- capability-map: "Remote access via Cloudflare Tunnel + Traefik (per-app subdomains)"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: use.lan-fallback
band: journey
stage: 3
title: "Reachable on the home network when the internet is down"
status: built
note: "Only an observation on a running box with WAN pulled could settle it. Boxes are off."
sources:
- capability-map: "LAN access when internet is down (lan_resolver)"
verified:
date: 2026-08-09
verdict: needs-hardware
depth: needs-hardware
- id: use.files
band: journey
stage: 3
title: "Phone photos, documents with text recognition, files from Windows Explorer or a Mac"
status: walked
sources:
- capability-map: "Files from Windows Explorer / Mac Finder (SMB server)"
- evidence: "audits/SPIKE-lan-discovery-2026-07-18.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: use.launcher
band: journey
stage: 3
title: "A one-tap launcher, and a read-only guest link for visitors"
status: built
sources:
- capability-map: "Indítópult (app launcher) — one-tap grid of the household's openable apps"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: use.dlna
band: journey
stage: 3
title: "Media to a TV"
status: missing
note: "Map: MISSING."
sources:
- capability-map: "Media to TV via DLNA"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: use.multiuser
band: journey
stage: 3
title: "Separate accounts per household member"
status: missing
note: "Map: MISSING."
sources:
- capability-map: "Multiple household users / per-person accounts"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: drives.enrol
band: journey
stage: 4
title: "A new drive is found, offered, formatted, mounted and enrolled — including on awkward older boot layouts"
status: built
note: "Applies to a NEW drive. Re-attaching an existing one after a reinstall is R-280 and fails."
sources:
- capability-map: "Drive wizard: scan/format/mount/enroll, incl. legacy-boot LVM-root hosts"
- register: "R-220"
changed:
from: walked
reason: "the 2026-08-09 walk exercised RE-attach (which failed, R-280); first-enrolment of a NEW drive has no walk on file"
verified:
date: 2026-08-09
verdict: downgraded
depth: source-read
- id: drives.migrate
band: journey
stage: 4
title: "Moving data between drives, crash-safe; removing a drive safely; unplug detected"
status: built
sources:
- capability-map: "Data migration between drives (all / per-app), crash-safe"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: drives.nas
band: journey
stage: 4
title: "A network drive can be browsed and hold bulk media, but may not hold an app's data — enforced"
status: walked
note: "RefuseAsAppNamespace is the fail-closed predicate."
sources:
- capability-map: "Network storage (NAS) is browse + bulk-media only"
- register: "R-108"
- evidence: "audits/R108-network-app-namespace-2026-07-30.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: drives.reattach-wall
band: journey
stage: 4
title: "After a reinstall the data drive cannot be re-attached through any dashboard route"
status: partial
note: "Warning card. /api/disks/candidates returns empty; the restore page promises two clicks."
sources:
- register: "R-280"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: backup.tier1
band: journey
stage: 5
title: "App data on the machine, nightly database dumps, a copy on a second drive"
status: built
sources:
- capability-map: "Tier-2 secondary-drive copy: class-driven legs"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: backup.whole-machine
band: journey
stage: 5
title: "A whole-machine archive that lands off the guest's own disk — a single-drive machine is recorded as degraded rather than pretending"
status: built
sources:
- capability-map: "Whole-guest backup lands OFF the guest's own physical device"
- register: "R-165"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: backup.offsite
band: journey
stage: 5
title: "An encrypted off-site copy, sealed with a key the operator cannot read"
status: walked
note: "Verified tonight from the repo itself: 18 snapshots, daily, unbroken."
sources:
- capability-map: "Offsite (restic → Hetzner Storage Box)"
- register: "R-199"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: backup.restore-proof
band: journey
stage: 5
title: "The backups prove themselves: a restore is actually performed, unattended, on every tier, on both machines"
status: built
note: "'on both machines, unattended, every tier' is a continuing claim about scheduled runs. Both boxes are off; the last recorded restore-test on demo-hp FAILED (notification_log 2026-08-05 restore_test_failed). Cannot be settled tonight."
sources:
- capability-map: "Restore-proof is UNATTENDED — the scheduler covers EVERY tier"
- register: "R-86"
changed:
from: walked
reason: "no walk document cited, and the last recorded restore-test on demo-hp FAILED (2026-08-05)"
verified:
date: 2026-08-09
verdict: downgraded
depth: needs-hardware
- id: backup.fill-warning
band: journey
stage: 5
title: "The customer is warned before a drive fills, per drive, in their own language"
status: walked
note: "R-177 (no operator-triggerable run) limits testing, not the capability."
sources:
- capability-map: "The customer is warned BEFORE a filesystem fills"
- register: "R-167"
- evidence: "audits/SPIKE-r165-mp1-merge-2026-08-02.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: backup.sikeres
band: journey
stage: 5
title: "A backup that covered nothing still calls itself successful"
status: partial
note: "Warning card."
sources:
- register: "R-240"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fault.selfheal
band: journey
stage: 6
title: "The machine watches itself and repairs some faults without telling anyone it had to"
status: built
note: "R-264 records that the self-heal counters reach the hub and are decoded nowhere."
sources:
- capability-map: "Box survives an unattended app or guest-network failure"
- register: "R-264"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: source-read
- id: fault.operator-email
band: journey
stage: 6
title: "Failures reach the operator by email, one mail per run, every failing app named"
status: built
note: "CONFIRMED against source: backup_run_failures digest is allowlisted (api/handler.go:1837), operator-only (dispatcher.go:423), templated (templates.go:48); recovery_unit_capture_failed is record-only (dispatcher.go:376). NOTE: R-182's register row still describes the PRE-FIX behaviour — see R-289."
sources:
- capability-map: "A failed per-app Tier-1 backup reaches the OPERATOR — EVERY failing app, in ONE mail per run"
- register: "R-182"
changed:
from: walked
reason: "the digest is wired and source-verified, but no run of it has been observed delivering"
verified:
date: 2026-08-09
verdict: downgraded
depth: source-read
- id: fault.customer-email
band: journey
stage: 6
title: "Failures reach the customer"
status: built
note: "The customer leg is built; today's log shows customer-channel rows skipped as operator_only."
sources:
- capability-map: "App crashes → customer notified (one event per transition, no flapping spam)"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fault.already-paired
band: journey
stage: 6
title: "An already-paired box is still told to pair itself"
status: partial
note: "Warning card."
sources:
- register: "R-214"
- register: "R-235"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: recover.screen
band: journey
stage: 7
title: "A rebuilt machine shows a full-page recovery screen without anyone looking for it, and says plainly that nobody can replace a lost recovery code"
status: walked
note: "Seen unsought on the rebuilt demo-hp 2026-08-09, seal date matching host_escrow.created_at."
sources:
- register: "R-193"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: recover.byte-identical
band: journey
stage: 7
title: "The customer's code opens the sealed package and the data returns byte for byte — including accented Hungarian filenames, verified as raw bytes"
status: walked
note: "Reproduced 2026-08-09: 4/4 byte-identical, name bytes NFC-preserved, out of snapshot 41c830db."
sources:
- register: "R-201"
- evidence: "tests/walk5-r201-2026-08-07/journal.md"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: recover.no-shell
band: journey
stage: 7
title: "Walked end to end with no command line inside the machine (2026-08-07)"
status: walked
note: "CONTESTED-RESOLVED: not a contradiction. The walk proves the ROUTE needs no guest shell; the map's MISSING row is about a NON-OPERATOR doing it, which has still never happened. Two questions, one word 'customer'."
sources:
- register: "R-201"
- evidence: "tests/walk5-r201-2026-08-07/journal.md"
- capability-map: "A customer (not the operator) performs a restore via UI alone"
verified:
date: 2026-08-09
verdict: contested
depth: source-read
- id: recover.putback
band: journey
stage: 7
title: "Putting restored files back where they belong is still manual"
status: partial
note: "Warning card. Confirmed 2026-08-09: the restore lands in a verification folder and says so."
sources:
- register: "R-213"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: recover.tripwire
band: journey
stage: 7
title: "The tripwire that says someone is opening this customer's backups does fire"
status: walked
note: "CONFIRMED tonight from the hub store: escrow_blob_served 2026-08-09 10:19:41Z = 12:19 CEST in the operator's mailbox. R-281's original claim of silence is WITHDRAWN."
sources:
- register: "R-281"
- register: "R-285"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.disk-failing
band: failures
title: "A disk starts failing — healthy path only; a genuinely failing disk has never been seen"
status: partial
note: "Only a failing disk on a running box could settle it."
sources:
- capability-map: "Lemez-egészség felügyelet: per-disk SMART kártya"
- evidence: "audits/SPIKE-smart-coverage-2026-07-25.md"
verified:
date: 2026-08-09
verdict: needs-hardware
depth: needs-hardware
- id: fail.backup-drive-unplugged
band: failures
title: "The backup drive is unplugged"
status: walked
sources:
- capability-map: "An ABSENT backup-target drive raises its OWN alarm"
- evidence: "audits/R116-v0116-2026-07-30.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fail.drive-filling
band: failures
title: "A drive is filling up"
status: built
sources:
- register: "R-167"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: fail.app-crash
band: failures
title: "An app crashes — the email leg has never been confirmed end to end"
status: built
note: "Consistent with fault.operator-email: the digest is wired but its delivery is unobserved."
sources:
- capability-map: "App crashes → customer notified (one event per transition, no flapping spam)"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.power-cut
band: failures
title: "Power cut mid-backup"
status: walked
sources:
- evidence: "audits/AUDIT-power-outage-recovery-2026-07-22.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fail.guest-destroyed
band: failures
title: "The guest is destroyed"
status: walked
sources:
- register: "R-201"
- evidence: "audits/DRILL-r201-night-run-2026-08-04.md"
- evidence: "audits/DRILL-r201-night-run-2026-08-04.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fail.wiped-reinstalled.data
band: failures
title: "The whole machine is wiped and reinstalled — the data comes back"
status: walked
note: "4/4 byte-identical 2026-08-09."
sources:
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.wiped-reinstalled.journey
band: failures
title: "The whole machine is wiped and reinstalled — the journey needs a terminal twice"
status: partial
note: "CONTESTED-RESOLVED against the map: the map's PROVEN-LIVE row is scoped to a controller-data-volume REBUILD (2026-08-04), not a whole-host reinstall. The map has no row for the host case, so there was no contradiction — only a gap."
sources:
- register: "R-273"
- register: "R-280"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.stolen-machine
band: failures
title: "The machine is stolen, and someone opens the backups — the operator is told"
status: walked
note: "Confirmed 2026-08-09 from the store and the mailbox."
sources:
- register: "R-281"
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.forgot-password
band: failures
title: "The customer forgets their dashboard password"
status: walked
note: "Exercised 2026-08-09 via the reset code."
sources:
- evidence: "audits/REHEARSAL-byo-reinstall-2026-08-09.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.lost-recovery-code
band: failures
title: "The customer loses their recovery code — by design, the data is unrecoverable"
status: built
note: "The recovery screen states it in Hungarian."
sources:
- capability-map: "Escrow ceremony: customer-facing wizard, one-shot R claim, operator zero-knowledge"
- register: "R-198"
changed:
from: walked
reason: "a by-design refusal; no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: source-read
- id: fail.moves-house
band: failures
title: "The machine moves house / new network"
status: walked
sources:
- capability-map: "Box survives a site/network change (relocation, different subnet, DHCP re-lease)"
- evidence: "audits/AUDIT-vacation-remote-ops-2026-07-20.md"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fail.internet-down
band: failures
title: "The internet goes down — built, never walked"
status: built
note: "Needs a running box with WAN pulled."
sources:
- capability-map: "LAN access when internet is down (lan_resolver)"
verified:
date: 2026-08-09
verdict: needs-hardware
depth: needs-hardware
- id: fail.hub-down
band: failures
title: "The hub is down"
status: built
sources:
- capability-map: "Config/state change round-trips in seconds (hub↔box immediacy"
changed:
from: walked
reason: "no walk document cited"
verified:
date: 2026-08-09
verdict: downgraded
depth: register+map
- id: fail.broken-release
band: failures
title: "We ship a broken release — the guard is missing"
status: missing
note: "Proven the hard way on 2026-08-09: a vouched agent version had no git tag and every install died at 5/8. Both guards still owed."
sources:
- register: "R-273"
- register: "R-287"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.stale-image
band: failures
title: "A fresh install picks up an old image"
status: partial
note: "Narrowed 2026-08-09: the resume path fetched the vouched golden; a fresh install still takes the newest LOCAL archive with no manifest comparison."
sources:
- register: "R-274"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.offsite-account-deleted
band: failures
title: "The off-site provider account is deleted"
status: partial
note: "The credential that holds the customer's documents can still delete."
sources:
- register: "R-95"
verified:
date: 2026-08-09
verdict: confirmed
depth: register+map
- id: fail.expected-downtime
band: failures
title: "The machine is switched off for an afternoon — no notion of expected downtime"
status: missing
note: "Confirmed 2026-08-09: eight operator mails for deliberate, attended work."
sources:
- register: "R-285"
verified:
date: 2026-08-09
verdict: confirmed
depth: source-read
- id: fail.customer-self-restore
band: failures
title: "A customer restores their own data with no help"
status: partial
note: "CONTESTED-RESOLVED: the map's MISSING is about a NON-OPERATOR performing it; the walks prove the route, not the person. Neither record was wrong."
sources:
- capability-map: "A customer (not the operator) performs a restore via UI alone"
- register: "R-201"
verified:
date: 2026-08-09
verdict: contested
depth: source-read
+7 -1
View File
@@ -509,10 +509,16 @@ applied.** The one that matters: Scenario A **fails against today's tree** with
| **R-278** | **demo-felhom's off-site tier has never completed a run and has been stuck for six days.** `offsite.state=needs_credential` since the 2026-08-03 guest rebuild; the hub's own alarm reads *"enabled + escrowed but no run has EVER succeeded"*; the controller's `offsite-credential-retry` job runs every 5 minutes and completes in 0 s, doing nothing. R-193's fix (the recovery SCREEN, controller 0.200.0) is present on the box, so the remedy exists — it just needs the customer-present ceremony that nobody has run, which is R-243's shape (*"a machine waiting for its recovery code can stop backing up off-site without alarming us"*) landing on a real box. **Contrast that makes it a defect and not a chore:** demo-hp, same rebuild, same day, recovered and has 18 snapshots | **READY (S) — NEW 2026-08-09** | — | Either the self-heal reconciler owns this shape end-to-end, or the box must say plainly on the dashboard that it is unprotected pending the recovery code | CC |
| **R-279** | **There is no operator-triggerable off-site backup.** The only route to `POST /backup/offbox/run` is the customer's own dashboard session; `signed_jobs` carries opaque operator-SIGNED blobs and the hub holds no signing key. This cost the rehearsal a stop: preparing the run needed one off-site push and there was no operator path to it. Sibling of **R-177** (no operator-triggerable fill check) | **READY (XS) — NEW 2026-08-09** | — | Same shape as R-177; solve both together | CC |
| **R-280** | **RANK 1 — after a reinstall the data drive cannot be re-attached through ANY dashboard route, and the restore page promises it is "two clicks".** Measured on the rebuilt demo-hp, 2026-08-09. The restore page diagnoses the situation perfectly and then sends the customer to an empty page: *„Előbb csatold vissza az adatmeghajtót. A mentéseid megvannak, és a meghajtók is megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová visszaállítani. **Ez két kattintás:** Tárhely → Meghajtók, »Meglévő meghajtó csatolása«."* **It is not two clicks; it is zero possible clicks.** `GET /api/disks/candidates``{"initialize":[],"attach":[]}`, so both wizards render an empty selector, and `Tárhely → Meghajtók` reads „Nincs regisztrált adattároló" with an empty unregistered list. **The agent is not at fault**`GET /api/disks` returns the NVMe in full (1.0 TB, SMART PASSED, `mount_path:/mnt/nvme-1tb`, `guest_attached:false`), so the channel and enumeration work. **ROOT CAUSE:** `handleDiskCandidates` builds both lists from `ListCandidateDisks`, the UNCLAIMED-disk scan; demo-hp's NVMe is deliberately BOTH the user-data drive and the `felhom-backup` target (`operations/nodes.md`), so it is claimed and never offered. That filter is **correct for `initialize`** (never offer to format a disk in use — `/storage/init` even says so: *„Rendszer- és biztonsági-mentés meghajtók itt nem jelennek meg — azok védettek"*) and **over-broad for `attach`**, which is non-destructive by definition and whose own page says *„A meghajtón lévő adatok nem törlődnek — a csatolás csak elérhetővé teszi azokat."* **It cascades:** no store → Calibre-Web's install page degrades to *„Nincs regisztrált adattároló — adja meg kézzel az útvonalat"* and demands a hand-typed `E-könyvtár útvonal`; no app → the restore rows read „Nincs telepítve". **THE ESCAPE HATCH WORKS AND NO CUSTOMER COULD FIND IT:** `POST /settings/storage/add` with `storage_path=/mnt/sys_drive` succeeded first try (*„Adattároló sikeresen hozzáadva"*) — and `/mnt/sys_drive` is an internal path, the very one registered before the wipe. Once registered, everything unblocked and the deploy form became a proper picker (*„Tárhely (sys_drive) — 64.2 GB szabad"*). **This is R-220's successor:** R-220 was closed as "drives unenrollable after a rebuild — fixed"; enumeration is fixed, OFFERING is not | **READY (M) — NEW 2026-08-09** | — | Populate `attach` from mounted-but-unregistered filesystems rather than from the unclaimed-DISK scan; and never print "two clicks" without asserting the destination is non-empty | CC |
| **R-281** | **The hub said NOTHING through an entire reinstall — and the tripwire for a sealed-backup unseal did not fire on a real unseal.** Between the uninstall (08:38 UTC) and the verified restore (10:27 UTC) demo-hp's guest was destroyed, the agent and its pveum identity removed, the host re-enrolled, a new guest provisioned, the box re-claimed, the sealed off-site package opened with the customer's recovery code, an app redeployed and 3.8 MB restored. **Events recorded for demo-hp in that window: ZERO. Notifications: ZERO.** **Positive control on the query** (standing rule 3): the hub recorded **2 events all day across all customers**, newest `db_dump_completed` at 00:30:07 — so the store is reachable and the silence is real, not a bad filter. **The good half, stated first:** no FALSE alarm fired during a legitimate reinstall, which is what P7 was watching for. **The owed half:** `escrow_blob_served` exists precisely as the tripwire for this moment — its text is *"the blob cannot be opened without the customer's recovery code… If no recovery is in progress on that box, investigate"* — and it **has fired for demo-hp before** (twice, last 2026-08-04 20:12:54). Today's unseal, through the R-193 recovery screen, fired it **not at all**. Either the screen's unlock path does not emit it or the rebuilt-box path bypasses it; **which of those is not established here.** A reinstall and a theft of a machine look identical to the operator | **READY (M) — NEW 2026-08-09** | — | Emit on the recovery-screen unlock path; and decide which reinstall milestones are worth one line each | CC |
| **R-281** | ~~**The hub said NOTHING through an entire reinstall — and the tripwire for a sealed-backup unseal did not fire on a real unseal.**~~ **WITHDRAWN 2026-08-09 — THE FINDING WAS AN ARTEFACT OF MY OWN MEASUREMENT, AND IT WAS WRONG IN BOTH DIRECTIONS.** The operator's mailbox settled it: the hub fired **twenty events** on 2026-08-09, and `escrow_blob_served` **DID** fire — 10:19:41 UTC / **12:19 CEST**, eight minutes before the verified restore. **Cause of the false reading, ESTABLISHED (not guessed):** the P7 query copied `/data/hub.db` **without `hub.db-wal`**. The hub runs SQLite in WAL mode (R-172), so every write since the last checkpoint was invisible. **The signature is an exact match:** P7 reported *"2 events all day, newest `db_dump_completed` 00:30:07"*, and the number of rows on 08-09 at or before 00:30:07 is **exactly 2**. **The two obvious alternatives were TESTED AND REFUTED**, not waved away: a **timezone offset** — all nine mailbox stamps equal the hub's UTC + 2 h exactly (`escrow_blob_served` 10:19→12:19, `host_down` 09:28→11:28, and seven more), so the window was right; and a **wrong customer key or wrong store** — the same table and key return the correct rows now. A live re-run cannot reproduce the fault because the WAL has since been checkpointed; the case rests on the command text plus the 2-of-2 count signature, and that is stated rather than dressed up as a reproduction. **This is a trap this project has already documented**`operations/nodes.md` says copying `hub.db` alone is *"valid but stale … the worst failure shape"* — and I had avoided it correctly earlier in the same session before hitting it. **Split out: → R-285** (the real, opposite defect) and **→ R-286** (the measurement lesson) | **WITHDRAWN 2026-08-09** | — | Superseded by R-285/R-286 | CC |
| **R-282** | **One secret, three different Hungarian names, and the email sends the customer to a page their box is not showing.** Sending it from the hub is „**Visszaállító** kód küldése"; the email that arrives is subject „Jelszó-**visszaállítási** kód", body „**Visszaállító** kód: …", and it instructs *„Add meg a vezérlőpult »**Elfelejtett jelszó**« oldalán"*; the page the box actually serves is „A szerver **beállítása**" asking for a „**Beállító** kód". **A rebuilt box shows a SETUP page and the hub can only send a RESET mail** (because hub-side the customer is still `claimed_at 2026-07-21`), so the instruction names a route that does not exist on screen. **It does work if you ignore the instructions** — the reset code was accepted on the setup page (302 + session), so this is naming, not function. **It cost this session real time and one wasted code:** the operator supplied a 3-word Hungarian code believing it was the recovery code, because the hub calls the claim code „Visszaállító kód" and the ESCROW code is also „Visszaállító kód" — the only reliable discriminator is length (claim = 3 Hungarian words; recovery = **10** EFF-list words, and the recovery screen does say „(tíz szó)") | **READY (S) — NEW 2026-08-09** | — | Pick one name per secret and use it on all three surfaces; make the mail's page reference match what a rebuilt box actually shows | CC |
| **R-283** | **After a rebuild the hub says "Claimed 18d ago" while the box serves its first-run setup page.** `customer_claims` for demo-hp still read `claimed_at 2026-07-21 16:29:25`, `generation 2`, `issued_at 2026-08-03` while the freshly provisioned guest — whose `settings.json` is new — correctly showed „A szerver beállítása". The two sides never reconcile: the hub's claim state survives a guest rebuild and the box's does not. Consequences: the operator's screen says the box is claimed when it is not, a resend produces a RESET code instead of a SETUP code (→ **R-282**), and any previously issued code fails with *„Hibás vagy lejárt kód"* — a message that is technically true and tells the customer nothing about the real cause, namely their own reinstall. Mirror image of **R-214/R-235** (an already-paired box still told to pair itself) | **READY (S) — NEW 2026-08-09** | — | Let a report from a box carrying no claim state clear the hub's, or show both sides on the operator page | CC |
| **R-284** | **„A kiválasztott tárhely majdnem megtelt." on a store that is 93 % FREE — an apparent inverted threshold.** Calibre-Web's deploy page rendered `<option value="/mnt/sys_drive" data-free-percent="93">` alongside „Tárhely (sys_drive) — **64.2 GB szabad**" and the warning „A kiválasztott tárhely majdnem megtelt." 93 % free read as 93 % used is the obvious candidate, and `checkStorageSpace(this)` is the function to look at. **Not confirmed by reading the code** — reported as measured output only. A capacity warning that cries wolf on an empty disk is one a customer learns to click past | **READY (XS) — NEW 2026-08-09** | — | Check `checkStorageSpace`'s comparison against `data-free-percent`; add a render test per branch | CC |
| **R-285** | **A planned, supervised reinstall pages the operator as if the machine had died — there is no notion of expected downtime anywhere.** During the 2026-08-09 rehearsal the hub sent, all `status: sent` to the operator channel: `host_stale` 08:58 UTC, `node_stale` 09:00, **`host_down` 09:28 (error)**, **`node_down` 09:30 (error)**, `host_leaf_changed` 09:31, `host_recovered` 09:31, `node_recovered` 09:34, `offsite_delivery_stuck` 09:34 — eight operator mails for work that was deliberate, attended and announced. **This is the OPPOSITE gap from the one R-281 filed:** the alarms are not missing, they are indiscriminate. `host_stale` at 30 min and `host_down` at 60 min (`monitor/host_staleness.go:22-23`, `downAfter = 2 * threshold`) cannot distinguish a wiped-on-purpose box from a dead one, and `host_leaf_changed` firing on a reinstall is correct-but-expected. **Note the interaction with the mute used on 2026-08-09 evening:** blocking a customer silences everything, so today the only two settings are *page me for planned work* and *tell me nothing at all*. **What is owed is a middle:** a maintenance window, or an operator-set expected-downtime flag, that suppresses staleness and leaf-change while leaving genuine faults audible | **READY (M) — NEW 2026-08-09** | — | The evidence is the operator's mailbox plus `events`/`notification_log` for 2026-08-09 | CC |
| **R-286** | **A control drawn from the same channel as the measurement cannot detect a defect in that channel — and this one passed while the measurement was wrong.** The P7 check asked *"did the hub record anything?"* against a stale snapshot, got "no", and then validated itself with *"is the hub recording ANY events today, for anyone?"***against the same stale snapshot**. It answered "2 events all day", which was internally consistent and entirely false. The standing rule (*an absent log line is not evidence*) was followed in form: a positive control WAS run. **It was the wrong kind of control**, and nothing in the rule as written says so. **The independent channel existed and was available the whole time: the operator's mailbox.** One glance at it would have shown eight alarms in the window. **The durable lesson, to be added where the standing rules live:** a control must come from a DIFFERENT channel than the measurement — same query, same snapshot, same API, same clock all fail this. **Concrete follow-through owed:** (a) add this to the standing rules in `runbooks/workspace-CLAUDE.md`; (b) any hub-state check in a runbook must copy `-wal` or query the pod directly, never `cat hub.db` alone — the trap `operations/nodes.md` already documents | **READY (S) — NEW 2026-08-09** | — | Parent: R-281 (withdrawn) | CC |
| **R-287** | **`felhom-agent` CI is red for a TRUE reason, and the diagnosis it was filed under is wrong in every particular.** The task premise was *"the gate is sensitive to being run against a tag ref rather than a branch"*. **It is not.** `check-published-versions.py` enumerates releases from the **Gitea tags API** (`/api/v1/repos/admin/felhom-agent/tags?limit=200`, `main()`), so the checked-out ref is irrelevant; and the two previous tag pushes **passed** (run 190 `v0.126.0`, run 216 `v0.127.0`). **What is actually true:** run 267 (main, `28ba8593b8`, 2026-08-08 14:29 UTC) printed `ok v0.120.0: binary downloadable`; run 284 (tag, **the same commit**, 2026-08-09 09:30 UTC) printed `FAIL v0.120.0 — binary NOT downloadable (HTTP 404)`. **A published release became uninstallable between those two runs.** The registry now holds exactly the ten newest versions (0.121.0…0.128.0); `0.128.0` was published **2026-08-08 16:47 CEST = 14:47 UTC, eighteen minutes after run 267**, and `0.120.0` is gone. **WHO REMOVED IT IS NOT ESTABLISHED, and that is stated rather than guessed:** `package_cleanup_rule` is **empty** (queried in Postgres), `app.ini` sets no package limit, `publish-agent.sh` only pre-deletes the version it is publishing (`:77`), the Gitea pod has **53 days uptime and 0 restarts** so `RUN_AT_START` did not fire, and **no `DELETE` on the packages API appears in 48 h of Gitea router logs**. The leading candidate is the internal `[cron.cleanup_packages]` `@midnight` job, which falls inside the window and would leave no router log line — **leading candidate is not established.** **THEREFORE NO GATE WAS SILENCED AND NO WORKFLOW WAS CHANGED.** Silencing it would hide a released-but-uninstallable version, which is the exact R-115 defect the gate exists to catch. **It will recur:** if the ten-version window is real, the next publish evicts `0.121.0`. **Two honest fixes, both out of tonight's scope:** bound the gate to versions at or above the vouched `min_agent` floor (0.127.0 today — nothing installs 0.120.0 and nothing can), or retire ancient tags when their packages go. **Also measured, and good news:** the failure alarm DID send — `RESEND-ACCEPTED id=fa1a7a83-714f-4357-b0ca-d3c4bb7ae73f` | **READY (M) — NEW 2026-08-09** | — | Establish the deleter first; do not raise the retention until it is known | Viktor |
| **R-288** | **The capability map is too long to be read, and that is why it stops being true.** `architecture/00-capability-map.md` is **134 642 bytes / 19 456 words across 99 table rows in only 159 lines** — because the rows ARE the length. Measured, longest first: the unaided-recovery-journey row is **3 024 words**, the offsite-password-recovery row **1 087**, the unattended-restore-proof row **971**, the app/guest-network-failure row **904**. That single longest row is a novella of nested corrections, each appended rather than resolved. Its own verification stamp reads **2026-07-16 against evidence corpus @ felhom.eu tip `4b18cc5`** (line 23) — three weeks stale, which is the measurable consequence: nobody re-reads a row they cannot finish. **This is the project's memory, so restructuring it is surgery and wants daylight** — filed, deliberately not attempted in the 2026-08-09 session. **What the shape should probably be:** one line of status per capability plus a dated evidence pointer, with the argument moved to the audit it came from | **READY (M) — NEW 2026-08-09** | — | Do not fold this into another session; it needs its own | Viktor |
| **R-289** | **R-182's register row describes a defect the code no longer has — an OPEN row that is a false alarm.** The row reads *"A full disk tells the operator about ONE app and silently swallows every other app's refusal for an hour"*, cited at `hub/internal/notify/dispatcher.go:268`. **Read against live source 2026-08-09, that is fixed:** the per-run digest `backup_run_failures` is allowlisted (`hub/internal/api/handler.go:1837`), operator-only (`dispatcher.go:423`) and templated (`notify/templates.go:48`); `recovery_unit_capture_failed` is now a **record-only** event (`dispatcher.go:376`) whose notification IS the digest, listing every failed app in one mail; and a cooldown drop now writes a `suppressed` row instead of vanishing (`dispatcher.go:314-330`). The capability map already records the fixed shape (*"EVERY failing app, in ONE mail per run"*). **So the register is behind the code, which is the mirror of the decay this session was looking for** — the session expected stale PROOFS and found a stale DEFECT. **Not closed here, deliberately:** the digest's *delivery* has never been observed end to end (the page's own "an app crashes — the email leg has never been confirmed" card), so the honest move is to re-scope R-182 to that residue rather than tick it | **READY (XS) — NEW 2026-08-09** | — | Re-scope R-182 to "the digest has never been seen delivering", or close it and open that | CC |
| **R-290** | **Most capability-map rows that back a green dot cite no evidence document at all — measured, 20 of 28 probed.** The page's *Walked* means *"done end to end on real hardware, evidence on file"*. Extracting the evidence column for the 28 rows behind the page's claims found a `tests/` or `audits/` path in **8**; the other 20 carry prose only. **Consequence, applied this session:** of 32 claims the page drew as Walked, **12 were downgraded to Built** because no walk document exists for them — `install.installer-by-tag`, `use.lifecycle`, `drives.enrol`, `drives.migrate`, `backup.tier1`, `backup.whole-machine`, `backup.restore-proof`, `fault.selfheal`, `fault.operator-email`, `fail.drive-filling`, `fail.lost-recovery-code`, `fail.hub-down`. **This is not a claim that those twelve are false** — several are near-certainly fine — it is a claim that nothing on file distinguishes them from an opinion, which is exactly what the status word promises. **The gate now enforces it going forward:** `scripts/check_stands.py` fails on `status: walked` with no `evidence:` source. **What is owed:** either a walk document per row, or an honest demotion in the map itself (the map is the source; the dataset only follows it) | **READY (M) — NEW 2026-08-09** | R-288 | The dataset was corrected; **the capability map itself still says PROVEN-LIVE for these rows** and is the thing to fix | Viktor |
**Explicitly still open, untouched by this session:** R-246, R-255, R-256, R-257, R-261, R-262,
R-263, **R-264** (the twenty-one undecided facts — a design session of its own), R-240, R-243,
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check_stands.py — the gate on documentation/architecture/where-felhom-stands.yaml.
The page that dataset renders is the operator's picture of where the product stands. Its whole
value is that every claim on it is traceable, so this checks the traceability rather than the
claims: a claim nobody can trace is not a weaker claim, it is not a claim at all.
WHAT IT CONVICTS ON (each is a FAIL, exit 1):
1. an entry with NO source — §4 rule 1: that is a defect, not a claim
2. an `evidence:` path that does not resolve — a citation nobody opened
3. a `register:` id absent from OPEN-ITEMS.md — a dangling register reference
4. a `capability-map:` anchor not found — the row it derives from has moved or gone
5. status `walked` with no `evidence:` source — THE LOAD-BEARING ONE. "Walked" means done end
to end on real hardware with evidence on file. If no evidence document is cited, the page is
drawing a green dot from an opinion. This is the rule the positive control exercises: take a
claim the page marks `missing`, mark it `walked`, and this fires.
WHAT IT DOES NOT DO, said plainly so the green is not over-read: it does not read the evidence and
judge whether it supports the claim, and it cannot. A human verdict lives in each entry's
`verified:` block; this gate checks that the paperwork exists, not that the paperwork is right.
Run: python3 scripts/check_stands.py [path-to-yaml]
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT = os.path.join(ROOT, "documentation", "architecture", "where-felhom-stands.yaml")
REGISTER = os.path.join(ROOT, "documentation", "backlog", "OPEN-ITEMS.md")
CAPMAP = os.path.join(ROOT, "documentation", "architecture", "00-capability-map.md")
DOCS = os.path.join(ROOT, "documentation")
def load(path):
"""Minimal parser for the shape this file is written in.
Deliberately not PyYAML: the runner image and the gate hosts carry python3 and nothing else
(the same constraint that keeps `uses:` out of the CI workflow), and a gate that needs a pip
install is a gate that silently stops running.
"""
claims, cur, in_sources = [], None, False
for raw in open(path, encoding="utf-8"):
line = raw.rstrip("\n")
if line.startswith(" - id:"):
cur = {"id": line.split(":", 1)[1].strip(), "sources": []}
claims.append(cur)
in_sources = False
continue
if cur is None:
continue
if line.strip() == "sources:":
in_sources = True
continue
m = re.match(r'\s+- (capability-map|evidence|register): (.*)$', line)
if in_sources and m:
cur["sources"].append((m.group(1), m.group(2).strip().strip('"')))
continue
m = re.match(r'\s+(status|title|verdict|depth): (.*)$', line)
if m:
in_sources = False
cur[m.group(1)] = m.group(2).strip().strip('"')
return claims
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
claims = load(path)
register = open(REGISTER, encoding="utf-8").read()
# Normalise the map before matching: its row labels carry ** and ` markup, so a literal
# substring probe against the raw file fails on text that is plainly there. Matching the
# rendered words is what the citation means.
capmap = open(CAPMAP, encoding="utf-8").read()
capmap = re.sub(r"[`*]", "", capmap)
capmap = re.sub(r"\s+", " ", capmap).lower()
print("check_stands — %d claim(s) in %s" % (len(claims), os.path.relpath(path, ROOT)))
fails = []
for c in claims:
cid = c["id"]
if not c["sources"]:
fails.append("%s: NO SOURCE — an entry with no source is a defect, not a claim" % cid)
for kind, ref in c["sources"]:
if kind == "evidence":
if not os.path.exists(os.path.join(DOCS, ref)):
fails.append("%s: evidence path does not resolve: documentation/%s" % (cid, ref))
elif kind == "register":
if not re.search(r"\*\*%s\*\*" % re.escape(ref), register):
fails.append("%s: register id %s is not in OPEN-ITEMS.md" % (cid, ref))
elif kind == "capability-map":
probe = " ".join(ref.split()[:4])
probe = re.sub(r"[`*]", "", probe)
if probe and probe.lower() not in capmap:
fails.append("%s: capability-map anchor not found: %r" % (cid, probe))
if c.get("status") == "walked" and not any(k == "evidence" for k, _ in c["sources"]):
fails.append("%s: status 'walked' but NO evidence document cited — a green dot "
"drawn from an opinion" % cid)
counts = {}
for c in claims:
counts[c.get("status")] = counts.get(c.get("status"), 0) + 1
print(" statuses: " + ", ".join("%s=%d" % kv for kv in sorted(counts.items())))
if fails:
print("\nCONVICTED — %d problem(s):" % len(fails))
for f in fails:
print(" " + f)
return 1
print("\ncheck_stands: OK — every claim cites a source, every citation resolves, and every "
"'walked' cites a walk.")
return 0
if __name__ == "__main__":
sys.exit(main())
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""render_stands.py — generate where-felhom-stands.html from where-felhom-stands.yaml.
Run by hand, deliberately not wired into CI:
python3 scripts/check_stands.py && python3 scripts/render_stands.py
WHY THIS EXISTS. The first version of this page was a React bundle: one 154 KB artefact whose
content sat gzip+base64 inside a JS module map. It rendered, and that was all it could do — it
could not be edited, a diff of it showed nothing a person could read, and it could not be
regenerated when a status moved. So it began going stale the moment it was committed, which is the
one thing a picture of "where we stand" must not do.
This emits plain static HTML with inline CSS and NO JavaScript. It is bigger on disk than it needs
to be and that is the trade: the page is a build product of a file a person can read and diff.
The palette and layout deliberately match the 2026-08-09 snapshot — the operator should recognise
the page, not learn a new one.
"""
import html
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC = os.path.join(ROOT, "documentation", "architecture", "where-felhom-stands.yaml")
OUT = os.path.join(ROOT, "documentation", "architecture", "where-felhom-stands.html")
DOT = {"walked": "#34d399", "built": "#60a5fa", "partial": "#fbbf24", "missing": "#64748b"}
LEGEND = [
("walked", "Walked", "done end to end on real hardware, evidence on file"),
("built", "Built", "shipped and tested, the real path never walked"),
("partial", "Partial", "some walked, some not — the note says which"),
("missing", "Missing", "does not exist"),
]
STAGES = {1: "Getting a box", 2: "Making it theirs", 3: "Using it", 4: "Drives",
5: "Backing up", 6: "When something goes wrong", 7: "Getting everything back"}
def load(path):
claims, cur, sect = [], None, None
meta = {}
for raw in open(path, encoding="utf-8"):
line = raw.rstrip("\n")
if line.startswith("verified_on:"):
meta["date"] = line.split(":", 1)[1].strip()
if line.startswith(" - id:"):
cur = {"id": line.split(":", 1)[1].strip(), "sources": [], "changed": None}
claims.append(cur)
sect = None
continue
if cur is None:
continue
if line.strip() == "sources:":
sect = "sources"
continue
if line.strip() == "changed:":
sect = "changed"
cur["changed"] = {}
continue
if line.strip() == "verified:":
sect = "verified"
continue
m = re.match(r'\s+- (capability-map|evidence|register): (.*)$', line)
if sect == "sources" and m:
cur["sources"].append((m.group(1), m.group(2).strip().strip('"')))
continue
m = re.match(r'\s+(\w+): (.*)$', line)
if not m:
continue
k, v = m.group(1), m.group(2).strip().strip('"')
if sect == "changed":
cur["changed"][k] = v
elif sect == "verified":
cur.setdefault("v_" + k, v)
else:
cur.setdefault(k, v)
return meta, claims
def esc(t):
return html.escape(t, quote=False)
def dot(status):
return ('<span style="width:8px;height:8px;border-radius:50%%;background:%s;'
'flex:0 0 8px;margin-top:5px"></span>' % DOT.get(status, "#64748b"))
def claim_html(c):
bits = ['<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:9px">', dot(c.get("status"))]
body = ['<span style="font-size:12px;line-height:1.45;color:#b8c4d6;text-wrap:pretty">',
esc(c.get("title", ""))]
if c.get("changed"):
body.append('<span style="display:inline-block;margin-left:6px;padding:1px 5px;border-radius:3px;'
'background:#3a2a12;color:#fbbf24;font-size:10px;white-space:nowrap">changed 2026-08-09, was %s</span>'
% esc(c["changed"].get("from", "?")))
if c.get("v_verdict") in ("needs-hardware", "contested"):
col = "#a78bfa" if c["v_verdict"] == "contested" else "#7dd3fc"
body.append('<span style="display:inline-block;margin-left:6px;padding:1px 5px;border-radius:3px;'
'background:#1b2740;color:%s;font-size:10px;white-space:nowrap">%s</span>'
% (col, esc(c["v_verdict"])))
if c.get("note"):
body.append('<span style="display:block;color:#7c8aa3;font-size:11px;margin-top:3px">%s</span>'
% esc(c["note"]))
if c.get("changed", {}) and c["changed"].get("reason"):
body.append('<span style="display:block;color:#9a7b3a;font-size:11px;margin-top:2px">why it moved: %s</span>'
% esc(c["changed"]["reason"]))
srcs = " · ".join("%s %s" % (k, esc(v if len(v) < 62 else v[:59] + "")) for k, v in c["sources"])
body.append('<span style="display:block;color:#4b5b76;font-size:10px;margin-top:3px;'
'font-family:ui-monospace,SFMono-Regular,Menlo,monospace">%s</span>' % srcs)
body.append("</span>")
bits.append("".join(body))
bits.append("</div>")
return "".join(bits)
def panel(title, inner, sub=None):
h = ['<div style="background:#121b2c;border:1px solid #1f2c44;border-radius:6px;padding:14px 13px">']
h.append('<div style="font-size:14px;font-weight:500;padding-bottom:9px;margin-bottom:10px;'
'border-bottom:1px solid #1f2c44">%s</div>' % title)
if sub:
h.append('<div style="color:#6b7a91;font-size:11px;margin:-6px 0 10px">%s</div>' % esc(sub))
h.append(inner)
h.append("</div>")
return "".join(h)
def main():
meta, claims = load(SRC)
counts = {}
for c in claims:
counts[c.get("status")] = counts.get(c.get("status"), 0) + 1
changed = [c for c in claims if c.get("changed")]
o = []
o.append("<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">")
o.append('<meta name="viewport" content="width=device-width, initial-scale=1">')
o.append("<title>Where Felhom stands</title><style>")
o.append("@page{size:A3 landscape;margin:8mm}"
"*{margin:0;padding:0;box-sizing:border-box}"
"body{background:#0b1220;color:#e6edf7;font-family:-apple-system,BlinkMacSystemFont,"
"'Segoe UI',Roboto,sans-serif;-webkit-font-smoothing:antialiased;padding:26px 20px}"
".wrap{max-width:1600px;margin:0 auto}"
".grid7{display:grid;grid-template-columns:repeat(7,1fr);gap:12px;align-items:start}"
".grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;align-items:start}"
".grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;align-items:start}"
"h1{font-size:22px;font-weight:600;letter-spacing:-.2px}"
"a{color:#60a5fa}"
"@media print{body{padding:0}.grid7{grid-template-columns:repeat(7,1fr)}}")
o.append("</style></head><body><div class=\"wrap\">")
o.append("<h1>Where Felhom stands</h1>")
o.append('<div style="color:#8fa0b8;font-size:12.5px;margin:7px 0 4px;max-width:1100px;line-height:1.5">'
'What we built, what happens when things go wrong, and what is still missing. '
'<b style="color:#cbd7e8">Generated from '
'<code>where-felhom-stands.yaml</code></b>, which cites the capability map, register row or '
'evidence document behind every claim — and which is checked by '
'<code>scripts/check_stands.py</code>.</div>')
o.append('<div style="color:#6b7a91;font-size:11.5px;margin-bottom:16px">Verified %s against '
'felhom-agent <code>28ba8593b8</code>, felhom-controller <code>c732fe1283</code>, hub '
'<code>56f8aa611c</code>. <b style="color:#fbbf24">%d status(es) moved in that pass</b> — '
'each is marked on the page.</div>' % (meta.get("date", "?"), len(changed)))
leg = ['<div style="display:flex;gap:22px;flex-wrap:wrap;background:#121b2c;border:1px solid #1f2c44;'
'border-radius:6px;padding:11px 14px;margin-bottom:18px">']
for k, name, desc in LEGEND:
leg.append('<div style="display:flex;gap:8px;align-items:flex-start">%s'
'<span style="font-size:11.5px"><b>%s</b> <span style="color:#7c8aa3">(%d)</span>'
'<span style="display:block;color:#6b7a91;font-size:10.5px">%s</span></span></div>'
% (dot(k), name, counts.get(k, 0), esc(desc)))
leg.append('<div style="display:flex;gap:8px;align-items:flex-start"><span style="font-size:11.5px">'
'<b style="color:#7dd3fc">needs-hardware</b><span style="display:block;color:#6b7a91;'
'font-size:10.5px">only a running box could settle it</span></span></div>')
leg.append("</div>")
o.append("".join(leg))
o.append('<div style="font-size:15px;font-weight:600;margin:0 0 9px">The journey</div>')
o.append('<div style="color:#6b7a91;font-size:11.5px;margin-bottom:10px">Seven stages, left to '
'right, as they happen to a person.</div>')
o.append('<div class="grid7">')
for n in range(1, 8):
inner = "".join(claim_html(c) for c in claims
if c.get("band") == "journey" and c.get("stage") == str(n))
o.append(panel('<span style="color:#4b5b76;font-family:ui-monospace,monospace">%d</span> &nbsp;%s'
% (n, esc(STAGES[n])), inner))
o.append("</div>")
fails = [c for c in claims if c.get("band") == "failures"]
o.append('<div style="font-size:15px;font-weight:600;margin:22px 0 9px">When things go wrong</div>')
o.append('<div style="color:#6b7a91;font-size:11.5px;margin-bottom:10px">%d situations, and what '
'actually happens in each.</div>' % len(fails))
o.append('<div class="grid3">')
for c in fails:
o.append(panel(esc(c.get("title", "").split("")[0])[:74], claim_html(c)))
o.append("</div>")
o.append('<div style="margin-top:26px;padding-top:12px;border-top:1px solid #1f2c44;color:#4b5b76;'
'font-size:10.5px;line-height:1.6">'
'Generated by <code>scripts/render_stands.py</code> from <code>where-felhom-stands.yaml</code>. '
'Do not hand-edit this file — edit the data and regenerate. '
'The 2026-08-09 React bundle is kept as '
'<code>where-felhom-stands-2026-08-09-snapshot.html</code> and is not maintained.'
'</div>')
o.append("</div></body></html>")
open(OUT, "w", encoding="utf-8").write("\n".join(o))
print("wrote %s (%d bytes) — %d claims, %d changed"
% (os.path.relpath(OUT, ROOT), os.path.getsize(OUT), len(claims), len(changed)))
print(" statuses: " + ", ".join("%s=%d" % kv for kv in sorted(counts.items())))
return 0
if __name__ == "__main__":
sys.exit(main())