architecture: the system poster committed, its facts given a home, and a rule to keep them together
gates / gates (push) Failing after 5m29s
gates / gates (push) Failing after 5m29s
PART A -- the poster. documentation/architecture/felhom-system-poster.html
(307 KB). Secret scan first: ZERO IPv4, zero PEM blocks, zero ssh keys, zero
Bearer. The one EAA... match is base64 inside an embedded "mime":"font/woff2"
blob, not a Facebook token. "token"/"secret"/"password" appear 11 times and
every one is a NAME ("6. ep0 read token", "the hub seal key"); the poster
itself says "Names only; no secret values". All five long base64 blobs are
declared assets: 1 image/png, 3 text/javascript, 1 font/woff2.
It renders with NO network: the source mentions cdn.jsdelivr.net and Google
Fonts, but the loaded requests are only the HTML plus blob:/data: URLs -- the
bundler inlined everything. Measured, not assumed, and it matters: this is a
disaster-recovery document, so needing the internet to draw would be a defect.
No console errors.
The operator's three Claude Design fixes are all present: (a) no "WG" badge,
WireGuard only for the tunnel, no badge on the ep0-copy tile; (b) the box ->
ep0 arrow reads "encrypted on the box, sent through WireGuard"; (c) "Known
gaps" holds two items and NOT the household-keys sentence, which is now a
neutral "By design" note under the ep0 household namespace.
ONE FACT ON IT WAS WRONG. The felhom.eu tile said "served from DooPlex through
Cloudflare". It is not: Cloudflare is DNS only and the traffic goes direct --
measured this morning for the privacy notice, which states exactly that. The
poster would have contradicted a published page. Fixed in place (a label):
"served from DooPlex, Cloudflare DNS only". The first wording overflowed the
fixed-size tile, so it was shortened to fit and the evidence lives in the
facts file instead -- checked by re-rendering, not by hoping.
PART B -- the facts and the rule. DESIGN-PROMPT-...md is renamed
felhom-system-poster.facts.md (one home per fact), with the three fixes folded
in as explicit instructions so a regeneration cannot undo them, plus a new
"Badges" section saying a "WG" chip must never come back.
New rule, section 6 "The system poster stays true", added IDENTICALLY to all
five copies of unprompted-work.md (the four repos and the workspace root on
DooPlex; verified identical by diff before and after) and to
PROMPT-TEMPLATE.md's end-of-session checklist as a FIFTH coupled artifact.
scripts/poster_facts_gate.py WARNS when the facts file has a newer commit than
the poster. It never fails a push, deliberately: a refresh needs Claude Design
and the operator, --no-verify is forbidden here, so a blocking gate would leave
deleting it as the only way out. It compares COMMIT times, not mtimes, because
a checkout rewrites mtimes and every fresh clone would shout.
RED-PROOF -- and it found a real bug in the gate. The first run warned
correctly but exited 1: a single non-ASCII character in its own warning raised
UnicodeEncodeError on this cp1250 console. A gate whose entire contract is
"never fails a push" was failing pushes. Fixed (ASCII output + an encode
guard), and the decoy now asserts BOTH the warning and exit 0. Three branches
proven: facts newer -> warns, rc 0; poster newer -> quiet, rc 0; poster
missing -> "could not tell", rc 2, not a false all-clear.
The decoy itself was seen to fail, twice, on Linux (the suite needs fcntl and
cannot run on Windows): breaking the warning gives STALE_WARNS=False, and
making it exit 1 gives RC_STALE=1. All 80 felhom.eu decoys behave.
PART C -- do box reports pass through Cloudflare? NO. Two channels. DNS from
PUBLIC resolvers (not DooPlex's own, which answers the LAN address):
hub.felhom.eu is a CNAME to dooplex.hopto.org -> 37.191.56.193, not a
Cloudflare address, and no cf-ray comes back. The manifest: an ordinary k3s
Ingress, Cloudflare named only in a DNS setup comment. THE CONTROL that makes
the negative mean something: iso.felhom.eu resolves to 172.67.x / 104.21.x,
real Cloudflare addresses -- so the method does detect proxying.
So nothing is added to the Cloudflare row: the hub path does not touch it.
06-offsite-connectivity.md section 1 claimed the public edge is a
Cloudflare-Tunnel and "DooPlex has no public IP" -- both untrue today. Kept
and marked STALE with the measurement rather than rewritten, because that
paragraph is the reason ep0 exists and the argument needs its premise visible.
total-loss-of-dooplex.md's "today a CNAME to dooplex.hopto.org" is confirmed
correct.
Register: 137 before, 137 after, 0 opened, 0 closed -- every finding here was
small and fixed in the session.
This commit is contained in:
@@ -73,3 +73,19 @@ that no longer exists. Each edit is named in the report (file, line, before, aft
|
||||
operator's word: loosen a safety rule, a fence, a „never", a protected machine, a secret rule, or a review step; or
|
||||
remove a rule. When in doubt, it is a rule change, and it goes to the operator. If Claude Code's own permission check
|
||||
asks before such an edit, wait for the operator's click; if it refuses, record that and file the exact line.
|
||||
|
||||
## 6. The system poster stays true
|
||||
|
||||
**A session that changes a fact listed in `architecture/felhom-system-poster.facts.md` (a machine, a
|
||||
role, a traffic path, a backup tier, a time, a retention, a key, a known gap) updates that file in
|
||||
the same commit.** If the change is text only, it also edits the matching text in
|
||||
`felhom-system-poster.html`. If it needs a new drawing, it adds the line
|
||||
**"System poster needs a refresh: <what changed>"** to `STATUS.md`'s "waiting on the operator" list.
|
||||
**The session report names which of the three it did.**
|
||||
|
||||
Why the three-way split: the poster is a drawing made in Claude Design, and **nothing in this
|
||||
repository renders it** — unlike `where-felhom-stands.html`, which has `render_stands.py`. So the
|
||||
facts file is the source of truth and the drawing trails it. `scripts/poster_facts_gate.py` warns
|
||||
when the facts file has a newer commit than the poster; it **never fails a push**, because a refresh
|
||||
needs the operator and another tool, and a gate nobody can clear is a gate people learn to route
|
||||
around.
|
||||
|
||||
@@ -5,6 +5,28 @@
|
||||
**Updated 2026-10-09 (afternoon): hub 0.144.0; demo-hp, demo-felhom and Tester 1 run agent 0.154.0 and controller
|
||||
0.304.0. The open-items list is at 138. Reports: `REPORT-break-the-circle-2026-10-09.md`, `REPORT-dooplex-survival-2026-10-09.md`, `REPORT-day4-2026-10-09.md`.**
|
||||
|
||||
## Evening (2026-10-09): the system poster is in the repository, and staying true is now a rule
|
||||
|
||||
- **The poster you made is committed**, next to the architecture documents. I checked it for secrets first:
|
||||
**no addresses, no keys, no passwords** — the only "password" words on it are names of things ("the password
|
||||
manager", "the hub seal key"), and the one string that looked like a token turned out to be ordinary data
|
||||
inside the embedded font. It opens **with no internet at all**: the fonts and scripts are built into the file.
|
||||
- **Your three Claude Design fixes are all in it** — the backup tier no longer carries a "WG" tag, the box-to-ep0
|
||||
arrow says "encrypted on the box, sent through WireGuard", and the household-keys sentence is a neutral note
|
||||
rather than a red "gap" box.
|
||||
- **One thing on it was wrong and I corrected it.** The poster said the website is "served from DooPlex through
|
||||
Cloudflare". It is not: Cloudflare only answers the name, the traffic goes straight to your home connection.
|
||||
I measured it, and the privacy notice published this morning already says so — the poster would have
|
||||
contradicted the website.
|
||||
- **The facts the poster was drawn from now live beside it** as a plain-text list, and there is a new rule: a
|
||||
session that changes one of those facts must update the list in the same commit, fix the poster's text if the
|
||||
change is small, or tell you here that the poster needs redrawing. A check warns when the list is newer than
|
||||
the drawing — **it never blocks anything**, because only you can redraw it.
|
||||
- **Nothing needs redrawing right now.** My one correction was a text label, done in place.
|
||||
- Also answered, an open question from the poster: **the boxes' reports do not go through Cloudflare.**
|
||||
`hub.felhom.eu` points straight at your home connection. One architecture document still said the opposite
|
||||
(it was written in July, before you had a public address) and now carries a dated correction.
|
||||
|
||||
## Afternoon (2026-10-09): the password manager is copied off-site; one page to print
|
||||
|
||||
- **Your password manager (Vaultwarden) now goes to ep0 every night**, with the code. You said yes. A test brought it
|
||||
|
||||
@@ -366,6 +366,15 @@ Decisions made, architectural state, what's next.
|
||||
`felhom.eu/documentation/{architecture,controller}/`. Spikes/audits → `felhom.eu/documentation/audits/`.
|
||||
|
||||
### N.5 The coupling rule — FOUR artifacts, same session (end-of-session checklist)
|
||||
|
||||
> **FIVE, when a poster fact moved.** **The system poster stays true** — a task that changes a fact
|
||||
> listed in `architecture/felhom-system-poster.facts.md` (a machine, a role, a traffic path, a backup
|
||||
> tier, a time, a retention, a key, a known gap) updates **that file in the same commit**. If the
|
||||
> change is text only, it also edits the matching text in `felhom-system-poster.html`. If it needs a
|
||||
> new drawing, it adds **"System poster needs a refresh: <what changed>"** to `STATUS.md`'s
|
||||
> "waiting on the operator" list. **The report names which of the three it did.** The poster is drawn
|
||||
> in Claude Design and nothing here renders it, so `scripts/poster_facts_gate.py` only WARNS when the
|
||||
> facts outrun the drawing — it never fails a push. Same wording in `.claude/rules/unprompted-work.md` §6.
|
||||
If the task **changed what the platform can do** (new/removed capability, a capability moved status,
|
||||
or **what is open changed**), update **all four** in the SAME session:
|
||||
- **`architecture/00-capability-map.md`** — add/adjust the affected *scenario* row with the correct
|
||||
|
||||
@@ -23,6 +23,11 @@ The operator's one-page picture of what is proven, built, partial and missing.
|
||||
- [`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
|
||||
|
||||
### The whole system on one page — `architecture/felhom-system-poster.*`
|
||||
A poster for the operator: every machine, path, backup tier, time and key on a single sheet.
|
||||
- [`architecture/felhom-system-poster.html`](architecture/felhom-system-poster.html) — the drawing. **Made in Claude Design, NOT generated by anything in this repo**, so do not expect a renderer; it is self-contained (fonts and scripts are inlined — it opens with no network)
|
||||
- [`architecture/felhom-system-poster.facts.md`](architecture/felhom-system-poster.facts.md) — **the source of every fact on it. Change the facts here first.** Rule: `.claude/rules/unprompted-work.md` §6. Gate: `scripts/poster_facts_gate.py` WARNS (never fails) when this file has a newer commit than the drawing
|
||||
|
||||
### 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
|
||||
|
||||
@@ -38,6 +38,27 @@ customer box cannot reach it at all. The DR crypto/identity side is done and dri
|
||||
(`SPIKE-dr-recipe-2026-06-16.md`); **this transport is the last missing piece**, and the spike
|
||||
proved the cheapest transport suffices.
|
||||
|
||||
> **STALE AS OF 2026-10-09 — the two transport facts in the paragraph above are no longer true, and
|
||||
> the gap they describe is closed.** Kept rather than rewritten, because the paragraph is the reason
|
||||
> ep0 exists and the reasoning only makes sense with its premise visible.
|
||||
>
|
||||
> **MEASURED 2026-10-09, two channels.** (1) DNS, from public resolvers (`@1.1.1.1` and `@8.8.8.8`,
|
||||
> **not** DooPlex's own, which answers the LAN address): `hub.felhom.eu` is a **CNAME to
|
||||
> `dooplex.hopto.org` → 37.191.56.193**, which is **not** a Cloudflare address, and
|
||||
> `curl -sI https://hub.felhom.eu/` returns **no `cf-ray`** header. (2) The manifest: the hub is an
|
||||
> ordinary k3s Ingress and `manifests/hub.yaml` names Cloudflare only in a setup comment about
|
||||
> pointing DNS at the cluster. **The control that makes this mean something:** `iso.felhom.eu`
|
||||
> resolves to `172.67.183.101` / `104.21.18.218` — real Cloudflare addresses — so the method does
|
||||
> detect proxying when it is there.
|
||||
>
|
||||
> So: **DooPlex is reachable on a public address today** (a dynamic-DNS name, No-IP `dooplex.hopto.org`,
|
||||
> S13 in `runbooks/total-loss-of-dooplex.md`), and **the hub's public edge is NOT a Cloudflare
|
||||
> Tunnel** — boxes reporting to `hub.felhom.eu` reach the home connection directly. Cloudflare is
|
||||
> DNS for that name and nothing more. What Cloudflare *does* carry is the households' **app** traffic
|
||||
> through their tunnels, which is a different path and is the one the system poster's "Known gaps"
|
||||
> box is about. `runbooks/total-loss-of-dooplex.md` §9's "today a CNAME to `dooplex.hopto.org`" is
|
||||
> correct and was confirmed by this measurement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions (settled — recorded, not re-litigated)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# felhom-system-poster.facts.md — the source of every fact on the poster
|
||||
|
||||
> **This file is the source of every fact on `felhom-system-poster.html`. Change the facts here
|
||||
> first.** The poster is a drawing made from this list in Claude Design; it is not generated by a
|
||||
> script in this repository, so nothing but discipline keeps the two together — which is why
|
||||
> `scripts/poster_facts_gate.py` warns when this file has a commit newer than the poster, and why
|
||||
> the shared rule file carries **"The system poster stays true."**
|
||||
>
|
||||
> **How to use it.** A session that changes a fact listed here (a machine, a role, a traffic path, a
|
||||
> backup tier, a time, a retention, a key, a known gap) edits THIS file in the same commit. If the
|
||||
> change is text only, it also edits the matching text in the poster. If it needs a new drawing, it
|
||||
> adds a line to STATUS's "waiting on the operator" list and the operator regenerates the poster in
|
||||
> Claude Design from this file. The session report says which of the three it did.
|
||||
>
|
||||
> **History.** Written 2026-10-09 as `DESIGN-PROMPT-infrastructure-infographic-2026-10-09.md` and
|
||||
> renamed here the same day, with the operator's three Claude Design fixes folded in (marked *fix
|
||||
> (a)/(b)/(c)* below) and one measured correction: felhom.eu is served from DooPlex with
|
||||
> **Cloudflare DNS only**, not through Cloudflare.
|
||||
|
||||
---
|
||||
|
||||
## # Claude Design prompt — "Felhom: the whole system on one page" (state of 2026-10-09)
|
||||
|
||||
> Paste everything below the line into Claude Design. Facts were read from the live repository on 2026-10-09
|
||||
> (`01-topology-and-trust.md`, `06-offsite-connectivity.md`, `07-backup-architecture.md` §6.1, `11-os-updates.md`,
|
||||
> `runbooks/total-loss-of-dooplex.md`, `runbooks/ep0-datastore-copy.md`, `audits/dooplex-survival-2026-10-09/PLAN.md`).
|
||||
> No secret, key value, password, token or IP address is in this prompt, and none may appear on the poster.
|
||||
|
||||
---
|
||||
|
||||
## What to make
|
||||
|
||||
One **large poster-style infographic** (landscape, roughly A1 / 1600×1100 px or larger) that shows the whole Felhom system at a glance: every machine and its role, one example customer box opened up, how traffic flows, **what is backed up where, when, and with which key**, and what survives if the operator's home server is lost. It is for the operator (one person) who has lost track of what is backed up where. Clarity beats decoration. Every label must be readable when printed on A2.
|
||||
|
||||
**Style:** calm, technical, light background, a dark-mode-friendly palette is not needed. Use one colour per *kind of thing*, and keep it the same everywhere:
|
||||
- **Green** = household data (apps, files, photos).
|
||||
- **Blue** = backup copies (each tier its own shade of blue, numbered).
|
||||
- **Orange** = operator infrastructure (code, hub, password manager, signing keys).
|
||||
- **Purple** = keys and secrets (shown as key icons with *names only*).
|
||||
- **Grey** = outside services (Cloudflare, Hetzner, mail service).
|
||||
- **Red** outline = a single point of failure or a known gap.
|
||||
Arrows: solid = data traffic; dashed = backup copy; dotted = control (reports, jobs). Every arrow gets a short label and, for backups, a time.
|
||||
|
||||
**Layout (left to right, three zones, plus two strips):**
|
||||
1. **Left zone — "The household's home":** one example customer box, opened up.
|
||||
2. **Centre zone — "The internet":** Cloudflare, Hetzner (ep0 and the Storage Box), the mail service.
|
||||
3. **Right zone — "The operator's home":** DooPlex and the test machines.
|
||||
4. **Bottom strip — "One night, hour by hour"** (a timeline 00:00 → 09:00).
|
||||
5. **Right-hand column or bottom-right box — "If DooPlex is lost"** and **"Keys that must exist on paper"**.
|
||||
A small legend in a corner.
|
||||
|
||||
---
|
||||
|
||||
## Zone 1 — the customer box (draw one, opened up like a cutaway)
|
||||
|
||||
Title: **"A Felhom box (example: one household)"**. A small PC running **Proxmox** (the host operating system).
|
||||
|
||||
Inside, from bottom to top:
|
||||
- **The Proxmox host.** Runs the **host agent** (Felhom's operator-tier program: it holds the only Proxmox key, does backups and restores of the whole guest, OS updates, drive management). The agent never accepts a connection from outside; it **polls the hub** for signed jobs.
|
||||
- **The customer guest** (one Linux container per household, ID 9201). Inside it, **Docker**, running:
|
||||
- the **controller** (the household's dashboard and the app manager; it has no Proxmox key),
|
||||
- **Traefik** (routes each web address to its app),
|
||||
- **cloudflared** (the tunnel to Cloudflare, so no router setup is needed),
|
||||
- **the apps** — draw 4–6 small app tiles labelled with real catalog names, e.g. Nextcloud, Immich, Paperless-ngx, Vaultwarden, Jellyfin, Mealie, and a "+50 more" tile (the catalog has 56 apps).
|
||||
- **The drives:**
|
||||
- the **system disk** (Proxmox + the guest),
|
||||
- **data drive 1** (household data and the **Tier-1** backup folder),
|
||||
- an optional **data drive 2** (the **Tier-2** copy).
|
||||
Note on the drawing: the data drives are attached to the guest from the host; the **whole-guest backups do not include the data drives** (that is what the per-app tiers are for).
|
||||
|
||||
Arrows out of the box:
|
||||
- **Household's phone / laptop → Cloudflare → cloudflared → Traefik → app** (solid, green). Label: "Remote access. HTTPS ends at Cloudflare's edge, so Cloudflare could technically see this traffic (later item: our own relay)." Also a short local arrow: "At home on the LAN: direct to Traefik."
|
||||
- **Box → hub** (dotted): "Reports every few minutes; asks for signed jobs. The hub never connects into a box."
|
||||
- **Box → ep0** (dashed, through a small padlock tunnel labelled **WireGuard**): "Whole-guest backup, encrypted on the box, sent through WireGuard." — *fix (b), 2026-10-09: the label must name BOTH, because "encrypted on the box" alone read as if WireGuard were doing the encrypting.*
|
||||
- **Box → Hetzner Storage Box** (dashed): "Per-app off-site backup (restic), encrypted on the box. The box's key can only ADD copies; old copies are removed only in a weekly clean-up window the hub opens."
|
||||
- **Box → mail service** is not needed; the hub mails households.
|
||||
|
||||
## The backup tiers of a customer box (a table or stacked bars next to Zone 1)
|
||||
|
||||
Title: **"Where a household's data is copied"**
|
||||
|
||||
| Tier | What it copies | Where | When | Kept | Locked by |
|
||||
|---|---|---|---|---|---|
|
||||
| **Tier 1 — recovery unit** | each app: its settings, database dump, volumes | data drive 1, on the box | nightly at **02:30** (W) | 1 point per app | not encrypted (it never leaves the home) |
|
||||
| **Tier 2 — cross-drive** | a mirror of Tier 1, plus the app's files | data drive 2, on the box (only if the household has 2 drives) | nightly **03:30** | mirror | not encrypted |
|
||||
| **Tier 3 — off-site, per app** | Tier 1 + the app's must-keep files | **Hetzner Storage Box** (Germany), one folder per household | nightly **04:15** | 7 daily, 4 weekly, 6 monthly | the household's own restic password |
|
||||
| **Whole guest — local** | the guest's system (not the data drives) | the box's own system disk | daily, in the window **04:30–08:30** | up to 3 copies (fewer on a small disk) | not encrypted |
|
||||
| **Whole guest — off-site** | the same | **ep0** (Hetzner, Germany), one namespace per household | weekly, same window | last 2 | the household's own key, escrowed with a recovery code |
|
||||
|
||||
Footnote: "W = the household's backup window start, 02:30 by default. Tiers 1–3 follow at fixed offsets so they cannot run out of order. A missed night is caught up 15 minutes after the box starts."
|
||||
|
||||
## Zone 2 — the internet
|
||||
|
||||
- **Cloudflare** (grey): DNS for the customer domains and for felhom.eu; the **tunnels**; the **geo-block** (only Hungarian visitors); flood protection. The hub holds a Cloudflare key per customer zone and checks that it reaches only that customer's own domain.
|
||||
- **ep0** (grey box with an orange stripe, a Hetzner cloud server in Germany): runs the **WireGuard endpoint** and the **off-site backup server (PBS)**. Inside, one datastore with **namespaces**:
|
||||
- one namespace **per household** (whole-guest copies, each household's own key),
|
||||
with a NEUTRAL note under it (not a red gap box): "By design: each household holds the key to its own copies; the operator cannot open them without their recovery code." — *fix (c), 2026-10-09: this is a property of the design, not a gap, and a red box said the opposite of what it means.*
|
||||
- the **`operator`** namespace: two groups — **"hub database"** and **"DooPlex copy"** (code, password manager, secrets, signing keys).
|
||||
Prune jobs on ep0: households keep the last 2; `operator` keeps 14 daily + 8 weekly.
|
||||
Mark ep0 with a small shield: "**Protected** — holds the only off-premises copy of real household data."
|
||||
- **Hetzner Storage Box** (grey): the shared box for the households' per-app restic copies (Tier 3), one sub-account per household, add-only keys.
|
||||
- **Mail service** (grey, "Resend"): every mail the hub sends — to the operator and to households.
|
||||
- **The public website** felhom.eu (served from DooPlex — Cloudflare DNS only, the traffic does not pass through it; MEASURED 2026-10-09: the public A record is the home address, not a Cloudflare one, and no `cf-ray` header comes back) and **iso.felhom.eu** (where households download the installer): draw them as two small web pages near Cloudflare. Do not draw where iso.felhom.eu is hosted.
|
||||
|
||||
## Zone 3 — the operator's home
|
||||
|
||||
**DooPlex** (big orange box, the home server; mark it "**Protected — everything else can be rebuilt from it**"). Inside it, a small Kubernetes cluster (k3s) and host services. Show these parts as tiles:
|
||||
- **Gitea** — all the code (10 repositories) and the **container registry** (the images the boxes run). The CI runner runs here too.
|
||||
- **The hub** — customer records, box reports, the job queue, alarms, the escrow of household keys, the System page (OS updates, kernel approvals).
|
||||
- **Vaultwarden** — the operator's password manager.
|
||||
- **Prometheus + Alertmanager** — the operator's alarms.
|
||||
- **The signing keys** — they sign every agent update and config bundle a box accepts.
|
||||
- **The website** (felhom.eu) and the contact form's mailer.
|
||||
- **Local backups** — several restic sets on DooPlex's own disks (red outline: "all on the same machine").
|
||||
- **ep0-copy** — a nightly pull of the whole ep0 datastore back to DooPlex (ciphertext only; DooPlex cannot read households' copies).
|
||||
|
||||
Arrows from DooPlex (dashed, orange → ep0):
|
||||
- **00:20 "DooPlex copy"**: the code (614 MB), Gitea's database and settings, the **password manager**, the nightly secrets export, the **signing keys**. Encrypted on DooPlex with the **DooPlex off-site key**. The key that writes it **cannot delete** old copies. Tested back every Sunday.
|
||||
- **02:30 "Hub database"**: encrypted with the **hub-DB key**. Tested back every Sunday at 04:30.
|
||||
- **Left out on purpose:** the container registry (27.7 GB) — "rebuilt from the code".
|
||||
Arrow ep0 → DooPlex (dashed, 05:00): "**ep0-copy** pull: a second copy of ep0 at home. Keeps 8 weekly copies. A daily job removes a deleted customer's copy within 30 days."
|
||||
|
||||
**The test machines** (small, plain tiles near DooPlex, labelled "disposable"):
|
||||
- **demo-hp** (a mini PC) — ring 0: it gets OS updates, Docker and kernels first. Also hosts the **scratch guest 9202** (a second guest with no hub link, for tests) and the **Tester 1** virtual machine (a whole Felhom box inside a VM).
|
||||
- **demo-felhom** (an Intel N100 mini PC) — ring 0.
|
||||
- **The test bench** — a throwaway guest where app updates are tested before any box gets them.
|
||||
- **Tester 2** — the first outside tester's laptop, at his own home. Draw it in Zone 1's colour family, as a second, smaller customer box, with a note "real household, often off".
|
||||
|
||||
## Control and updates (a small panel, dotted arrows)
|
||||
|
||||
Title: **"Who tells a box what to do"**
|
||||
- The **hub** queues jobs; the operator **signs** each one; the **agent** checks the signature before it acts.
|
||||
- **Updates, in rings:** ring 0 (demo boxes) first → the operator approves on the System page → ring 1 (customers).
|
||||
- **Apps:** each new app version is tested on the bench and the scratch box first; boxes update at night, after a fresh backup.
|
||||
- **Debian security fixes:** nightly, after the whole-guest backup.
|
||||
- **Proxmox programs, Docker, the kernel:** slow lanes, one approved set at a time.
|
||||
- **Kernel:** the household gets a mail the day before; the box restarts at night; a crash falls back to the old kernel by itself; a freeze needs someone to unplug the box.
|
||||
- **Hub buttons** (no signature needed, a fixed safe list): off-site backup now, run a check now, stop or extend a deletion countdown.
|
||||
|
||||
## Bottom strip — "One night, hour by hour"
|
||||
|
||||
A horizontal timeline from **00:00 to 09:00**, two rows:
|
||||
- **DooPlex row:** 00:00 database dumps · **00:20 DooPlex copy → ep0** · **02:30 hub database → ep0** · 03:30 ep0 prunes households · 03:45 ep0 prunes `operator` · **05:00 ep0-copy pull → DooPlex** · 07:30 ep0-copy prune · 08:00 deleted-customer clean-up. Sundays: 04:30 restore tests.
|
||||
- **Customer box row:** **02:30 Tier 1** · **03:30 Tier 2** · **04:15 Tier 3 off-site** → app updates → **04:30–08:30 whole-guest backup** (local daily, off-site weekly) → Debian fixes → (when approved) Proxmox, Docker, kernel restart. 05:00 the hub checks every box made its backup.
|
||||
- A small note: "Household mails about a kernel restart go out the day before, 09:00–20:00."
|
||||
|
||||
## Right column — "If DooPlex is lost"
|
||||
|
||||
Two lists side by side:
|
||||
- **Survives (off DooPlex):** ep0 (the DooPlex copy and the hub database, plus every household's whole-guest copies); the Hetzner Storage Box (households' per-app copies); the boxes themselves (they keep running and backing up, but cannot report or update until the hub is back); the operator's workstation.
|
||||
- **Lost and not in any copy:** the container registry (rebuild from code), DooPlex's own SSH key, local build files, Prometheus history, other homelab apps.
|
||||
|
||||
A short numbered path: "1 New machine → 2 Reach ep0 → 3 Open the DooPlex copy with the **paper key** → 4 Password manager first → 5 Gitea → rebuild images → 6 Hub (mail held) → 7 Point DNS at the new home → boxes reconnect by themselves."
|
||||
|
||||
## Right column, bottom — "Keys that must exist on paper, away from home" (purple)
|
||||
|
||||
Show as a row of key icons with **names only** (never values):
|
||||
1. **DooPlex off-site key** — opens the DooPlex copy (the key to everything else).
|
||||
2. **Hub-DB off-site key** — opens the hub database copy.
|
||||
3. **Hub seal key** — opens the console passwords inside the hub.
|
||||
4. **DooPlex backup passphrase** — opens the secrets export.
|
||||
5. **Signing keys** (recovery + operational) — let the operator update boxes again.
|
||||
6. **ep0 read token** — reads the copies.
|
||||
7. **Hetzner login** — reaches ep0 if DooPlex is gone.
|
||||
8. **Cloudflare login** — moves DNS to the new home.
|
||||
Plus one in a "head" icon: **the password-manager master password** (memorised, never written on the sheet).
|
||||
Caption in red: "**A key kept only in the password manager is not off DooPlex** — the password manager runs on DooPlex."
|
||||
|
||||
## Badges — what may and may not carry one
|
||||
|
||||
*fix (a), 2026-10-09.* **"WG" is not a badge.** The whole-guest backup tier carries no "WG" tag;
|
||||
the word **WireGuard** appears only where the WireGuard tunnel itself is meant (the box → ep0
|
||||
padlock, and ep0's own "WireGuard endpoint" role). **The ep0-copy tile carries no badge at all.**
|
||||
A regeneration that puts a "WG" chip back on a backup tier has reintroduced the error.
|
||||
|
||||
## Small boxes for known gaps (red outlines, one line each)
|
||||
|
||||
- Cloudflare can read remote-access traffic (HTTPS ends at its edge) — a later item: our own relay.
|
||||
- DooPlex's local backups all sit on one machine; only the DooPlex copy and the hub database leave it.
|
||||
|
||||
## Header and footer
|
||||
|
||||
- **Header:** "Felhom — the whole system on one page" and "State of 9 October 2026".
|
||||
- **Footer:** "Sources: architecture documents 01, 06, 07, 11 and the total-loss runbook in the felhom.eu repository. Names only; no secret values."
|
||||
|
||||
**Do not invent** anything that is not above (no extra servers, clouds or services). If something does not fit, drop detail from the app tiles first, never from the backup table, the timeline or the key list.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""poster_facts_gate.py — warn when the system poster is older than the facts it was drawn from.
|
||||
|
||||
Usage: python3 scripts/poster_facts_gate.py [<repo-root>]
|
||||
Exit: ALWAYS 0 when it can read git. 2 only when it cannot tell (no git, file missing).
|
||||
|
||||
WHY THIS EXISTS, AND WHY IT ONLY WARNS.
|
||||
|
||||
`documentation/architecture/felhom-system-poster.html` is a drawing made in Claude Design from
|
||||
`felhom-system-poster.facts.md`. **No script in this repository renders it**, which makes it unlike
|
||||
`where-felhom-stands.html` (that one has `render_stands.py`). Nothing mechanical keeps the drawing
|
||||
and its facts together, and `render_stands.py`'s own docstring already names the failure mode this
|
||||
project has lived with: a build product "began going stale the moment it was committed".
|
||||
|
||||
So the facts file is the source of truth and the poster trails it. When a session changes a fact,
|
||||
the rule ("The system poster stays true", in the shared rule file) says to edit the facts file in
|
||||
the same commit, and either fix the poster's text or ask the operator for a new drawing. This gate
|
||||
is the instrument that makes a skipped refresh VISIBLE.
|
||||
|
||||
**It must never fail a push**, and that is a deliberate choice rather than timidity: regenerating
|
||||
the poster needs Claude Design and the operator, so a failing gate would block every unrelated push
|
||||
until a human with another tool was available. A gate nobody can clear is a gate people learn to
|
||||
bypass — and `--no-verify` is forbidden here, so the only remaining move would be to delete the
|
||||
gate. A warning that shows up in STATUS costs nothing and keeps the fact visible.
|
||||
|
||||
HOW IT DECIDES. Commit time of the last commit touching each file (`git log -1 --format=%ct`), not
|
||||
mtime: a checkout rewrites mtimes and would make every fresh clone shout. A file not yet committed
|
||||
is treated as "no commit", and the gate says so instead of guessing.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DEFAULT_ROOT = os.path.dirname(HERE)
|
||||
ARCH = os.path.join("documentation", "architecture")
|
||||
FACTS = os.path.join(ARCH, "felhom-system-poster.facts.md")
|
||||
POSTER = os.path.join(ARCH, "felhom-system-poster.html")
|
||||
|
||||
|
||||
def last_commit_epoch(root, rel):
|
||||
"""Epoch seconds of the last commit touching `rel`, or None if it has never been committed."""
|
||||
try:
|
||||
out = subprocess.run(["git", "-C", root, "log", "-1", "--format=%ct", "--", rel],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
raise RuntimeError("git is not usable here: %s" % e)
|
||||
if out.returncode != 0:
|
||||
raise RuntimeError("git log failed for %s: %s" % (rel, (out.stderr or "").strip()[:200]))
|
||||
s = (out.stdout or "").strip()
|
||||
return int(s) if s else None
|
||||
|
||||
|
||||
def check(root):
|
||||
"""Return (code, lines). code 0 = said something or nothing to say; 2 = could not tell."""
|
||||
lines = []
|
||||
for rel in (FACTS, POSTER):
|
||||
if not os.path.isfile(os.path.join(root, rel)):
|
||||
return 2, ["poster-facts: %s is missing - not checked" % rel]
|
||||
try:
|
||||
f_at = last_commit_epoch(root, FACTS)
|
||||
p_at = last_commit_epoch(root, POSTER)
|
||||
except RuntimeError as e:
|
||||
return 2, ["poster-facts: %s - not checked" % e]
|
||||
|
||||
if f_at is None or p_at is None:
|
||||
which = " and ".join(n for n, v in ((FACTS, f_at), (POSTER, p_at)) if v is None)
|
||||
lines.append("poster-facts: not committed yet (%s) - nothing to compare" % which)
|
||||
return 0, lines
|
||||
|
||||
if f_at > p_at:
|
||||
days = (f_at - p_at) / 86400.0
|
||||
lines.append(" WARNING: System poster is older than its facts - the facts file was committed "
|
||||
"%.1f day(s) after the poster." % days)
|
||||
lines.append(" The poster is a drawing; regenerate it in Claude Design from %s," % FACTS)
|
||||
lines.append(" or, if the change was text only, edit the matching text in the poster.")
|
||||
lines.append(" This is a WARNING on purpose: it never fails a push.")
|
||||
else:
|
||||
lines.append(" poster is current: the drawing is as new as its facts "
|
||||
"(poster %+d s relative to facts)" % (p_at - f_at))
|
||||
return 0, lines
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
root = argv[0] if argv else DEFAULT_ROOT
|
||||
code, lines = check(root)
|
||||
out = ["poster-facts gate - %s" % ("could not tell" if code == 2 else
|
||||
"advisory, never fails a push")] + lines
|
||||
for l in out:
|
||||
try:
|
||||
print(l)
|
||||
except UnicodeEncodeError:
|
||||
# A gate that must never fail a push must not fail on its own console either. Windows
|
||||
# consoles default to cp1250 here; this was found by the red-proof, where a single
|
||||
# non-ASCII character in the warning turned exit 0 into exit 1.
|
||||
print(l.encode("ascii", "replace").decode("ascii"))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -30,6 +30,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
|
||||
15b. iso-bootstrap the ISO first-boot harness in felhom-iso-assistant:trixie, with a built-in
|
||||
decoy — FULL RUNS ONLY, "not checked" (exit 2) without docker (R-502, decision 147)
|
||||
16. decoy-coverage every registered gate in all four repos has a decoy, or a named exemption (R-421)
|
||||
17. poster-facts WARNS (never fails) when felhom-system-poster.facts.md is newer than the poster
|
||||
|
||||
**THE `GATES` TABLE BELOW IS THE LIST; THIS IS A POINTER TO IT.** It drifted once already —
|
||||
it read eleven while thirteen were registered, from 2026-08-24 until 2026-09-01, so
|
||||
@@ -172,6 +173,13 @@ GATES = [
|
||||
# R-421 — every registered gate across all four repos ships with a decoy test, or is
|
||||
# named in the exemption list with its row. Registered LAST, after every runner was green:
|
||||
# a failing gate refuses every push, which is what instructions_gate learned the hard way.
|
||||
# The system poster is a DRAWING made in Claude Design from felhom-system-poster.facts.md;
|
||||
# nothing in this repo renders it, so nothing mechanical keeps the two together. This gate warns
|
||||
# when the facts file has a newer commit than the poster. It NEVER fails a push, on purpose: a
|
||||
# refresh needs Claude Design and the operator, so a blocking gate would stop unrelated work
|
||||
# until a human with another tool was free -- and `--no-verify` is forbidden here, so the only
|
||||
# way out would be deleting the gate. Fast: two `git log -1` calls.
|
||||
("poster-facts", os.path.join(SCRIPTS, "poster_facts_gate.py"), [ROOT], True, False),
|
||||
("decoy-coverage", os.path.join(SCRIPTS, "decoy_coverage_gate.py"),
|
||||
[ROOT, os.path.join(os.path.dirname(ROOT), "felhom-controller"),
|
||||
os.path.join(os.path.dirname(ROOT), "felhom-agent"),
|
||||
|
||||
@@ -69,6 +69,7 @@ COVERS = {
|
||||
"(2026-10-03) an old-shape row under the new header, a near-miss category, an "
|
||||
"old rank tag as Sev, an undefined state word, and a pipe outside backticks"),
|
||||
"decoy-coverage": "a gate registered in a runner with no decoy and no exemption (its red-proof)",
|
||||
"poster-facts": ("a throwaway repo where the FACTS file is committed after the poster: the gate must SAY SO; the reverse order must stay quiet; and both must exit 0, because this gate's whole contract is that it never fails a push -- the first red-proof caught it exiting 1 on a cp1250 console over one non-ASCII character in its own warning"),
|
||||
"iso-bootstrap": ("R-502: SIX decoys + the genuine article in scripts/test_iso_bootstrap_gate.py, run from here, docker-free (a "
|
||||
"fake docker on a one-directory PATH): a BLIND harness that passes a bootstrap whose "
|
||||
"pairing banner never paints (the R-496 shape), a failing harness, the pass line with no "
|
||||
@@ -911,6 +912,70 @@ decoy("stands/walked-no-walk", "check_stands.py", plant_file(_STANDS, _stand("wa
|
||||
decoy("stands/closed-row-ok", "check_stands.py", plant_file(_STANDS, _stand("built", "R-273")), args=(_STANDS,),
|
||||
expect="pass")
|
||||
|
||||
# ── poster-facts ────────────────────────────────────────────────────────────────────────────────
|
||||
# The gate compares COMMIT times, so its decoy needs a repository of its own rather than a planted
|
||||
# file. Two orderings and one property are checked: facts-newer must warn, poster-newer must not,
|
||||
# and BOTH must exit 0. That last one is not ceremony — the first red-proof of this gate caught it
|
||||
# exiting 1 on a Windows console because its warning carried a single non-ASCII character, which
|
||||
# would have failed every push on this workstation while claiming to be advisory.
|
||||
_PF = r"""
|
||||
import os, subprocess, sys, tempfile, shutil
|
||||
gate = sys.argv[1]
|
||||
tmp = tempfile.mkdtemp(prefix="posterfacts-")
|
||||
try:
|
||||
arch = os.path.join(tmp, "documentation", "architecture")
|
||||
os.makedirs(arch)
|
||||
poster = os.path.join(arch, "felhom-system-poster.html")
|
||||
facts = os.path.join(arch, "felhom-system-poster.facts.md")
|
||||
def w(p, s):
|
||||
open(p, "w").write(s)
|
||||
def git(*a, when=None):
|
||||
env = dict(os.environ)
|
||||
if when:
|
||||
env["GIT_COMMITTER_DATE"] = when
|
||||
env["GIT_AUTHOR_DATE"] = when
|
||||
subprocess.run(["git", "-C", tmp] + list(a), capture_output=True, env=env, check=False)
|
||||
git("init", "-q", ".")
|
||||
git("config", "user.email", "d@d"); git("config", "user.name", "d")
|
||||
w(poster, "poster"); w(facts, "facts")
|
||||
git("add", "documentation/architecture/felhom-system-poster.html")
|
||||
git("commit", "-q", "-m", "poster", when="2026-10-01T10:00:00")
|
||||
git("add", "documentation/architecture/felhom-system-poster.facts.md")
|
||||
git("commit", "-q", "-m", "facts", when="2026-10-05T10:00:00")
|
||||
a = subprocess.run([sys.executable, gate, tmp], capture_output=True, text=True)
|
||||
w(poster, "poster v2")
|
||||
git("commit", "-q", "-am", "poster2", when="2026-10-07T10:00:00")
|
||||
b = subprocess.run([sys.executable, gate, tmp], capture_output=True, text=True)
|
||||
stale_warned = "older than its facts" in (a.stdout + a.stderr)
|
||||
fresh_quiet = "older than its facts" not in (b.stdout + b.stderr)
|
||||
print("STALE_WARNS=%s FRESH_QUIET=%s RC_STALE=%d RC_FRESH=%d"
|
||||
% (stale_warned, fresh_quiet, a.returncode, b.returncode))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
"""
|
||||
ran += 1
|
||||
_pf = subprocess.run([sys.executable, "-c", _PF, os.path.join(ROOT, "scripts", "poster_facts_gate.py")],
|
||||
cwd=ROOT, capture_output=True, text=True)
|
||||
_out = (_pf.stdout + _pf.stderr).strip()
|
||||
_want = "STALE_WARNS=True FRESH_QUIET=True RC_STALE=0 RC_FRESH=0"
|
||||
if _want in _out:
|
||||
print("ok poster-facts/stale-warns-and-never-fails")
|
||||
else:
|
||||
fails.append("poster-facts: expected %r, got %r" % (_want, _out[-300:]))
|
||||
|
||||
# the gate must print nothing non-ASCII: that is what broke it the first time, and a console that
|
||||
# cannot encode a character turns "advisory" into a failed push.
|
||||
ran += 1
|
||||
_src = io.open(os.path.join(ROOT, "scripts", "poster_facts_gate.py"), encoding="utf-8").read()
|
||||
_printed = re.findall(r"lines\.append\((.*?)\)\n", _src, re.S) + re.findall(r"print\((.*?)\)\n", _src, re.S)
|
||||
_bad = [s for s in _printed if any(ord(c) > 127 for c in s)]
|
||||
if _bad:
|
||||
fails.append("poster-facts: %d printed string(s) carry non-ASCII; on a cp1250 console the gate "
|
||||
"would raise UnicodeEncodeError and exit non-zero" % len(_bad))
|
||||
else:
|
||||
print("ok poster-facts/output-is-ascii")
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
for f in fails:
|
||||
|
||||
Reference in New Issue
Block a user