## 2026-10-15 (night) — read_credential.py refuses to write over the credentials file (R-1088) - `read_credential.py KEY ~/.config/credentials` — the credentials path given as the OUTFILE — truncated the operator's credentials file to one value (CC, the app-guides night). The reader now refuses, before reading or writing, an OUTFILE that is the credentials file by path, by symlink or by hard link (exit 2, the reason named). The docstring says what OUTFILE is. - `test_read_credential.py`: `test_the_outfile_can_never_be_the_credentials_file` (same path, symlink, hard link, and a separate outfile still accepted). Red-proved: with the guard off, the file is overwritten and 5 checks fail (`documentation/audits/app-guides-2026-10-15/credential-guard-redproof.txt`). The file itself is NOT restored — that is the operator's (R-1088). ## 2026-10-15 — golden_currency_gate: the golden is judged by its AGE (`09` §3 decision 250) - A controller release needs no golden (operator ruling 2026-10-15 11:26). The gate no longer convicts because the newest controller CHANGELOG heading is ahead of the newest bake; it convicts when a release is ahead AND the newest bake is MORE THAN 7 days old (the date in its evidence directory). Ahead and at most 7 days old: an advisory, exit 0. Unchanged: an unrecorded golden (R-385) at any age; a malformed waiver is exit 2; a valid waiver turns the age conviction into an advisory; documents-only pushes stay advisory (R-404). The gate stays offline, so it cannot see the floor or the vouch — „release ahead" stands in for „floor ahead". - `test_golden_currency_gate.py` CASE 18–21: 3 days → 0, 8 days → 1, exactly 7 → 0, an unrecorded golden however fresh → 1. Red-proved both ways (limit 0: CASE 18/20 fail; limit 30: CASE 19 fails). - `repo_gates.py`: the advisory block quotes the golden's age and names the weekly bake. ## 2026-10-14 (night) — https_only_check.py: the box-walk check for „https only” (R-1033) - New `scripts/https_only_check.py`. Reads each box's public names from the box (traefik labels + dynamic files, the script sent on stdin so the Go template's `$` survives ssh + pct), adds `felhom.` and one name that exists nowhere, and asks `http:///r1033-check?q=1` with no redirect following: 301/308 to the same https URL or the box is convicted. A browser-like User-Agent (Cloudflare 403s Python's default — measured on the first run). - Controls every run: NEGATIVE — a local stub answering 200 must be convicted, or the run stops (exit 2); POSITIVE — the first box's dashboard name must pass. Exit 0 / 1 / 2. Not a push gate (it needs the network and SSH). First run: 29 names on Tester 1, demo-hp, demo-felhom all OK (`documentation/audits/https-only-2026-10-14/1B-4-box-walk-check.txt`). ## 2026-10-14 — build-hub.sh v1.24.0: refuses a stale or dirty tree (R-1021) - Fetches, then REFUSES (exit 1, naming the leftovers' home and the own-clone route) unless the tree is clean, the `git pull --ff-only` succeeds, and `HEAD == origin/main`. `GIT_COMMIT` is read AFTER the pull. Until now a failed pull only warned and the commit was read before it — on 2026-10-14 the shared checkout was 44 commits behind and the script would have built old hub code under the new version label. - `FELHOM_HUB_REPO_DIR` builds from another clone (same rule). No `--allow-dirty`: no current runbook needs one. - `test_build_hub_tree_gate.sh` (new): a scratch origin + clone, a STUB `docker` first on PATH (nothing is built or pushed). Six cases — untracked file, modified file, diverged (pull blocked), unpushed commit → refused; behind with a clean pull → passes and the Commit line names the NEW head; clean → passes. Red-proof: the old script (only `REPO_DIR` made overridable) gets 5 of 6 wrong (`documentation/audits/immich-repair-2026-10-14/E/bh-old.txt`). ## 2026-10-14 — host install: the uninstall removes the new root helper felhom-vzdump-partial-clean (R-1006) - `felhom-host-install.sh` step 4d removes `/usr/local/sbin/felhom-vzdump-partial-clean` (agent v0.156.1's bundle installs it; a fresh install gets it with the vouched bundle). `test_hostinstall.py`'s frozen bundle list carries it (`test_bundle_list_is_current` caught the gap). `SCRIPT_VERSION` stays 1.32.0: the change reaches boxes with the next installer release. ## 2026-10-14 (night) — plugcut: a real power cut on the HP, with three ways back on - New `scripts/bench/plugcut.py` + `test_plugcut.py` (12 tests): refuses any plug but `hp` (N100 only with `--allow-n100`), without `--i-mean-a-power-cut`, over 60 s without `--long`, over 3600 s. The relay off carries the plug countdown (DP 9) in the same command; a detached watchdog is armed first; `finally:` re-arms. Red-proofed: each of nine guards removed in turn, a test failed each. `plugswitch.py` is unchanged. ## 2026-10-13 (night) — site_gates: the machine estimator moves from UNLISTED to TWINS (R-997) - `site_gates.py`: the `gepigeny`/`en/sizing` pair is a normal twin; `UNLISTED` is empty and gate 25 stays for the next such pair. The sitemap check now requires both URLs. - `test_build_sizing_web.py`: `test_sentence_base_is_the_drawn_box_base` (13 tests) — the sentence's base figure is the bar box's base (4.0 GB at 8 GB, 4.6 GB for the household preset). Red-proved: a fixed 4,708 MB base fails it. ## 2026-10-13 (evening) — power-off spike on the N100: plugswitch, wall-power figures - New `scripts/bench/plugswitch.py` (`on`/`off`/`status`) + `test_plugswitch.py` (7 tests): `off` refuses unless 20 s of readings sit below the measured standby + 1 W and the N100 answers no ping. Red-proofed: each of the four checks removed in turn, a test convicted each. Live: refused while the N100 ran; a slow-read bug (window cut to 16 s) found and fixed live. - `documentation/audits/poweroff-2026-10-13/`: Part A by hand — guest shutdown 12 s, host off 13 s later, 0.0 W off; hub silent for 18 min (still „ONLINE"); plug off/on did NOT start the N100, the power button did (dashboard in 42 s). - `sizing-2026-10-13`: wall figures in `SIZING.md` §4 and `sizing.json` `power` (8.5 W rest, 20.7 W work, 0.0 W off); the CPU-only RAPL figures kept under `power.cpu_rapl`. ## 2026-10-13 (evening) — plugmeter: the N100's power plug, read from DooPlex - New `scripts/bench/plugmeter.py` + `scripts/bench/README.md`: reads the Tuya meter plug the N100 runs on and prints CSV (`utc_time, watts, milliamps, volts, kwh_total`) every `--interval` s (default 2); `--duration`, `--once`. `--source tuya` (default) talks to the plug on the LAN (tinytuya 1.20.0, protocol 3.4) and forces a re-measure on each read; `--source ha` reads Home Assistant on demo-hp's guest. Keys and the token stay in DooPlex `~/.config/felhom-bench/` (700/600), never in a repo. A failed read logs to stderr and the run goes on. - Measured (`documentation/audits/plugmeter-2026-10-13/`): 10 min side by side, both mean ~8.4 W, median 7.2 W. Home Assistant alone did not change for 3 minutes — the plug reports to the cloud only when the local reader asks. Recommended source: tuya. - `documentation/operations/nodes.md`: the N100 is on a metered plug; Home Assistant on demo-hp stays installed. ## 2026-10-13 (night) — estimator v3.1 tests - `test_build_sizing_web.py` (12 tests): the bar is never below the smallest named box, its parts still sum to it within 1 MB (nothing ticked, one tiny app, household, all 62), the base system never below the 8 GB split (4,096); 9 apps → 9 named parts, 10 apps → 8 named + a group of 2. Red-proofed: the floor removed from the JS and from the mirror, and grouping a single app in the JS and in the mirror, each convicted. ## 2026-10-13 (evening) — estimator v3: segments and the English article under test - `build_sizing_web.py`: `bar_app_colours` (8) in the data file, so the script holds no number of its own. - `test_build_sizing_web.py` (10 tests): the bar's segments (base system = box − guest, Felhom, each app's peak, spare = guest − peak) add up to the bar within 1 MB for the four cases and for nothing selected; base and spare are never negative; the household preset by hand (spare = 5,515 × 0.5, base = 4,096 + 612); every English recommendation gets „an 8" / „a 12" … Red-proofed: the „an 8" rule removed, the JS computing spare from the typical figure, and segments off by 50 MB each convicted. ## 2026-10-13 (afternoon) — estimator v2: `build_sizing_web.py` box sizes, tests for the inverse - `build_sizing_web.py`: `boxes_gb` = 8, 12, 16, 24, 32, 48, 64; new `bar_marks_gb` and `bar_max_gb`. - `test_build_sizing_web.py` (8 tests): the four cases × seven sizes, with the recommended („fits well”) and the smallest „tight” size each; the inverse agrees with the forward rule (the guest at the answer holds the need, one step smaller does not); SIZING.md's table is read by its header and must carry the 24 GB column; `sizing.js` may hold only 0, 1, 2, 100 and 1024. Red-proofed: a wrong 24 GB cell, a missing 24 GB column, a hard-coded 64 in the JS, and an inverse 1 GB too big each convicted. ## 2026-10-13 — `build_sizing_web.py` + `site_gates.py` UNLISTED pairs and gate 25 - **`build_sizing_web.py`** builds `website/assets/sizing-data.json` (the machine estimator's data) from `documentation/audits/sizing-2026-10-13/sizing.json` and the two apps pages. Refuses (exit 1, writes nothing) when a listed app is missing from `sizing.json` (FileBrowser excepted by name: built in), when a peak is below its typical figure, or when the two apps pages disagree. `--check` compares the committed file with a fresh build. - **`test_build_sizing_web.py`** (7 tests): fresh build = committed file; the four cases of SIZING.md §5 give the expected verdicts; SIZING.md's verdict table agrees with the rule; `sizing.js` carries the rule's shape and none of its numbers. Red-proofed: a hard-coded 470 in the JS, a changed headroom in the data, and a wrong table cell each convicted. No JS runtime exists on DooPlex or in CI, so the rule is mirrored in Python on the same data. - **`site_gates.py`**: `UNLISTED` twin pairs get every twin check (hreflang, globe, canonical, lang, no Hungarian on the English page) and are excluded from the sitemap requirement; **gate 25** adds what unlisted means — `noindex`, a `Disallow:` line per URL in `robots.txt`, absent from `sitemap.xml`, linked from no other page. Red-proofed: each of the four, and a wrong twin alternate, convicted on its own; the first run caught the missing `robots.txt` lines for real. ## 2026-10-12 (night) — make_hero_clip.py: scenes keep their own length - **`--seconds` is no longer a fixed window.** Each scene now plays its full recorded length, read per file with `ffprobe` (operator ruling 2026-10-12: *„use the full length for all of them, they had been cut as I liked them”* — the raw file IS the edit). `--seconds N` stays as a cap, for trying a shorter loop; `0` (the default) means full length. - The xfade offsets are computed from the running sum of the scenes so far **minus one fade per transition already made** — xfade measures its offset on the chain it is handed, which is shorter than the raw sum. With equal-length scenes the old `i * (seconds - FADE)` was the same number; with unequal ones it is not. - **Output is `hero-clip.mp4` / `.webm` / `hero-poster.webp`** — no language suffix, because the same recordings serve `/` and `/en/`. `--lang hu` puts `-hu` back if the two ever differ. - Six real scenes, 36.6s after the fades: **mp4 1 134 KB, webm 1 295 KB, poster 53 KB.** Three times the 11.4s trial, as expected — the hero is the only page that loads it, and only once it is on screen. ## 2026-10-12 (evening) — make_hero_clip.py: the home page's laptop clip - `scripts/make_hero_clip.py` assembles the hero clip from the operator's raw app recordings: `NN-appname.ext` in, scaled and centre-cropped to the laptop screen's 1.626:1, crossfaded, out as **mp4 (h264) + webm (VP9) + a webp poster**. Runs on DooPlex; Windows has no ffmpeg. It is not part of any build, gate or deploy — the site has no build step, so its output is committed like any other picture. - **Why mp4+webm and not a GIF**, measured on DooPlex 2026-10-12 with six crossfaded scenes: | | 11.4s @ 904×556 | 11.4s @ 1130×696 | |---|---:|---:| | mp4 (h264 crf30) | **331 KB** | 775 KB | | webm (VP9 crf44) | 411 KB | 1 196 KB | | animated webp | — | 2 551 KB | | GIF | **2 676 KB for 6s** | — | A GIF is ~11× the mp4 for the same seconds, and dithers to 256 colours on a dark UI. - Proven end to end against six placeholder scenes built from the repo's own app screenshots: 21s loop → mp4 480 KB, webm 609 KB, poster 83 KB. ## 2026-10-12 (afternoon) — gate 22 also refuses an unscoped bare element rule - `site_gates.py` gate 22 gains a second half: a rule in `site.css` whose selector starts with a bare `nav`, `header`, `main` or `aside` and is not scoped `body:not(.shell)` / `.shell` is a failure, because those elements ARE the shell (`