Compare commits

...

445 Commits

Author SHA1 Message Date
admin e1613aea8d Restore-holes run close-out: README, CONTEXT, REPORT; the interrupted-restore log line names a waiting put-back. Commit only.
gates / gates (push) Successful in 1m18s
2026-10-24 21:03:34 +02:00
admin c9b4b82dc7 R-1253 (P2): an export that stops its app refuses while another operation (a restore) holds the app-stop marker — its Undo is never replaced. Commit only.
gates / gates (push) Successful in 1m28s
2026-10-24 20:29:59 +02:00
admin 518a13592d R-1252 (P2): a rejected or absent backup block never deletes second-drive copies — reconcile only on a valid classification; a rejected block is said on the app's status and once a day to the operator (backup_run_failures); kept copies stay kept (H2); Remove drops the kept records it deleted (H9). Commit only.
gates / gates (push) Successful in 1m18s
2026-10-24 20:07:16 +02:00
admin 062f9b41d9 R-1251: sync the restore copy's folders before the marker names it (a cut left undo.json as .tmp on Tester 1); the interrupted-restore record says the put-back waits, not 'run it again'. Commit only.
gates / gates (push) Successful in 1m24s
2026-10-24 19:36:32 +02:00
admin bb2809dc68 R-1251 (P1): a pending put-back blocks every start — a cut restore's copy on an unbound drive waits (a restore hold), is poured back when the drive binds before any start; a failed put-back holds the app (H2); Stop during a restore still puts back (H7). Commit only, unreleased.
gates / gates (push) Successful in 1m18s
2026-10-24 19:07:02 +02:00
admin 2449223f10 Review round 2 docs: REUSE.md — D22 released (v0.338.0), recordInstalledImages callers, a row for the stray-copy seams; no code change
gates / gates (push) Successful in 1m20s
2026-10-24 16:00:25 +02:00
admin b01bb4b88d Review round 2: a media restart no longer starts an app the household stopped (R-721); the file manager's header name file appears whole (R-1100 race, 44/300 -> 0/300); both red-proved; unreleased
gates / gates (push) Successful in 1m22s
2026-10-24 15:26:56 +02:00
admin 321df1f87a README §8e + REPORT: R-1250 hold, live proof on demo-hp
gates / gates (push) Successful in 1m24s
2026-10-24 13:48:15 +02:00
admin 046b808792 upwatch: hold a seeding torrent app for one line test, then put its limit back exactly (R-1250)
gates / gates (push) Successful in 1m22s
When the agent waits for quiet and qBittorrent or Transmission runs: record the limit first,
hold at 1 KiB/s, ask the agent for a test on request, put the old limit back (deferred; a
household change during the test stays). Start-up puts back a limit a killed controller left;
the 50 % rule skips while a hold record exists. Commit only.
2026-10-24 13:19:07 +02:00
admin bddc477e22 CHANGELOG: v0.342.0 released (the upload and CPU watch, the tested line, qBittorrent's limit); REPORT
gates / gates (push) Successful in 1m28s
2026-10-24 10:28:53 +02:00
admin 42cda07b42 v0.342.0 (unreleased): the watch's start line no longer says the line is learnt only from the off-site push
gates / gates (push) Successful in 1m22s
2026-10-24 09:58:13 +02:00
admin ed1011b088 v0.342.0 (unreleased): the upload watch reads the host's weekly line test (GET /host/line-test, agent >= 0.164.0); qBittorrent's limit = 50 % of the TESTED line only (D41), the watch uses the higher of test and push floor, the system page names the source; red-proved
gates / gates (push) Successful in 1m15s
2026-10-24 08:59:57 +02:00
admin 60e487db51 docs: v0.342.0 (unreleased) — CONTEXT note and REPORT for the upload watch; live proofs on scratch 9202
gates / gates (push) Successful in 1m18s
2026-10-23 22:56:39 +02:00
admin a9c1997fbc v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s,
25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex,
app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second
for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs.
Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
2026-10-23 21:26:09 +02:00
admin 277e99839d docs: v0.341.0 released — global floor 11:24 UTC on the operator's yes, MinAgent 0.158.1; the live proofs in REPORT (no code change)
gates / gates (push) Successful in 1m25s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-23 13:30:54 +02:00
admin 63dcccfeec v0.341.0: the night update takes a security_fix ladder step (D39 = B, 09 §3 decision 255, R-1239); every other leg guard unchanged; the leg names the CVE ids; MinAgent 0.158.1
gates / gates (push) Successful in 1m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-23 13:09:52 +02:00
admin 32e61d5d1d REPORT: day 2026-10-23 — controller 0.340.0 released (floor 06:41 UTC, MinAgent 0.158.1)
gates / gates (push) Successful in 1m28s
2026-10-23 11:07:44 +02:00
admin caaa86eb3c v0.340.0: release — the capped night copy that never outlives the controller (R-1214 + stray-copy fix), Immich's model cache out of backups (R-49), no import over a stopped app (R-1187 c), the catch-up checks (R-1189 d, C5); MinAgent 0.158.1
gates / gates (push) Successful in 1m53s
2026-10-23 08:19:45 +02:00
admin 23f77f93ba night 2026-10-23 docs: CONTEXT (0.340.0 candidate), REPORT (both review rounds, rc.8 re-proof), the OffsiteStateAwaitingRecoveryKey comment corrected (its hub claim was measured false: felhom.eu R-1230); no behaviour change
gates / gates (push) Successful in 1m39s
2026-10-23 00:05:34 +02:00
admin 1babfe94f6 Second-drive copy: two races closed — the start-up/retry removal holds the destination's lock (a copy this process starts is never removed), and a run records itself in one step with the restore check, every leg asking again; red-proved, race-clean; unreleased
gates / gates (push) Successful in 1m40s
2026-10-22 22:58:33 +02:00
admin 9d9e4041c9 Second review of the night's own code: a stray clears when a later removal works (N2), the start-up check never removes this process's own copy (N3), one copy per destination at a time (N4), RunTier2 refuses during a restore for every caller (N5), the import refusal no longer advises removal (N9); red-proved, race-clean; README; REPORT; unreleased
gates / gates (push) Successful in 1m40s
2026-10-22 22:45:42 +02:00
admin 943c517550 Review fixes for 0.340.0: C5 a catch-up releases the whole-guest backup when its backup legs end (red-proved); C3 the R-1187 c refusal text fits an unhealthy app; C6 two comments; C8 README backup.volumes wording; unreleased
gates / gates (push) Successful in 1m40s
2026-10-22 21:25:48 +02:00
admin 912e59210d Stray-copy fix: the copy container runs with --no-healthcheck (the image's controller health check showed it as starting on the 9202 proof); unreleased
gates / gates (push) Successful in 1m41s
2026-10-22 20:39:05 +02:00
admin 66a69d4954 Stray-copy fix: the capped second-drive copy container never outlives the copy — fixed name + label, removed by name on any error (the timeout included), every leftover removed at start before the scheduler and web server; a restore is refused while a copy runs or may still run; --mount (refuses a path the daemon does not see) and the image ID with --pull never (review); red-proved; unreleased
gates / gates (push) Successful in 1m53s
2026-10-22 20:20:35 +02:00
admin df78b6caae night 2026-10-22: README (backup.volumes excluded, the capped second-drive copy), CONTEXT, REPORT — four fixes on main, not released (0.340.0 next)
gates / gates (push) Successful in 1m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-22 18:11:45 +02:00
admin ae394670c1 R-1214: the capped copy refuses any path with a symlink in it (security review: a link under /mnt pointing outside resolves elsewhere for the daemon); red-proved; unreleased
gates / gates (push) Successful in 1m53s
2026-10-22 17:51:14 +02:00
admin 03c0a693b8 R-1214: the second-drive copy runs in a 512 MB memory-capped container for /mnt paths (its page cache no longer evicts the apps) — measured on 9202; guarded against a path the daemon sees differently; unreleased
gates / gates (push) Successful in 1m40s
2026-10-22 17:47:19 +02:00
admin d5f88a2182 R-1187 (c): refuse a .fab import over an installed app that is not running (it merged old and bundle data); R-1189 (d): the off-site proof + integrity check run after a catch-up; unreleased
gates / gates (push) Successful in 1m54s
2026-10-22 17:27:56 +02:00
admin 6a17c95815 R-49 (D21 = B): a template may exclude a named volume from every backup (backup.volumes, class excluded) — Immich's model cache; guarded, red-proved; unreleased
gates / gates (push) Successful in 1m50s
2026-10-22 16:21:38 +02:00
admin cbc944c3c0 REPORT: v0.339.0 released (R-1199 reopened, R-1216)
gates / gates (push) Successful in 1m44s
2026-10-22 13:15:42 +02:00
admin fc5a20f40a v0.339.0: release — the household's mail list never overwritten (R-1199 reopened), the swap sentence only when swap is really short (R-1216); MinAgent 0.158.1
gates / gates (push) Successful in 1m58s
2026-10-22 12:57:24 +02:00
admin add310c80a R-1199 reopened: a box with no list of its own reads the household's from the hub and adopts it, pushes its default once only when no household saved one; every push names its source; an empty saved list stays empty; R-1216 (D28 = A): the install page's swap sentence only when host swap is really short (shared case table); unreleased
gates / gates (push) Successful in 1m55s
2026-10-22 12:13:26 +02:00
admin 08fa440d04 REPORT: v0.338.0 released (operator yes 2026-10-22): 3 test boxes through the floor, checks green, global floor set
gates / gates (push) Successful in 1m41s
2026-10-22 10:39:43 +02:00
admin 7900a32695 v0.338.0: release (operator yes 2026-10-22) — D22 restore put-back, first-hour words, swap sentence, R-1199; MinAgent 0.158.1
gates / gates (push) Successful in 1m41s
2026-10-22 09:51:27 +02:00
admin 1de8a559ed REPORT: the night of 2026-10-21 (D22, words, R-1199; rc.2), unreleased
gates / gates (push) Successful in 1m40s
2026-10-22 00:06:36 +02:00
admin f0abdd0eda CHANGELOG: blank line before v0.337.0
gates / gates (push) Successful in 1m38s
2026-10-21 23:41:10 +02:00
admin ba22f9b2d9 R-1199: a box whose household never saved its notification choice pushes the default list its page shows at start, so the hub mails by it; unreleased
gates / gates (push) Successful in 1m37s
2026-10-21 23:40:21 +02:00
admin 5776d54901 W21 sharing switched on without a password asks for it and says why it waits; W25 failed off-site run shows the household a sentence built from the classifier, raw text only to log and operator; W28 share restore skips the controller's drop-zone share; unreleased
gates / gates (push) Successful in 1m40s
2026-10-21 23:19:07 +02:00
admin 28b05d6d68 W13 new-drive pages pre-fill the next free hdd_N and answer a bad name visibly; W14 drive operator detail behind Részletek; W29 plain decommission names no move; unreleased
gates / gates (push) Successful in 1m37s
2026-10-21 22:51:08 +02:00
admin 8c1e290f63 W12 family names keep their accents, login made without them; W9 banner link colour; W10 recovery-code page one plain line; W11/W24 remote page hides provider login and key fields behind Haladó; unreleased
gates / gates (push) Successful in 1m33s
2026-10-21 22:35:24 +02:00
admin 37b79caad9 install page: say when the host has too little swap behind the guest (R-1192 follow-up); restore record log says when a cut restore was put back; unreleased
gates / gates (push) Successful in 1m36s
2026-10-21 22:11:16 +02:00
admin d582feb245 D22: every restore puts the data back exactly on failure and after a power cut (R-1177, R-1195); whole restore asks the unit first (R-1178); no mirror during a restore (R-1180); W22 restore refusals in Hungarian; unreleased
gates / gates (push) Successful in 1m40s
2026-10-21 21:19:46 +02:00
admin 7848d4aaac CHANGELOG: v0.337.0 (MinAgent 0.158.1)
gates / gates (push) Successful in 1m20s
2026-10-21 15:58:43 +02:00
admin 5929b8e345 kept second-drive copies (D25: R-1175, R-1181, R-1179); .fab import keeps the family gate (R-1191); stale restore temp (R-1188 c); the overcommit sentence made true (R-1192); escrow before the floor (R-1176); unreleased
gates / gates (push) Successful in 1m20s
2026-10-21 15:46:10 +02:00
admin 8f5bea0148 R-1182: a finished off-site abandonment closes on the hub's abandon_purge_done (hub v0.163.0 keeps other copies' packages); unreleased
gates / gates (push) Successful in 1m18s
2026-10-21 13:36:12 +02:00
admin 01c967e49e monitoring page: storage labels follow the reader's language (W26, rehearsal 2026-10-20); unreleased
gates / gates (push) Successful in 1m11s
2026-10-20 22:36:19 +02:00
admin 7ca59e2de3 CHANGELOG + CONTEXT: v0.336.0 release line (the previous commit carried only REPORT)
gates / gates (push) Successful in 1m11s
2026-10-20 10:58:00 +02:00
admin aa9e09e817 CHANGELOG + CONTEXT + REPORT: v0.336.0 released (floor 08:54 UTC, MinAgent 0.158.1) after the 9202 proof and the three test boxes
gates / gates (push) Successful in 1m8s
2026-10-20 10:56:59 +02:00
admin 93fb251ea9 R-893 option A (operator D20): a failed off-site restore puts the app's live volumes and definition back exactly and starts it; fit check before the stop; the held sentence made true (R-1169)
gates / gates (push) Successful in 1m11s
2026-10-20 09:17:00 +02:00
admin ae2bfb2f7a REPORT: the security-scan gate (R-986)
gates / gates (push) Successful in 1m9s
2026-10-19 21:48:13 +02:00
admin a03c25aa1f security-scan gate (shared felhom.eu/scripts/security_scan_gate.py) in the fast set and CI, warn-first, with the accepted-hits file and decoys (R-986)
gates / gates (push) Successful in 1m12s
2026-10-19 21:11:31 +02:00
admin c4b3b0a039 REPORT: v0.335.0 is the floor (R-921)
gates / gates (push) Successful in 51s
2026-10-19 20:40:07 +02:00
admin 15ea002458 v0.335.0: no app stop for a tier the agent will refuse because its host is busy (host_busy pre-check, R-921)
gates / gates (push) Successful in 53s
2026-10-19 20:05:52 +02:00
admin db980ff2cc REPORT: v0.334.0 is the floor (R-1159)
gates / gates (push) Successful in 51s
2026-10-19 16:33:58 +02:00
admin 546eef37bd v0.334.0: a live box refreshes its config with its own hub key (GET /box-config; R-1159, operator D19)
gates / gates (push) Successful in 49s
2026-10-19 15:55:10 +02:00
admin e1a2abf1e3 REPORT: v0.333.0 is the floor (R-1155, R-1161)
gates / gates (push) Successful in 44s
2026-10-19 12:16:57 +02:00
admin a6fa3c01d2 v0.333.0 (unreleased): the R-1155 check reads every token source FileBrowser reads, Basic auth included (security review)
gates / gates (push) Successful in 45s
2026-10-19 11:43:52 +02:00
admin c809dd3bc6 v0.333.0: the file manager's own token ends with the dashboard sign-out (R-1155); the recovery-code page says the code becomes live after the check (R-1161)
gates / gates (push) Successful in 49s
2026-10-19 11:30:59 +02:00
admin a741bc1d0f CONTEXT + REPORT: v0.332.0 is the floor (R-1100 one sign-in, R-1151 escrow confirm)
gates / gates (push) Successful in 46s
2026-10-19 05:41:25 +02:00
admin 601bdba253 v0.332.0 (live rc.2 + security review): the pass redirect is absolute; no pass for a cross-site request; no dot-segments in to (R-1100)
gates / gates (push) Successful in 47s
2026-10-19 05:03:57 +02:00
admin 9bdd8f8fe8 v0.332.0: one sign-in for the file manager — a dashboard pass, a host-only files cookie, a per-box secret header (R-1100, operator D16)
gates / gates (push) Successful in 47s
2026-10-19 04:56:56 +02:00
admin ba4a6f060b The escrow wizard sends the typed-back words to the box; the new package reaches the hub only when they match (R-1151 controller half, unreleased; works with every agent)
gates / gates (push) Successful in 47s
2026-10-19 03:19:02 +02:00
admin 8ae9178138 v0.331.0: a removal deletes the volumes still labelled with the app's project that its definition no longer declares (R-1117)
gates / gates (push) Successful in 42s
compose down --volumes removes only the current definition's volumes; a volume a template dropped (or an older restore
re-created) survived 'remove with data' on 9202 (nextcloud_nextcloud_redis_data). Red-proofed.
2026-10-18 22:42:52 +02:00
admin 772ef36f5f v0.331.0: OnlyOffice opens and saves from FileBrowser and Nextcloud; FileBrowser back at once after a restart (R-1018)
gates / gates (push) Successful in 48s
FileBrowser config carries server.internalUrl http://filebrowser:80 (OnlyOffice fetched the public files address and got 403);
Nextcloud's DocumentServerInternalUrl gets its trailing slash (every save was lost: http://onlyoffice:80cache/...);
RestartStack("filebrowser") runs the R-1126 gate at once (files.<domain> was 502 for up to 5 min after the link switched).
Each red-proofed. MinAgent 0.158.1.
2026-10-18 21:05:04 +02:00
admin 615940de35 CI workflow: the failure mail links the public run page, by run id (R-1135)
gates / gates (push) Successful in 51s
The alarm step built its link from GITHUB_SERVER_URL, which on this runner
is the in-cluster Gitea address, and from the run NUMBER, which Gitea 1.26
reads as a run id first (felhom.eu /actions/runs/1116 opened run id 1116 =
run number 613). Now https://gitea.dooplex.hu + GITHUB_RUN_ID, and the link
is printed into the job log as ALARM-LINK. Same change in all four repos.
2026-10-18 20:34:59 +02:00
admin 6c6bd87664 v0.330.0: „Végleges leszerelés — mégis" asks the agent's disk list first and starts again the apps it stopped when the agent refuses (R-1144)
gates / gates (push) Successful in 52s
Red-proofed twice. MinAgent 0.158.1.
2026-10-18 13:06:09 +02:00
admin 03ab121b25 v0.329.0 (review): an app named like another app's appdata folder gets no root; USERDATA_PATH is always derived, never the app's env
gates / gates (push) Successful in 46s
Security review of 2fe6341 (same night, before any release). Both red-proofed.
2026-10-17 23:22:54 +02:00
admin 2fe634141f v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
gates / gates (push) Successful in 45s
R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx ->
paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063:
finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409
come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format
refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z.
Each pinned by a red-proofed test. MinAgent 0.158.1.
2026-10-17 23:11:56 +02:00
admin 5d67041d6d CI re-run: R-1142 and R-1143 (named in REPORT.md) are now in felhom.eu's register — job 2036 ran before they were pushed
gates / gates (push) Successful in 1m2s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 18:13:23 +02:00
admin e30091bf27 REPORT/CONTEXT: agent 0.159.1, the catalog allow-list (controller v0.328.0), its catalog gate (2026-10-17)
gates / gates (push) Failing after 1m2s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 17:29:53 +02:00
admin 99f9117296 v0.328.0 (unreleased): live finding on 9201 (rc.4) — roots resolved through their nearest existing ancestor; no symlink resolve for the placeholder check
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 16:52:09 +02:00
admin 09ac87a843 v0.328.0 (unreleased): security review round 3 — under HDD_PATH only appdata/ is a data root (not backups/)
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 16:49:17 +02:00
admin 36c4aa4e69 v0.328.0 (unreleased): security review round 2 — nearest-ancestor symlink resolve (fail closed), any extends refused, the entry binds a literal image and its exact healthcheck, no variable in a bind's options
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 16:46:32 +02:00
admin 24d937d6e4 v0.328.0 (unreleased): the security review's four findings — the entry is bound to its image and start; symlinks out of a root; the missed classes; roots only under the drive namespace or system data
gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 16:42:18 +02:00
admin 6d90374f63 v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 16:34:12 +02:00
admin 71fbef6bbe v0.327.1: files.<domain> reaches FileBrowser again — a file route by name, not Docker labels
gates / gates (push) Successful in 43s
v0.327.0's box walk: files.<domain> timed out on demo-hp and the N100. With labels, Traefik kept the address it chose at
container start (on felhom-filebrowser, before the gate joined traefik-public). FileBrowser now carries no Traefik
labels; dynamic/filebrowser.yml routes files.<domain> to http://filebrowser:80 by name, resolved per connection and
only while the gate keeps it on traefik-public. Test extended, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 13:56:45 +02:00
admin 0c6ce9500c v0.327.0: CHANGELOG, CONTEXT, REPORT (and the R-1129 correction)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 13:42:42 +02:00
admin a4c0d546e5 R-1125: the install page shows the check's own memory reading
gates / gates (push) Successful in 46s
The deploy page read /proc's host-wide figures (system.GetMemoryMB) while the install check reads the guest cap and
the installed apps' committed requests; on 9202 the page said 12,637 MB needed where the check counted 2,648. Both
now read stacks.InstallMemory. TestR1125_InstallMemoryIsTheChecksReading, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 13:37:55 +02:00
admin 67a4b452e2 R-1125: the install page's memory box says what to do too (fixtures re-captured under new names)
gates / gates (push) Successful in 44s
deploy.nincs_eleg_memoria_foglalas_telepites (HU + EN) now says the memory is held by the installed apps and to
remove one or use a machine with more memory. deploy_new_blocked and deploy_new_memory_blocked re-captured as
*_r1125 (only that sentence differs); all other fixtures byte-identical.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 13:26:46 +02:00
admin 8f029aa70b R-1126: the gate publishes the container it checked, by id (TOCTOU from the commit's security review)
gates / gates (push) Successful in 42s
web.SyncFileBrowserMounts can re-create FileBrowser between the check and the connect; a connect by name would publish
the new, unchecked container. The run is now "<id> <StartedAt>" and the connect targets that id.
TestFileBrowserGate_ConnectsTheCheckedContainerById, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 12:59:43 +02:00
admin 411d7761d9 CI workflow: a manual proof that the failure mail can be sent (R-1129)
gates / gates (push) Successful in 50s
workflow_dispatch with input force_alarm (default false). A dispatch-only
step prints the RESEND_API_KEY length (never the key) and, when forced,
fails so the alarm step sends its real mail, subject marked FORCED TEST.
Push runs are unchanged. No controller release, no code change.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 12:49:43 +02:00
admin e3bb775ac5 FileBrowser is published only after its default login is checked (R-1126); the memory refusal says what to do (R-1125)
R-1126 (D11 = A): FileBrowser starts off traefik-public, on its own network felhom-filebrowser; the controller joins it
there, checks this run's default login, and only then puts FileBrowser on traefik-public (files.<domain> served).
FileBrowser's own admin-password setting re-applies at every start (1.5.6 cmd/user.go:50-57, measured on 9202), so it
cannot carry the box's password without overwriting the household's own. Tests red-proved.
R-1125 (D10 = A): err.stacks.not_enough_memory says the memory is held by the installed apps and to remove one or use
a machine with more memory (HU + EN, reworded on purpose). TestR1125_NotEnoughMemorySaysWhatToDo, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 12:49:34 +02:00
admin 3e8d027677 Row numbers: R-1120 -> R-1126, R-1122 -> R-1128 (a parallel session took R-1118..R-1122)
gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:58:53 +02:00
admin 334df7db12 v0.326.0: CONTEXT, REPORT
gates / gates (push) Failing after 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:53:15 +02:00
admin 30956aabd2 R-977: a new FileBrowser run is given ~20 s to answer in the same tick
gates / gates (push) Successful in 44s
rc.1 on 9202: the tick that brought FileBrowser back probed it while it was starting; the default login stayed
open for one 5-minute round. TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:41:18 +02:00
admin 3f0127778c v0.326.0: FileBrowser's default password is checked at every FileBrowser start (R-977)
gates / gates (push) Successful in 49s
The probe ran once per box; a re-created FileBrowser data volume came back on admin/admin and was never probed
again (measured on scratch 9202 with 0.324.0). Now once per FileBrowser run (its StartedAt): the default found ->
a generated password, verified and stored encrypted; any other password -> nothing changes.
TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced, red-proved. The comment "before a claim there is no
tunnel" was false and is corrected; the tunnel is not changed.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:18:38 +02:00
admin 55868cb3e0 The decoy suite is a gate of the full run (R-1116); no code change
gates / gates (push) Successful in 46s
controller/scripts/test_gate_decoys.py is now controller_gates.py's `gate-decoys` row: blocking, full run only
(~22 s, 53 cases, and it edits real files in the tree for a moment, so never the hook). Declared in its own
COVERS; test_controller_gates.py pins it. Red-proved: debug-routes/comment flipped to expect accept ->
CONVICTED: gate-decoys; restored, green. gates.md's "--fast is all of them" corrected.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:01:40 +02:00
admin 85d60a1930 R-1110: the guard's census is 42 calls, not 41 (measured; comment and CHANGELOG)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 10:24:48 +02:00
admin 68af0e97f2 Escrow re-auth answers in the box language; a stacks test no longer writes into its source folder
gates / gates (push) Successful in 47s
Both fixed without a row (size rule): login.msg.wrong_password for the escrow re-auth
(TestEscrowReauth_WrongPasswordInTheBoxLanguage, red-proved); cmdManager gets its own DataDir (the update
journal went into internal/stacks/, and the R-578 source walk tripped on it once).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 10:18:53 +02:00
admin 88aa65d595 v0.325.0: CONTEXT, REPORT
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 10:01:33 +02:00
admin 6ed223886d R-1112: open the family store when the controller starts
gates / gates (push) Successful in 46s
Found on demo-hp with 0.325.0-rc.1: the store (and so the conversion of an old family.json) opened only on the
first family request; a box nobody visits through a family gate kept the session values. OpenFamilyStore from
main, one INFO line with the count. TestR1112_StartConvertsTheFamilyFile, TestR1112_MainOpensTheFamilyStore,
red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 09:56:12 +02:00
admin 94f44931dd v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
gates / gates (push) Successful in 45s
R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112
family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its
family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers.
R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 09:36:34 +02:00
admin 7d41c2973c v0.324.0 is the floor (02:46 UTC): CONTEXT, REPORT
gates / gates (push) Successful in 49s
2026-10-17 04:52:29 +02:00
admin 7600e6a8b7 v0.324.0 released to the three test boxes: CHANGELOG live results, CONTEXT, REPORT
gates / gates (push) Successful in 47s
2026-10-17 00:02:39 +02:00
admin b69585789a README: the database password comes back with the data (R-1106); restic 0.19.1
gates / gates (push) Successful in 49s
2026-10-16 23:49:58 +02:00
admin f75b3aa778 v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s
2026-10-16 22:46:00 +02:00
admin 00a0d91976 v0.323.0 released to the three test boxes: REPORT, CONTEXT
gates / gates (push) Successful in 44s
2026-10-16 21:36:06 +02:00
admin 8587198781 v0.323.0: CHANGELOG live results and scanner; README — the image's pinned restic, Samba 1.2.0, the update path
gates / gates (push) Successful in 49s
2026-10-16 21:22:42 +02:00
admin fbcc3cf042 Debian 13 runtime with restic 0.18.1 pinned outside the OS; restic assumptions re-proven; felhom-samba 1.2.0 (R-1105, R-972)
gates / gates (push) Successful in 49s
2026-10-16 20:33:57 +02:00
admin 29a3ce8cf2 v0.322.0 released: REPORT, CONTEXT, README (R-970 routes removed)
gates / gates (push) Successful in 52s
2026-10-16 19:07:43 +02:00
admin e9d0d723c1 v0.322.0: CHANGELOG; the build runs apt-get upgrade (Debian 12 security fixes newer than 12.13)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:56:27 +02:00
admin 52619e89bd R-972: the controller runtime on Debian 12.13 (restic stays 0.14.0); Debian 13 is its own change (R-1105)
gates / gates (push) Successful in 47s
Debian 13 ships restic 0.18.0; the off-site engine's measured behaviour is 0.14.0's. The binary stays on
Go 1.27.2; the runtime moves to the current Debian 12 point release, pinned by digest.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:48:08 +02:00
admin fc77fcb89b R-972: Go 1.27.2, x/crypto v0.58.0, debian 13.7-slim — pinned by version and digest
gates / gates (push) Successful in 48s
Go 1.24 had no security fixes left; the shipped binary carried 62 known Go vulnerabilities and the
bookworm base 12 fixable criticals. Full suite green on Go 1.27.2 (i18n parity included).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:37:52 +02:00
admin f037be7a6a R-970: the box's hub key opens no route; Data tab leftovers; R-1103: the install form's dead branches
gates / gates (push) Successful in 48s
R-970: /api/config, /api/config/hash, /api/config/apply removed (they read and replaced controller.yaml with
every secret, reachable with the box's own hub API key, unsigned; nothing called them). The hub-key Bearer
door on /api/selfupdate/ and /api/geo/ and the matching CSRF exemption are gone; the dashboard keeps using
those with a session. The route table is buildMux, so a test drives the real one.

Data tab: the export password field is autocomplete=new-password with a non-login name; the 2nd copy's
status rows that repeated the form are gone (the no-target reason moves into the form).

R-1103: app_fields.html loses its installed-only branches (no page serves them since R-1095); the tests
that rendered that dead path are retired, their rules asserted on the settings tab (R-694 moved there).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 18:24:00 +02:00
admin 8fe46a1313 v0.321.0 released: REPORT, CONTEXT
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:30:31 +02:00
admin 7e791d9b5d v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
gates / gates (push) Successful in 43s
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:17:26 +02:00
admin c02c9d833a v0.320.0 released: REPORT, CONTEXT
gates / gates (push) Successful in 52s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:50:28 +02:00
admin 1296e7aa46 v0.320.0: the FileBrowser login line under the folder list — readable link, spaced from the list
gates / gates (push) Successful in 45s
Seen in the headless-browser check on demo-hp (rc.1): the link kept the browser's default dark blue on
the navy panel. It takes app-info-link (--blue-bright) and sits .75rem under the list.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:31:17 +02:00
admin f30a80841e v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the
app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login.
A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every
other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab.
The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now
refuse a protected stack.

In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel
offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp:
new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated.

Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the
login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind
(first_visit default, email, none; none behind the family gate says the family sign-in is the login).

"Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present
drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound
into FileBrowser is text, never a link.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:19:14 +02:00
admin 39bf56298d v0.319.0 released: REPORT, CONTEXT; CHANGELOG MinAgent 0.158.1 (the floor's, carried from 0.318.0)
gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:43:40 +02:00
admin 0f82eba7c6 v0.319.0: 16px under the hero on the install page too (the memory box touched it)
gates / gates (push) Successful in 43s
Seen in the browser check on demo-hp (rc.2). CSS only; on the tabs the tab bar's 16px collapses into it.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:30:43 +02:00
admin 7adbbb17c0 v0.319.0: the export panel says „Exportálás" and its inputs use the dashboard's style
gates / gates (push) Successful in 55s
Seen in the browser check on demo-hp (rc.1): a page-style title with the app name inside a data section,
and a white select + password box. Two parity fixtures re-captured (app_data_deployed, app_data_export).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:25:05 +02:00
admin d170a2aecf v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
gates / gates (push) Successful in 52s
- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:12:06 +02:00
admin 8d44416610 REPORT + CONTEXT v0.318.0: build B live proof; R-1093/R-1094 filed
gates / gates (push) Successful in 49s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 10:09:52 +02:00
admin 36da4089ca v0.318.0: a part is offered for formatting only while nothing on its disk is mounted (agent 0.158.1 formattable)
gates / gates (push) Successful in 45s
The agent's root mkfs wrapper (security review of agent 0.158.0) refuses any mounted node and trusts no sibling claim; the
wizard now matches it. A free filesystem part of an in-use disk stays offered for use. Red-proved.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 04:05:09 +02:00
admin a43d0423d5 v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
gates / gates (push) Successful in 46s
felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when
agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 03:57:49 +02:00
admin 123d2d553f REPORT v0.317.0: the live proof on Tester 1, demo-hp and the N100; R-1083 and R-1084 filed
gates / gates (push) Successful in 53s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 21:54:23 +02:00
admin a86c864b06 REPORT-app-page-2026-10-15.md: the app-page arc, in a file a second session cannot overwrite
gates / gates (push) Successful in 50s
REPORT.md is per-session and overwritten, and tonight two sessions released this repo within an
hour — the second (v0.317.0, the storage work) took it. This sibling holds the app-page arc:
Part 0's measurement and its caveat, what shipped, every red-proof, the three incidents, what is
deployed and what must never be, the browser check, and what is still owed.

The convention is felhom.eu/CLAUDE.md's, for exactly this case; this is the first use of it in
this repo. Whether REPORT.md should stay a single overwritten file in a repo two sessions can
release is a question for the operator, and is left as one rather than answered by a change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:48:19 +02:00
admin e7b64af1f0 build.sh's workspace/ is generated output, not a source change
gates / gates (push) Successful in 49s
The clean-tree gate added minutes ago immediately caught its own build: build.sh creates its
scratch directory beside the script, so a build run from a CLONE — which is exactly what the gate
pushes you to when the shared tree is busy — dirties the tree it is checking. It is generated
output and belongs in .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:32:08 +02:00
admin 0626b9fc8a v0.317.0: a second copy counts only on a proven different disk; the no-drive install speaks in words (felhom.eu architecture/16, build A)
gates / gates (push) Successful in 46s
Tier-2 and the one-copy notice ask the host agent (POST /disks/same-disk, agent 0.157.0): different +
proven -> made and counted; same disk -> not made (D1); unknown (VM, hidden USB serial) -> made, not
counted, the row says why (D2); a NAS never counts and no longer hides the notice (R-1075). An older
agent keeps the st_dev rule, logged once. D4: a data-folder or media app on a box with no drive is
refused in words with a link to the drives page (R-1068's raw-error half). MinAgent 0.157.0.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 21:21:54 +02:00
admin c6a567122e v0.316.4: build.sh refuses a push from a dirty or unpushed tree
gates / gates (push) Failing after 50s
An admission and its mechanism. Building 0.316.3 published another session's work: the shared
DooPlex clone held 31 uncommitted files, and the build directory's build.sh is a symlink into that
clone's WORKING COPY — which predated the FELHOM_REPO_DIR override, so the override was ignored and
the build copied the shared tree. The banner printed 'Commit: 0225255' and no 'Repo:' line; the
evidence was on screen and the image was already pushed. Nobody deployed it, which is luck, not a
control.

The clean-tree rule has been a sentence in a runbook all along. A rule without a mechanism is a
wish, so it is now two questions the build asks and refuses on: is every tracked file committed,
and is HEAD what origin/main has. A directory that is not a git checkout is refused too — a build
whose source cannot be identified is not reproducible.

Four new cases in the test (ten in total), each driving the real script; red-proved by deleting the
gate, which fails all four.

0.316.3 must never be deployed. It is not re-pushed or deleted: a version number is pushed once,
and that applies to a number I spoiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:09:38 +02:00
admin 4261a38bf0 build.sh: the source repo is overridable, and the build says which tree it used
gates / gates (push) Failing after 54s
The default is unchanged — the shared DooPlex clone the release runbook names. It is overridable
because that clone is SHARED: tonight another session had 30 files of work in progress in it, and
the clean-tree gate rightly forbids building over them. The way to ship is then a clean clone at
the pushed commit, not stashing somebody else's work.

The banner now prints the repo it built from, so an image is attributable to a tree as well as to
a commit. That matters more now that a version number can only be pushed once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:05:45 +02:00
admin f9151f0875 v0.316.3: the content column sits closer to the top and the sidebar
gates / gates (push) Failing after 45s
.app-page carried the mockup's 32px 48px 64px, but the .content wrapper it lives in already
supplies 2rem (1rem on a phone). The two added up: 64px from the top and 80px from the sidebar,
which is the gap the operator called too big. .app-page now adds no top or side padding of its
own — .content's is the page's — and keeps only the extra room at the bottom.

Reported from the rendered page with DevTools open, where both paddings are visible at once. The
mockup's numbers were the TOTAL the page should show, not an amount to add to what was already
there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 21:04:52 +02:00
admin ec438479e2 REPORT + CONTEXT for v0.316.x: the diagnosis, the two mechanisms, and the bad push I caused
gates / gates (push) Failing after 48s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:56:58 +02:00
admin 8c7aa10a6e v0.316.3: the layout polish ships under its own number
gates / gates (push) Failing after 44s
The two data-tab leftovers and the form de-boxing were committed after 0.316.2 was already pushed,
so they ship as 0.316.3. A version is pushed once; the number moves, the image does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:52:04 +02:00
admin 9e68e526e9 v0.316.2: two more leftovers on the data tab
gates / gates (push) Failing after 48s
Biztonsagi mentes was still a card — it sits directly in the tab, not inside a section, so the
.app-section scoping rule did not reach it. It is a section now.

The 2nd-copy heading still read '2. mentes beallitasa — <app>', because the locale key carries the
app name: it was a PAGE title, and a tab already says which app it is. New key
app_page.second_backup ('2. mentes' / 'Second backup'). This is the leftover the task named.

Found by looking at the rendered page, not the markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:51:45 +02:00
admin 02252556b3 v0.316.2 CHANGELOG
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:46:24 +02:00
admin f908eee2a3 v0.316.1: the settings tab's reused form markup loses the install page's boxes
gates / gates (push) Failing after 47s
The fields on Beallitasok are the install wizard's markup, which carries that page's card around
the whole form and one around every generated value. On a TAB those are sections, so the boxes come
off; the input itself is still a field, which is what tells the household it holds a value. Scoped
to .app-section so the install page — same column, its own form — is untouched.

Found in a browser by asking the page which elements still paint a background, rather than reading
the markup and guessing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:45:57 +02:00
admin 006ceab58a v0.316.1: the asset tag actually reaches the dashboard pages
gates / gates (push) Failing after 48s
v0.316.0 shipped it dead. Every shell asked for `?v={{.AssetTag}}`, TestAssetTag_EveryShellUsesIt
passed, and the first live load through the public address rendered `/static/style.css?v=` —
EMPTY. addShellAssetData was called only by executeTemplateLang (the sign-in, claim, recovery,
guest-share and catch-all pages). The dashboard path had never needed it, because layout.html used
to ask for {{.Version}}, which baseData supplies; moving it to {{.AssetTag}} left nothing to
supply the value.

An empty buster is a CONSTANT. It would never have changed again — worse than the per-version one
it replaced, and invisible to a test that only checks the markup. executeTemplate now calls the
same function executeTemplateLang does, which is literally what the v0.255.0 comment above that
call asked for: "setting it at the single choke point every shell renders through means a new
shell cannot miss it". There were two choke points.

TestAssetTag_ReachesTheRenderedPage renders six real URLs through the production router and asserts
the buster is non-empty, 8 hex, and not a version. Red-proved by removing the call again: four
pages fail naming the empty value.

A PATCH VERSION, not a second push of 0.316.0 — the rule this release added, applied to its own
mistake rather than around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:41:09 +02:00
admin 48d8674876 v0.316.0 CHANGELOG; the push-once fact in this repo's unprompted-work rule
gates / gates (push) Failing after 49s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:33:22 +02:00
admin 7f3e58ff0a The app page as one column — the approved mockup
gates / gates (push) Failing after 49s
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:

  ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
  SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
    is a thing you READ, so a card there is a promise the content cannot keep.
  A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
    amber when something is risky. The exception-colour principle applied to shape as well.

What moved:
  - the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
    (state, lifecycle, update) and meta text (the address, the last automatic update);
  - the tabs are links with a 2px blue underline under the open one — no pills, no fills;
  - sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
    value on the page starts at the same x, and stack under 720px;
  - the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
    wants its claim code should meet the code box, not a Restart button;
  - "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
    pages that no longer exist, on a tab that already says which app this is;
  - the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
    that said the same thing is skipped for an installed app (the install wizard still asks);
  - the "this app is already installed, the settings are read-only" BANNER becomes the hint under
    the fields it is about;
  - the install wizard gets the same back link, hero and column, so pressing Telepites does not
    land the household on a page that looks like a different product.

THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.

Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.

app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:29:56 +02:00
admin 9cc7ffcf3b A version number is pushed once, and every build addresses its CSS differently
gates / gates (push) Failing after 44s
MEASURED FIRST (audits/app-page-2026-10-15b/P0/VERDICT.md). The tab bar rendered unstyled through
the public address for one reason, and it was not the box: exactly one guest runs 0.315.0 and it
runs the registry's current digest (8887343011d6, identical to the registry's). The floor is still
0.314.0, so nothing else could have taken it.

What it was, in four measured facts:
  1. the stylesheet URL is /static/style.css?v={{.Version}} -> ?v=0.315.0 for all THREE different
     0.315.0 builds (e66d466, fe0228c, db52f5a), the first of which had no .app-tabs rules at all;
  2. the origin sends NO validator at all (ETag present = 0, Last-Modified present = 0), so a
     cached copy cannot be revalidated - it can only expire;
  3. Cloudflare caches that URL (cf-cache-status HIT, age climbing) and rewrites the browser TTL
     from the origin's 3600 to 14400 - four hours;
  4. the session's first request returned cf-cache-status EXPIRED, which only happens when the edge
     holds an entry that has aged out.

Origin, edge and box now agree byte-for-byte. The stale object itself is gone - that first probe
forced its revalidation - and the REPORT says so rather than claiming a HIT nobody can reproduce.

THE FIX IS IN TWO PARTS, NEITHER OF WHICH IS PURGING A CACHE:

build.sh refuses to push a version that already exists, before building, for --push and
--multiarch. A registry that cannot be asked is a refusal too: proving the tag is new is the point,
and "I could not ask" is not that proof. Local builds are untouched - reusing a version while
iterating is normal. scripts/test_build_push_once.sh drives the real build.sh with a fake docker on
PATH over six cases; red-proved twice (check deleted -> a present tag builds and pushes; an
unreachable registry treated as absent -> two cases fail).

assetTag() now hashes version + GitCommit + BuildTime, and EVERY shell's stylesheet link uses it
(seven of the nine asked for ?v={{.Version}}; only the two stranger-facing share pages already had
it). It stays opaque and 8 hex, because the share pages are reached by a capability URL and naming
the build to whoever holds one is a gift to somebody hunting a known fault. Cache-Control is
unchanged: the new address is the fix, not a shorter cache.

The parity harness normalises the asset tag on BOTH sides of the comparison, as it already does for
the per-session CSRF token, so all 148 fixtures stay byte-identical on disk and stop depending on
which build rendered them. The same normalisation is applied at the second comparison site in
i18n_wiring_test.go, which had the same assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:08:01 +02:00
admin 83392064ae REPORT + CONTEXT for v0.315.0: the live walk on demo-hp, and the two defects only a browser found
gates / gates (push) Failing after 46s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:32:47 +02:00
admin db52f5a4cf v0.315.0: close the log tab's <script> — the live poller was dead, and a test now says so
gates / gates (push) Failing after 49s
FOUND IN A BROWSER. The log tab's body was lifted out of the old logs.html by line range and the
range stopped one line short, so the closing </script> never came with it. Everything downstream
agreed the page was fine:

  - html/template parsed it,
  - the page rendered and looked right,
  - TestAppPage_EachTabRendersItsOwnContent found `setInterval(window.fetchLogs` exactly where it
    expected it, because the TEXT was there.

Chrome refused the whole block with "SyntaxError: Unexpected token '<'" at the `</div>` that
followed, so the live log never refreshed. A marker proves a string is present; it does not prove
the browser will execute it.

TestAppPage_ScriptTagsAreBalanced now counts <script> against </script> on all seven app-page URLs
and checks the last block is closed. Red-proved by removing the tag again: it fails naming the logs
tab, 3 opened and 2 closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:27:21 +02:00
admin fe0228c877 v0.315.0: style the app page's tab bar, and reuse the hero classes that already have rules
gates / gates (push) Failing after 46s
FOUND IN A BROWSER, and findable no other way. Every endpoint-level marker passed and the page was
unusable: the tab bar rendered as four bare underlined links run together, and the app logo filled a
third of the screen. The shell had been written with new class names — app-hero, app-hero-logo,
app-tabs — and only the first two of those had any CSS at all, under the names the design system
already uses.

  - the shell now reuses .app-info-hero / .app-info-logo / .app-info-hero-text, which carry the
    80px logo box, the hairline border and the 2px radius. Inventing a parallel name is what left
    the logo unsized; REUSE.md's first rule, learned again.
  - .app-tabs / .app-tab / .app-tab-active are genuinely new and are now styled to design system
    v2: 2px radius, hairline --line border, no shadow, --text-2 for an inactive tab and
    --blue-bright only for the one the household is on (blue marks where you are, not a deviation),
    the active tab sitting on the bar's own line, and a narrower gutter under 640px.
  - .after-start-card gets a blue left hairline — it is a request, not a fault, so no amber.
  - .app-faq gets its dt/dd rules.

TestAppPage_DeployedDefaultsToSettings sliced the header at `class="app-hero"`; it now slices at the
reused class and fails loudly if either marker is gone, instead of slicing on -1. The 36 app-page
parity fixtures are recaptured — and no other fixture moved, which is the evidence that the shell
change reaches only the app page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 19:04:57 +02:00
admin e66d4669d6 v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 18:37:50 +02:00
admin 85f65c70d8 v0.314.0: CONTEXT and REPORT after the release (floor 0.314.0 raised by CC's token at 13:11 UTC)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 15:17:51 +02:00
admin 97c992ad44 v0.314.0: the R-1060 web test (a fake Plex that refuses the name „plex”), left out of 17863fe
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 14:34:46 +02:00
admin 17863fe8bc v0.314.0: a restore puts the setup gate back as it was (R-1057); a test build moves with the floor (R-1053); Prowlarr links itself (R-1058); the box claims Plex by address (R-1060)
gates / gates (push) Successful in 47s
MinAgent 0.155.0 (unchanged). Tests red-proved by mutation (A: 10, B: 7, C: 1, C2: 2).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 14:34:33 +02:00
admin e209a8d724 Rule file: a release needs no golden; the floor carries it after the three test boxes; golden weekly (09 §3 decision 250, operator 2026-10-15)
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 11:43:16 +02:00
admin cac92a407e v0.313.0: media apps on every drive, drive events, a real restart, Plex without the race — release notes (MinAgent 0.155.0); golden next (bake → vouch → floor)
gates / gates (push) Successful in 44s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:48:58 +02:00
admin f327bc53a9 after_start deploy fields: asked after the start by this controller, still required for older ones (R-1028/R-1045, security review of catalog 0188430)
gates / gates (push) Successful in 47s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:32:51 +02:00
admin 372d6e5994 Plex (R-1045): the controller asks Plex itself whether it is claimed — after the claim and on the household's „Kész”; no template probe (an older controller would refuse the press forever)
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:29:47 +02:00
admin 37ffa3beb5 Safe removal (R-1044), found on Tester 1 G4: the drive gate leaves a safely removed drive alone (no false RETURNED/ABSENT, no mail); presence = backing device, not the agent's state
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:25:08 +02:00
admin a09cab124a mediawire: qBittorrent 5 answers a good login with 204 — accept it (found on Tester 1, Part G1); fake answers 204 like the app
gates / gates (push) Successful in 41s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 10:02:26 +02:00
admin f580d88a14 Media build part 2 (unreleased): drive wiring in every app (R-1024/R-1037), old-install repair (R-1047), drive events notice + real restart now/later + stream check, file browser lets go of dead/removed drives and recreates on return (R-1038), safe removal keeps the drive (R-1044), Plex claim on the running server + probe accept (R-1028/R-1045)
gates / gates (push) Successful in 45s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 09:54:49 +02:00
admin 78c894234c Media build part 1 (unreleased): real restart (R-1026), media apps on every PRESENT drive via an owned compose override (R-1023), dead-drive check from mountinfo, removal never deletes shared/:ro binds (R-1027), safety list knows userdata (R-1032), no drive question for media apps (R-1031)
gates / gates (push) Successful in 46s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 09:25:43 +02:00
admin 331be17e1e Rule file: bake first — bake → vouch → floor, never a floor with no golden (09 §3 decision 247, operator 2026-10-15)
gates / gates (push) Successful in 49s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-15 08:18:12 +02:00
admin 8bc7ec54cd v0.312.0: every public name answers only over https; HSTS one day (R-1033, P1)
gates / gates (push) Successful in 48s
forwarded.yml: felhom-forwarded is now a chain [felhom-https-only (redirectScheme https, 301 when the trusted tunnel
says X-Forwarded-Proto: http), felhom-forwarded-headers (the clean-up + HSTS 86400, no subdomains, no preload)] plus a
priority-1 catch-all router for unknown names over http. traefik.yml unchanged; hot-loaded.
Tests red-proofed. Evidence: felhom.eu documentation/audits/https-only-2026-10-14/.
2026-10-14 22:39:58 +02:00
admin 9b95e8e09b REPORT: R-1007 — the repair after an unclean boot, proven live on demo-hp
gates / gates (push) Successful in 42s
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 17:52:20 +02:00
admin 3b85a0c29a v0.311.0 (before the build): the repair's commands run quietly — no household file name can reach a log or the operator event
gates / gates (push) Successful in 50s
Security review of 8d862e4: compose's shared runner logs a failed command's stdout and puts its
stderr in the error, and that error became the event's `problem`. The after_unclean_boot commands now
run through a quiet runner (nothing logged, stderr discarded) and `problem` keeps the exit code only.
TestAUB_AFailingCommandLeaksNoOutput, red-proofed.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:58:20 +02:00
admin 8d862e4e69 v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s
New template block after_unclean_boot (decision 236): once per controller start that followed an
UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot
never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no
symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then
reported) and its own lost-file check is read. The household sees lost files (name, date taken) on
the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips,
with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged.
Tests red-proofed (14 guards). One new parity fixture (the new card only).

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-14 16:47:13 +02:00
admin acbbf4b305 REPORT: the machine came back by its power button; the hub ended the hold (R-998 live proof complete)
gates / gates (push) Successful in 49s
2026-10-14 00:05:58 +02:00
admin e24430cce5 REPORT: power-off (R-998) released and proven on the N100
gates / gates (push) Successful in 48s
2026-10-13 21:41:33 +02:00
admin 201f936e9a v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
gates / gates (push) Successful in 48s
POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
2026-10-13 20:17:30 +02:00
admin 1b2f74c52f v0.309.0: the default list completes groups 1 and 3 for households with no saved list (operator Q1); REPORT
gates / gates (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 17:25:12 +02:00
admin 7626a28de3 v0.309.0: the fill warning reaches the household in its own language; the root-filesystem disk event pinned end to end (R-521 F7)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 17:11:53 +02:00
admin 402fa41e45 v0.309.0: re-capture the two notification-page parity fixtures; capture settings_notifications_groups_custom (R-388 slice 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 16:50:42 +02:00
admin 462196e29b v0.309.0: the notification page shows three group switches; details and cooldown under Részletek (R-388 slice 1, decision 200)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 16:50:41 +02:00
admin 68294ecb6f REPORT: v0.308.0 (the operator's answers, 2026-10-12)
gates / gates (push) Successful in 44s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:42:31 +02:00
admin faef8f8a91 v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted
Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before
release. A box whose first ingestion failed now serves one bilingual waiting page and retries every
minute instead of the wizard. Own commit so the lead can hold it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:38:28 +02:00
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00
admin dcd89364a4 REPORT: v0.307.0 released and delivered (2026-10-10 afternoon)
gates / gates (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:24:38 +02:00
admin fc6466a50a v0.307.0: after a restart the Databases table lists each database once, not every pre-restore undo copy
gates / gates (push) Successful in 1m14s
Seen on demo-hp 2026-10-10 (adventurelog x4, bookstack x4). The synthesised run skips IsUndo files; only undo copies
-> no synthesised run. Two tests, both red-proved. v0.306.0 (R-570) was built but never delivered; 0.307.0 carries it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:19:43 +02:00
admin 9e5b88146e v0.306.0: R-570 — the off-site note decides on its kind only; the pre-0.251.0 text fallback is deleted
gates / gates (push) Successful in 1m14s
Fleet condition read on each box's settings.json 2026-10-10 (offbox.last_success ~02:16Z on 0.304.0).
Red-proof: the new no-kind row of TestR553_StaleNoteDecisionSurvivesWordingChange failed against the fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 15:08:42 +02:00
admin c255195e37 REPORT: v0.305.0 released and delivered (2026-10-10)
gates / gates (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 11:06:55 +02:00
admin 1cb43a21a2 v0.305.0: release entry (R-921 pre-check, R-922 email_cleared); MinAgent 0.131.0
gates / gates (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-10 09:55:30 +02:00
admin b3a879b50e rules: "The system poster stays true" (section 6, all five copies identical)
gates / gates (push) Successful in 1m14s
The shared rule file carries the same wording in felhom.eu, felhom-controller,
felhom-agent, app-catalog-felhom.eu and the workspace root on DooPlex --
"change all five or none" -- so this is this repo's copy of a rule added in
felhom.eu a08bd3cbd5.

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; fixes the poster's text if the change is text only; or adds
"System poster needs a refresh: <what changed>" to STATUS. The report names
which of the three it did.

The poster is drawn in Claude Design and nothing in the repo renders it, so
scripts/poster_facts_gate.py only WARNS when the facts outrun the drawing --
it never fails a push, because a refresh needs the operator and another tool.

Verified identical to the other four copies by diff, before and after.
2026-10-09 18:10:12 +02:00
admin ec9b997151 controller (unreleased): R-921 check-first pre-check (no stop while another tier's job is in flight); R-922 email_cleared on a household's deliberate clear
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 12:57:34 +02:00
admin 13eeb484ab REPORT: v0.304.0 released, delivered, D1/D3/D4 proven live (2026-10-09)
gates / gates (push) Successful in 1m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 08:36:57 +02:00
admin d7bfdccf57 v0.304.0: the decision sheet (D1, D3, D4, D8), R-899, R-304, three layout fixes — release entry
gates / gates (push) Successful in 1m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-09 06:56:22 +02:00
admin 3f84f82c3d R-893: hold the app after ANY failure once the definition or a volume moved; hold persisted before the stop; run_job done says it ran, not what it found (security review)
gates / gates (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:13:04 +02:00
admin d3e17e9b2e CHANGELOG/README/REUSE/REPORT: the decision sheet D1, D3, D4, D8 (unreleased)
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin e06680b797 Operator actions in the report reply (D1, R-314/R-279/R-177, decision 185)
The hub may ask the running controller for a CLOSED list of actions,
carried in the report ACK (operator_actions) and answered on the next
report (operator_action_results): offsite_backup_now, abandon_stop,
abandon_extend (1-30 days), run_job (fill-watch, offsite-integrity,
offsite-proof, disk-health-check). Unknown action/job/argument -> refused,
nothing called. Once per id (in memory; every action is safe to repeat).

- internal/report/opactions.go: the executor; results re-sent until the
  hub stops listing the id.
- scheduler.RunNow: refuses unknown / already-running jobs; OnDemand(ctx)
  makes an operator's offsite-integrity run even when not due.
- ExtendAbandon never shortens the countdown and refuses in the hub phase;
  StopAbandon reports failure when the hub cancel failed (it said success).
- Report ACK read cap 4 KiB -> 64 KiB (an ACK over the cap dropped every
  field in it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 63441f0a69 R-893 slice 1: a failed off-site replay after files/volumes/version moved holds the app
Decision 192 (D8, option C). After a failed off-site database replay whose
rollback worked, but where the snapshot's definition was written or a named
volume was replaced, the app is no longer started on the mixed state:
the live definition is written back, the database service stopped, and the
app held (HoldReasonRestoreMixed) for support. The sentence (hu+en) says the
app needs help and no longer claims the data is back as it was. The operator
gets a backup_run_failures mail (leg restore-hold-mixed).

The plain case (no version change, no volume replaced) keeps today's
behaviour (TestR379_ScenarioA). Red-proofed: r893_mixed_restore_test.go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 740339567a R-35: an ended session cannot return after a failed save (password fingerprint in the file; a failed revoking save removes it) — security review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 1040cfe225 R-35 (D4): dashboard sign-ins survive the controller's own restart; disk holds only a fingerprint
Sessions are keyed by sha256(cookie) and persisted to dashboard-sessions.json
(0600, tmp+fsync+rename) in the data dir: fingerprint, expiry, CSRF token.
Loaded in NewServer; expired rows dropped at load and save. Logout and
invalidateAllSessions (password change, claim reset) write the file at once.
Corrupt/unreadable file = start with no sessions (never fatal).

Red-proof: with load/save as no-ops the restart test fails ('the old cookie
no longer signs in'); with the raw token as the key the file test fails
('the sessions file holds the cookie value').

Also: TestR650_NoBareDockerExec skips a non-.go file that vanished mid-walk
(a parallel stacks test's update-journal.json.tmp raced it in a full run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin fcd9f09927 health check: the last six producers carry their dashboard sentence (R-79 option A, D3)
Docker unreachable, protected container down, and the four storage-path entries now carry a
bundle key beside their wire text; the wire text is unchanged byte for byte. Hungarian values of
the four storage keys equal the frozen formats (i18n_go_parity). Red-proven: a zero MsgRef on the
unavailable-path warning fails TestR79_RemainingProducersCarryTheirDashboardSentence and
TestR79_HealthBannersFollowTheHousehold (producer-driven, English household sees Hungarian).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 14:49:27 +02:00
admin 423b6d3404 Dashboard banners on a phone wrap inside the banner; the banner link takes the banner's colour
gates / gates (push) Successful in 1m5s
Seen on scratch 9202 with the R-906 build: once the real banner warnings showed (instead of an empty
overflow line), the link ran 90 px past a 390 px screen and rendered browser-default blue. Phone block:
.alert-banner wraps, .alert-message breaks long paths, .alert-link on its own line; .alert-link color: inherit.
Pinned in TestR907_PageHeaderWrapsOnAPhone (red against d5f2e47's CSS). No Hungarian string changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:41:18 +02:00
admin e37e9b50c6 Parity fixtures: re-capture stacks_full, stacks_unhealthy_stop, stacks_install_interrupted for the Apps card layout (R-909)
gates / gates (push) Successful in 1m1s
Reason: a deliberate LAYOUT change, not a translation. Deleted and re-written with
`go test -run TestI18nParity -update-i18n-golden -i18n-golden-only '^stacks_'`.
Measured: each equals its predecessor with exactly the template change applied (div.stack-title-text,
the address span.subdomain-text + title, the div.stack-tags row) — byte-for-byte, 3 of 3; the visible
text is identical. No Hungarian string changed; no other fixture moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:32:06 +02:00
admin d5f2e47ea1 Dashboard layout: Apps card header (R-909), launcher header wraps on a phone (R-907), overflow counted after the page filter (R-906)
- Apps card: title row (logo top-aligned, name, address cut with an ellipsis and full in title) over a wrapping
  .stack-tags row; the tags no longer share a space-between row with the name.
- Phone (768px block): .page-header wraps; the share button drops to its own line, text whole.
- GetBannerAlerts(page, lang): the layout's filter before the cap; baseData and /monitoring use it.
- Tests: TestStackCardHeaderLayout, TestR907_PageHeaderWrapsOnAPhone, TestR906_* (all red against 05e12921).
- No Hungarian string changed. The three stacks parity fixtures are re-captured in the next commit
  (this commit alone fails TestI18nParity on them; both are pushed together).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-08 14:32:06 +02:00
admin 05e129267a REPORT/CONTEXT: 2026-10-08 afternoon (R-304 operator mail, R-298 side fix)
gates / gates (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 10:25:55 +02:00
admin a40729a698 R-304 option C: operator mail when a household's code opens or may open an older package (once a day); R-298 side fix (no eject/format on a backup-target drive); R-717 comments
gates / gates (push) Successful in 59s
Unreleased; ships with tomorrow's release (needs the hub of the same day).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 10:07:03 +02:00
admin a0370b4ed8 REPORT/CONTEXT: 2026-10-08 day (R-899, R-304 on main, unreleased)
gates / gates (push) Successful in 1m1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 08:08:44 +02:00
admin 75b3b39254 R-304: the recovery screen says 'we do not know' when earlier packages were not checked (hu + en)
gates / gates (push) Successful in 54s
Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 07:53:09 +02:00
admin 6d07ca2be4 R-899: a daytime press never cancels the night's whole-guest backup (operator ruling 2026-10-08, option A); press sends trigger=manual
gates / gates (push) Successful in 1m3s
Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 07:34:03 +02:00
admin 4d3a0246df REPORT: v0.303.0 (R-897) delivered
gates / gates (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 19:06:36 +02:00
admin 29bebbb937 v0.303.0 (code): after a restart, a backup capture waits for the drive's bind (R-897)
gates / gates (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 18:45:01 +02:00
admin 431da7ebbc REPORT/CHANGELOG: 2026-10-07 day
gates / gates (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 14:21:10 +02:00
admin 191836946b Shared rule file: no hub image build or deploy in a session the operator does not attend (09 §3 decision 162)
gates / gates (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:54:53 +02:00
admin e2392935a3 REPORT: released and delivered (2026-10-07)
gates / gates (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:32:03 +02:00
admin a3e85e5a40 v0.302.0: R-542, R-516, R-330 (MinAgent 0.131.0)
gates / gates (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 09:07:48 +02:00
admin 9acc3adb96 Shared rule file: rule 11 — every helper prompt carries the brief's fences in full (09 §3 decision 160)
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 08:52:24 +02:00
admin 0629c16a4b REPORT: the second burn-down night
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 00:14:57 +02:00
admin 1de22a166a R-330: the controller decodes SMART 187/188/199 from the agent (carried only, no verdict change)
gates / gates (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 22:05:57 +02:00
admin 3d6ba2852f R-516: the formal forms in Go literals move into the bundle in the te-form
gates / gates (push) Successful in 50s
22 Go-literal messages (escrow handler, share password page, export
upload, network-storage uid/remove/attach failures) are bundle keys with
te-form Hungarian and English; a detached attach failure renders in the
reader's language. The NAS refusal says „Válassz". Setup wizard,
recovery-info.txt (R-554) and the SMART/fill-watch wire texts are left.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 21:40:25 +02:00
admin f65ace0ca6 R-542: the disk candidates no longer offer a registered, in-use drive
gates / gates (push) Successful in 1m2s
The format wizard rendered the agent's initialize list as-is, and the
agent deliberately allows re-initialising Felhom's own drives, so a
registered data drive was offered for formatting. The controller proxy
now drops every candidate that backs a registered storage path (joined
through the guest mount table) from both lists; an unreadable mount
table empties initialize.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 20:52:15 +02:00
admin 5e7522e023 REPORT: v0.301.0 (2026-10-06 evening)
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 16:10:36 +02:00
admin 0b1b8d3445 v0.301.0: R-518, R-638, R-717 (MinAgent 0.131.0)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:49:58 +02:00
admin 66aee8939b Rules: the shared rule file's copies line names five copies (the agent repo has one now, 09 §3 decision 152)
gates / gates (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:23:16 +02:00
admin ce2e31f4b2 R-717: name each test's red-proof file
The six red-proofs are saved under felhom.eu
documentation/audits/design-build-2026-10-06/E/red-*.txt; every test comment
now names its file and the reverted line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 5b8656974b R-717: after_setup open_command — the family window reopens an app's DB-held sign-up switch
The command form ran only when the lock was SET, so a database switch closed by
`command` stayed closed through the household's 15-minute window. New twin
fields `open_command` + `open_success` (same service/user/args_env and the same
argv-safe expansion and success-marker rules as `command`/`success`).

- liftNativeLock (the window, via OpenSignupWindow → goNativeLock(false)): marks
  the gate record native_lock "opening" BEFORE anything opens, lifts the env,
  runs open_command; only full success records "lifted". A failed open closes
  the switch again at once and records after_setup {ok: false, step: open}; the
  app page shows its own line (app_info.signup_native_open_failed).
- The close: reconcileSignupBlocks (every 20 s and at controller start) runs
  `command` for any non-"applied" state once no window runs. A failed close
  after a window is logged ERROR ("may still be OPEN past the household's
  window") and retried every nativeLockOpenRetry (2 min) instead of 30.
- After a successful app update, verifyAndConclude → markNativeLockForReapply
  sets native_lock "" so the loop closes the switch again.
- A template with `command` but no `open_command` keeps today's window (env
  only) and logs once per app that its own switch cannot be reopened.

Tests: internal/stacks/after_setup_r717_test.go (7), web render + parity case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 9c945688c0 R-638 option A: a replay meets the copy's own schema on the two side paths (09 §3 decision 154)
Slice 1 — the no-manifest fallback RestoreApp no longer starts the WHOLE stack
at the current definition before the replay (a newer app could migrate the
restored data underneath it). New order: resolve DB services from the live
compose -> stop -> volumes -> DB-only start (StartStackServices, the same
helper the unit restore uses, R-47) -> replay -> full start -> health wait.
A dump with no identifiable DB service is refused before any mutation (same
gate and message as the unit and off-site paths). A failed volume leg skips
the replay. restoreDockerVolumes now goes through the existing
volumeReplayFrom seam (nil in production) so the order is testable without
Docker.

Slice 2 — a unit restore whose volume leg failed no longer calls the
importer. Everything else on that failure path is unchanged: dataErr is
returned as "completed with data errors", the unit's definition is written
and the app is fully started.

No loader change, no new delete step. Red-proofs in felhom.eu
documentation/audits/design-build-2026-10-06/B/ (red-slice1-fallback-order.txt,
red-slice2-no-replay-after-volume-failure.txt).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 9d2d62831a R-518: a press with the local storage absent backs up nothing (never the off-site tier as a stand-in); an agent that flags no primary keeps the first tier
09 §3 decision 156 says the button makes the local copy only.
Test: TestManualRunTiers_NoPrimaryAvailable_BacksUpNothing (red-proved on the previous version).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin d720495cd5 R-518: backup button copy says local copy, one short stop, off-site at night (hu+en)
The 'Mentes most' hint and its confirm stated the v0.296.0 measurements
(about 6 min / 9 apps, about 8 min / 12 apps) and that the off-site copy in
the same run makes it longer. Since decision 156 the press makes the local
copy only and resumes the apps at its snapshot, so the copy now says: a
short stop of about 1-1.5 minutes (longer with more apps), the apps run
again while the copy finishes, the off-site copy follows with the next
nightly backup. Parity fixtures updated in place;
TestR518_BackupButtonStatesTheMeasuredDowntime ->
TestR518_BackupButtonStatesTheShortLocalOnlyStop (new copy on page AND in
confirm, old minutes gone, ASCII fragments).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin d8c2634472 R-518 option A: one stop per backup tier (09 §3 decision 156)
Each quiesce window now backs up ONLY the first due tier that starts and
resumes the apps at its snapshotted; the upload finishes with the apps
running. Any other due tier stays due and a later cycle (the next poll)
takes it in its own short window - never straight after the first.
This reverses R-82's 'ONE quiesce window for both due tiers'.

A manual press (TriggerNow) backs up the primary (local) tier only, picked
by the agent's Primary flag; the off-site tier follows at the next
scheduled night run. If no available tier is flagged primary, the first
available tier is backed up rather than nothing.

Unchanged: marker written before any stop, one unquiesce per window, the
max-quiesce bound, BUSY/start-error handling (the next tier is still tried
in the same window when a tier does not start), breaker and contention.

Tests: TestBothTiersDue_ExactlyOneQuiesceWindow ->
TestBothTiersDue_FirstCycleRunsOnlyFirstTier; TestNonLastTierSnapshot_
DoesNotResumeApp -> TestFirstTierSnapshot_ResumesAppWhileUploadContinues
(red on old code); new TestManualPress_RunsOnlyLocalTier (red on old
code), TestLeftoverTier_RunsInNextCycleInItsOwnWindow,
TestManualRunTiers_NoPrimaryAvailable_BacksUpFirstAvailable;
TestNotify_BothFailingTiersAreReported now runs two cycles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 15:15:33 +02:00
admin 31242786af Instruction files kept true (09 §3 decision 150): stale gate lists, paths and facts corrected; no code change
gates / gates (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 13:44:01 +02:00
admin 36088fd82e REPORT: the operator's ten answers (2026-10-06)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 12:28:46 +02:00
admin 871d05b84d v0.300.0: R-645, R-856 (MinAgent 0.131.0)
gates / gates (push) Successful in 48s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:57:06 +02:00
admin c393d8529a R-856: after a crash boot of the host, app mails wait ~15 minutes; a normal boot keeps 90 s (09 decision 143)
The dead-app check (source of app_start_failed and app_stopped_unhealthy) now gates on a crash-aware
boot grace (internal/crashboot): 15 min when the host crash guard's last boot was UNCLEAN and within
30 min of the controller start, otherwise 90 s. The fact is read from the agent's local API
(GET /host/crash-guard, agentapi.Client.CrashGuard). UNKNOWN - no agent, an older agent's 404, no
crash-guard state - is a normal boot. The decision is logged once ("boot grace ...: ... (R-856)").

NEEDS AN AGENT CHANGE to take effect: GET /host/crash-guard serving the guard's state.json fields
(present, last_boot_at, last_boot_unclean, tripped). Until then every box keeps 90 s.

Tests: TestR856_CrashBootHoldsTheMailsForTheLongGrace, TestR856_NormalBootKeeps90s,
TestR856_FactReadLateInTheNormalGraceStillCounts, TestR856_AgentProbeReadsTheCrashGuardState,
TestR856_CrashGuardDecodesAndAnOlderAgentIs404, TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace,
TestR856_NormalGraceIsTheDeadAppBootGrace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:39:25 +02:00
admin 2d63714eca R-645: the night backup skips an app whose pinned version is not the one it runs (09 decision 142)
Every night leg (DB dump, volume dump, recovery-unit capture, Tier-2 mirror) now leaves alone an app
whose app.yaml pin (pinned_images) differs from its running record (installed_images) - the state a
failed update leaves behind. A hold lifted by hand (--clear-restore-hold + restart) no longer lets the
capture write the just-failed definition over the good unit. Unknown (no pin, no record, a service not
observed) never skips. The log says it per leg; the backups page shows one amber line, hu + en
(backup.status.version_skip). Seam: backup.Manager.SetVersionCheck <- stacks.Manager.PinNotRunning.

Tests: TestR645_HandLiftedHoldKeepsTheGoodUnit (whole night run + Tier 2, unit tree fingerprint),
TestR645_VersionSkipSentence, TestR645_PinNotRunning_*, TestR645_BackupRowSaysTheNightBackupSkipsIt,
TestR645_VersionCheckIsWiredAtStartup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 11:39:25 +02:00
admin 13bda270c3 REPORT: the morning after (2026-10-06)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:50:42 +02:00
admin ff4a99a0ab v0.299.0: R-889 (df's disk percent), R-585, R-621, R-516 (MinAgent 0.131.0)
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:03:46 +02:00
admin bee2c2d815 R-889: every disk percent is df's (used / (used + available)); the tile says Rendszer, not Rendszer (/) (09 decision 138)
The box divided used by the whole filesystem, so the 5 % reserved for root made a full disk read ~5 points low
(61.8 of 68.7 GB = 90 % where df said 95 %) and the fill alarms fire late. One function, DFUsedPercent, now serves
GetDiskUsage, readDiskUsage, the recovery-unit headroom projection and the deploy page's free percent. Tests use
numbers measured on demo-hp 9201 (/mnt/sys_drive: old 21.5 %, df 23 %); a source scan refuses a percent divided by
the whole filesystem (red-proved by putting the old line back). The label measured the docker data volume, not /.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 08:03:21 +02:00
admin c67b26be28 CHANGELOG: unreleased — R-516 the bundle's last formal forms
gates / gates (push) Successful in 54s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:44:42 +02:00
admin 4c3c2037fd R-516: the formal forms the gate did not count are in the te-form; the gate's stems widened
49 Hungarian values on the debug (RESET prompt), storage, network-storage, security, system,
deploy, restore and remote-backup copy moved from the formal („ön") to the product's te-form
(„írd be", „add meg", „hozz létre", „biztosan eltávolítod", „engedélyezd", „próbáld", ...).
i18n_missing_gate.py's stem list widened by 33 stems (+ one listed third-person exception,
„sora adja meg"); it counted 61 on the previous bundle and counts 0 now, ceiling stays 0.
Decoys: a widened-stem form convicts, its te-form twin and the third-person phrase pass.
38 parity fixtures changed by exactly those bytes. Seven Go-side keys listed as REWORDED in the
go-parity map. Pinned by TestR516_WidenedFormalFormsAreGone; two tests that quoted the old
words follow them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:37:34 +02:00
admin 2fc314d491 REPORT: the burn-down night (2026-10-06)
gates / gates (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:13:03 +02:00
admin 0a960ba58d CHANGELOG: unreleased — burn-down night ctrl-d lane (R-585, R-621, R-516, R-426 controller decoys)
gates / gates (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:08:50 +02:00
admin 7044b11804 R-426: decoys for docker-v and the shared reuse-refs, instructions and observations gates
docker-v: an unallowlisted `-v /etc:/x` in a NEW .go file two directories down
under internal/ and under cmd/ convicts; controls: the clean tree and the same
line in a _test.go pass. The three shared felhom.eu scripts run against a
scratch clone of THIS repo in a scratch workspace (siblings symlinked), the
felhom-agent b78a0ff pattern: a missing cited .go/.md path, a version literal
in CLAUDE.md effective text and R-419's prose-only Observations note convict;
the real files, the version inside an HTML comment and both genuine markers
pass. DECOY_SHARED_DIR judges a mutated copy for the red-proof. All four in
COVERS, so their EXEMPT entries in decoy_coverage_gate.py can go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:13 +02:00
admin 2e5ef7e410 R-516 item 12: the last eleven formal forms in the dashboard copy use the te-form
launcher, layout remove-dialog, deploy, debug, import, remote-backup, shared
backups, system settings and the password-changed flash. Formal-form ceiling
13 -> 0 (the ratchet now refuses any new one the gate's stems see). 119 parity
fixtures changed by exactly those bytes. flash.login.password_changed listed
as REWORDED in the go-parity map; pinned by TestR516_FormalFormsAreGone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin e4774e6a06 R-516 items 7-10: one banner per drive, local times, te-form, health banners in the household's language
- item 7: the storage page shows the disconnect time in local time (fmtTimeStr), not the raw RFC3339 UTC.
- item 8: a disconnected drive no longer has two banners - the health check's warning for it is dropped
  when the dedicated alert.storage.disconnected banner was built (it stays on the wire).
- item 9: alert.deadapp.group says "nezd meg" (te-form); formal ceiling 14 -> 13.
- item 10: the disk/memory/CPU/temperature health banners show the dashboard's own sentence
  (health.* keys, hu + en) via HealthReport.WarningMsgs/IssueMsgs; the wire text is unchanged.
  checkResources split out of RunHealthCheck as the test seam.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin 0f2eab7a3e R-621: the hold panel points at the log the hold kept
A held app's page now says, inside the hold panel, that the app's own log from
before it was stopped was kept, with a Napló link to the logs page that serves
it (0b93e1a). Shown only for an update hold with a non-empty kept log
(Manager.HoldLogKept). New copy through the bundle (hu + en), parity case
app_info_hold_log captured when born.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin ff1758a21c R-585: the last customer-facing producers follow the household's language
backup_integrity_ok / backup_integrity_failed now take facts and push bundle
keys (Hungarian bytes unchanged - go-parity, pinned verbatim by
TestR585_IntegrityHungarianIsUnchanged). The interrupted-operation alert
(backup_failed, customer-enabled by default) used to send the operator's
ENGLISH sentence to every household; NotifyInterruptedOperation composes it
per language, the English byte-identical to the operator's log line. The
now-callerless NotifyBackupFailed is removed. local_api_endpoint_drift is
operator-only (no customer toggle) and already English by design - not
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 02:01:12 +02:00
admin 84d6ef706d v0.298.0: the burn-down night (MinAgent 0.131.0)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-06 00:30:36 +02:00
admin ef199c5705 CHANGELOG: unreleased — burn-down night ctrl-a lane (R-126, R-270, R-616, R-498, R-682, R-757, R-522, R-575, R-585 part, R-516 part, R-718)
gates / gates (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:20:21 +02:00
admin c4f4019721 R-718: the close-sign-up and setup-gate cards say the app restarts once
Where the app has its own switch (after_setup.env), both presses restart
it; the cards now say so (app_info.restarts_once, hu+en). Two parity
cases born with the line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:13:00 +02:00
admin d7efa7b54e R-516 items 1+4: the menu says Hibakeresés; the storage pages use the te-form
Item 1: the always-visible 'Debug' menu item reads 'Hibakeresés' in
Hungarian (key layout.hibakereses; English stays 'Debug'). Item 4: four
formal forms on the storage pages (Adjon hozzá, Csatlakoztasson,
Válassza ki x2, Önnek) are now te-form. The parity fixtures change by
exactly these bytes (a deliberate copy change, not a regeneration);
HU_FORMAL_CEILING 17 -> 14. Item 2 was already fixed (slice 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:13:00 +02:00
admin ca89e70d53 R-585 (part): three producers follow the household's language
db_dump_failed, the off-box backup_failed and offbox_enlarge_blocked
(whose sentence IS the household's mail - no hub customerMessages entry)
now take facts and push a bundle key, so an English household reads
English. Hungarian bytes unchanged (go-parity). Added to
convertedProducers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin b05be0ac11 R-575: the overcommit warning follows the reader's language
memoryVerdict returns the warning's bundle KEY (KeyMemoryOvercommitWarning)
and DeployStack hands it on; the API renders it per request
(deployWarningData). msgHU, whose only use this was, is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin b36a85ac04 R-522: a disconnected tunnel is told on the dashboard row
Since v0.292.0 (R-841) cloudflared's health check asks /ready, so a lost
connection reads unhealthy; the cloudflared row now also carries the tag
"Kivulrol nem erheto el" with a plain title, in hu+en. New parity case
dashboard_tunnel_offline (born as a key).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 1991c742a3 R-757: a secret only after_install reads is not invented for an installed app
InjectMissingFields skips a new generated secret that is in after_install's
env list and named by no compose definition: after_install does not run
again, so the app never received the value and the reveal would answer
a login the app does not have (calibre-web ADMIN_USER, 2026-10-01).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 4347983a72 R-682: a Remove cut off by a controller restart is finished at boot
RemoveStack journals itself before compose down and clears on every
return; at start a found journal finishes the remove through the same
RemoveStack (once, before the boot reconciler), keeping drive data and
backups even if the household had asked to delete them (no unattended
deletion at boot; logged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 05a447ef02 R-498: the app page's first steps name the household's address, not wiki.DOMAIN
AppInfo.WithAddress fills the catalog placeholder (<label>.DOMAIN -> the
effective subdomain + customer domain, bare DOMAIN -> the domain) on a
copy; appDetailHandler applies it. Pinned per catalog template (both
languages) and on the rendered page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 28a5203d01 R-616 (on top of R-615): the catalog clone stores no credentials
Replaces 365eff6 for main, which now carries R-615 (0b349e2). The plain
URL is cloned; the Basic credentials ride per git command as
http.<origin>.extraHeader via GIT_CONFIG_COUNT. R-615's origin compare
now compares credential-free forms against the configured URL, so a set
token never re-clones; a stored origin with user:token@ is rewritten
without it. Pinned by TestR616_* incl. TestR616_TokenSetSameRepoNoRecloneOriginClean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 6fac245d24 R-270: the local_api drift detector also names a TOKEN-only divergence
A rotation that reached bootstrap.json but not controller.yaml left the
agent channel at 401 across restarts while the endpoint-only detector
stayed silent. The tokens are now compared (constant time, boolean only);
the token case gets its own banner key and operator message. Still
detection only - nothing is reconciled (R-78).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 33c397380e R-126: refuse a .fab export without a password to a network drive (09 decision 128)
An export to a registered network drive (Kind=network) with no bundle
password now answers 400 with a household sentence (hu+en); with a
password it runs; a local drive is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 23:12:59 +02:00
admin 034a2b7363 CHANGELOG: unreleased — burn-down night ctrl-c lane (R-578, R-569, R-492, R-576, R-574, R-488, R-325)
gates / gates (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:30:45 +02:00
admin daeed27223 R-325 (controller half): retrieval_promise_gate imports the shared stem list
STEMS now comes from felhom.eu/scripts/customer_copy_vocab.py (RETRIEVAL_STEMS), read the
way controller_gates.py reads the other shared checkers (CI clones felhom.eu beside this repo);
an absent or empty vocabulary is INCONCLUSIVE (exit 2), never a pass. Decoys: vocabulary
absent -> rc 2; an invented stem in a doctored shared list convicts a template using it.
hub_copy_gate.check_drift on this file now reads 'ok — single source'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 8ce496f124 R-488: backup tests read ONE health-wait clock; package 444 s -> ~2 s
waitForHealthy's 3 s settle / 5 s poll / 90 s timeout become package vars (production values
unchanged, pinned by TestR488_HealthWaitDefaultsAreProduction); TestMain shortens them once.
Measured before: 51 of 599 tests held 442 of 444 s, two not-running fixtures 279 s alone.
newOffboxManager now installs a failing SSH fake: TestOffbox_ConfirmedReset was spawning a
real 'ssh felhom@nas.local' and waiting out its 10 s ConnectTimeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 391967bcf1 R-574: handler_debug.go literals classified; the 38 page-copy ones move to the bundle
39 Hungarian literals: 38 are debug-page copy (every writeDebugJSON message/error, rendered
into the result line by debug.html, plus the pre-0.83 agent notice rendered in the log
viewer) -> 30 debug.api.* keys (hu byte-equal, parity gate green; en added). One is PAYLOAD:
the 'Teszt esemény' text sent to the hub as the test event's message stays a literal. The
diagnostic dump carries no Hungarian. TestR574_DebugPageCopyFollowsTheLanguage pins hu bytes
and en rendering.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 074ddda352 R-576: go-parity gate also judges the CALL — arity vs printf verbs, and no key glued with +
Checks 4 (ARITY) and 5 (NO-CONCAT) in i18n_go_parity.py: at every message-helper call
(MsgError/MsgErrorf/Text/Msgf/msg/msgLang/msgHU/note) whose key is a literal hu.json knows,
the argument count must equal the Hungarian value's printf verbs; a known key literal may
never be an operand of +. 485 calls judged, 0 defects today. Decoys: a dropped argument and
a key+suffix concatenation on a real producer convict; a nested-paren argument is accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:23 +02:00
admin 7b8216abac R-492: delete the always-empty cfg.Paths.HDDPath (field, env binding, every fallback)
Readers (report builder, health check, /api/system, web primaryHDDPath, metrics collector,
AutoDiscoverStoragePaths' fallback parameter) now use the storage registry only. An old
controller.yaml still carrying paths.hdd_path keeps loading (non-strict YAML), pinned by
TestR492_OldConfigWithHDDPathStillLoads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin d15ad105be R-569: stack-lifecycle handlers pick their status by error KIND, not English words
stop/start/restart/update, remove and delete matched "protected", "not found", "not deployed",
"still running", "not orphaned" in err.Error(). New sentinels in internal/stacks
(stack_errors.go) carried by the producers in manager.go/delete.go via util.KindErrorf (message
bytes unchanged); api.stackOpStatusFor maps them. Tests: reworded-message table per family,
wiring check over all three handlers, producers keep kind + words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin 9271f33359 R-578: settings-callback deadlock gate over every controller package
TestR578NothingTakesTheSettingsLockInsideASettingsCallback (internal/settings) derives from
source the *Settings methods that take s.mu, the callback runners among them, and per package
every helper that transitively reaches one; a call to any of those inside a func literal
passed to a runner (or a non-literal callback) fails with file:line. Decoy:
TestR578GateConvictsAHelperChain. REUSE.md lookalike row added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:24:22 +02:00
admin f7c39c1700 CHANGELOG: unreleased — burn-down night ctrl-b lane (R-729/R-545, R-621, R-839, R-271, R-521, R-607/R-615, R-25)
gates / gates (push) Successful in 51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 22:03:52 +02:00
admin 1355391c78 R-25 (controller half): drive init mounts the filesystem UUID the agent verified
agentapi FormatResult / FormatStatusResult gain fs_uuid (agent R-25 half). runStorageInit mounts
it — from the synchronous answer or the polled status — instead of re-resolving the UUID from the
/dev path, so a node that moved between format and mount cannot redirect the mount. An agent that
sends no field keeps the old resolve, logged as a WARN each time (the window is open then).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 0b349e2415 R-607 + R-615: the catalog sync follows a changed repo_url and rescans when the catalog moved
R-615: a changed git.repo_url was inert (the clone's stored origin was fetched for ever). The sync
now compares the configured URL with the clone's origin: another repository -> drop the cache and
clone it; the same repository with new credentials -> set-url.
R-607: a catalog move that changed no stack directory (a deployed, pinned app keeps its stored
definition) skipped the rescan, so CatalogImages and the update badge stayed stale, and the sync
said 'nincs változás'. The sync now notes the cache commit before and after, rescans when it moved,
says so, and returns catalog_moved.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 800b32ceec R-521: an app a lost drive stopped no longer mails app_start_failed one by one
One unplugged drive sent storage_disconnected plus an app_start_failed per app on it (five operator
mails). The dead-app job now hands the notifier the apps of a disconnected drive (its recorded
StoppedStacks plus apps whose HDD_PATH is that drive) as not down; the storage event already names
them. The dashboard's dead list is unchanged. The hub-side cooldown (F6/F7) and a household mail
for a lost drive are not in this change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin f885100d29 R-271: the channel recovery after a controller restart is reported
The agent_channel_unauthorized alert's remedy is a re-bootstrap (a restart), and an unseeded->up
first observation was silent, so following the instruction guaranteed no recovery event. A down
alert now leaves a marker in the data dir; the first UP after a restart sends the recovery and
clears it. A restart with no alert outstanding stays silent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 4867ec1804 R-839: the deploy API refuses an HDD_PATH that is a folder inside a registered drive
Diagnosed: 9202's paperless-ngx app.yaml named <drive>/userdata/paperless-ngx, written by the
2026-09-22 drill harness through POST /api/stacks/<n>/deploy, which accepted it (RefuseAsAppNamespace
matches by prefix). The boot sweep then asked a folder whether it was a mountpoint and HELD the app.
The deploy page offers drive roots only, so a household could not reach this; every other caller
is now refused with a sentence naming the drive (settings.DriveOfSubPath).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 0b93e1ad1e R-621: a held app's logs show the log the hold kept
GetLogs (logs page, /api/stacks/<n>/logs, the operator's remote diagnostics) returned "" for a held
app — no containers — while captureHoldLogs had kept the app's own log under
hold-logs/<ts>/compose-logs.txt. It now serves the newest non-empty kept log, under a header naming
the file, when the live log is empty; a running app's own output always wins. GetLogs gains a test
seam (logsComposeFn) so the test never reaches docker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 27b373b93c R-729 + R-545: the household can remove its own off-site target („Távoli mentési cél törlése")
POST /backup/offbox/clear (reveal-then-confirm) forgets the target and deletes ssh_key + known_hosts;
nothing on the target is touched. repo_password is kept whenever anything could depend on it (hub
sealed package — the R-241 rule; escrowed; a successful run; snapshots) and deleted only otherwise.
Refused for the Felhom tier (rclone-pinned), while the single-flight is held, and while an
abandonment countdown runs. i18n parity fixtures of backups_remote gain the additive block only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 21:55:37 +02:00
admin 6f1ba1fe43 gofmt gate: NOT CHECKED (out loud) on the Go-less CI runner, INCONCLUSIVE elsewhere; decoys for both (CI run 1371 was red)
gates / gates (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 20:15:54 +02:00
admin 1453cfc69b v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 20:12:56 +02:00
admin 114ff2761a R-263 writer invariant pinned by an AST test; R-368 IsDefault comment exact (no binary change; burn-down)
gates / gates (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 17:05:24 +02:00
admin e563733282 CHANGELOG: R-818 correction notes under v0.224.0 and v0.225.0 (mislabelled R-330/R-331)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 16:53:22 +02:00
admin df85a07c4c the size rule: a small finding is fixed in the session, not filed; reports state four register numbers (burn-down Part D)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 16:43:23 +02:00
admin 7690c27f86 controller v0.296.0 REPORT
gates / gates (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 13:00:18 +02:00
admin ff69074514 controller v0.296.0: a backup run cut off by a power cut or restart is said on the backup pages (R-519); the whole-system backup text states today's measurement (R-518)
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 12:14:54 +02:00
admin 477e2548db REPORT: the 2026-10-05 catch-up session
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 10:29:08 +02:00
admin 635c33d381 v0.295.0: a box that was off at its backup time catches up once (R-871, decision 109); the missed-backup banner (decision 110); a late daily timer after a host suspend is skipped
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 09:27:16 +02:00
admin e730a629fd REPORT: the 2026-10-05 night-fixes session
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 08:25:44 +02:00
admin 7861bf9dde v0.294.0: off-site clean-up guard follows the policy's own constants (R-867); no image clean-up while compose pulls (R-863); stderr tail (R-864); move-aside destination logged (R-869)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-05 07:13:02 +02:00
admin 69e914534f REPORT + CONTEXT: 2026-10-04 night (R-840 / R-860)
gates / gates (push) Successful in 33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 20:39:49 +02:00
admin 2a7f6c6c42 v0.293.0: the controller heals itself after the guest's Docker socket is re-created (R-860)
gates / gates (push) Successful in 29s
internal/sockheal: 60 s of refusals (never a timeout, only after Docker answered once) → exit 75 so
Docker's restart policy brings the controller back on the current socket; every 5 min it restarts any
other socket user (traefik) holding an older inode. Measured on 9202: only a docker.socket restart
re-creates the file; dockerd crash / docker.service restart keep it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 19:48:29 +02:00
admin 09e634de31 v0.292.0: cloudflared readiness health check (R-841) — tunnel --metrics localhost:20241 run + Docker healthcheck on cloudflared's own /ready; the host agent reports running/not_running/unknown from State.Health
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 13:16:26 +02:00
admin badc9c2a0c CHANGELOG + comment: correct the file-browser claim (the start-up mount sync already moved it); 9202 gaps measured
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 11:00:54 +02:00
admin fc796dd95e v0.291.0: decision 78 (R-726) — a returning household's first night sets the orphaned copy aside; R-838 — traefik v3.7.13, cloudflared 2026.9.3, filebrowser 1.5.6-stable, a release now moves a running file browser, check-infra-pins.py
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 10:56:19 +02:00
admin 99a1497560 CONTEXT + REPORT: v0.290.0 (floored, golden 0.290.0 vouched)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 07:39:45 +02:00
admin c1a73b24b3 v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-04 07:27:38 +02:00
admin c4bf730637 CONTEXT + REPORT: v0.289.0/v0.289.1 off-site lock (floored 0.289.1, golden 0.289.1 vouched)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 21:29:10 +02:00
admin 2a2af2018d v0.289.1: strip the provider rclone notice; an unreadable snapshot count is never a measured zero (found live on demo-felhom)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 17:15:10 +02:00
admin 55bb6c3d32 v0.289.0: off-site key cannot delete — append-only rclone transport, box sends only its public key (hub registrar), retention only inside a hub window behind the fake-snapshot guard (decisions 68-69, R-820, R-822)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-03 17:01:36 +02:00
admin 09453325d1 v0.288.0: REPORT + CONTEXT (R-800 live on 9202, floored, golden 0.288.0 vouched)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 16:54:36 +02:00
admin 580b656914 v0.288.0: the remove dialog and result name the household's userdata folders, which stay (decision 67, R-800)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 14:03:44 +02:00
admin 9821690980 v0.287.0: REPORT + CONTEXT (the family gate live on 9202, floored, golden 0.287.0 vouched)
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 09:03:51 +02:00
admin c8d5ee204d v0.287.0: CHANGELOG, README, REUSE (the family gate)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:43:49 +02:00
admin 977665d8c0 The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 07:42:47 +02:00
admin a4a753b9e2 CONTEXT + REPORT: v0.286.1 (R-753 visitors apart, R-772, R-773), floored + golden
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 22:06:02 +02:00
admin a28155f858 v0.286.1: CHANGELOG (R-772 order fix found live; 0.286.0 never floored)
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:41:23 +02:00
admin 79ef715277 R-772 (found live on 9202 with v0.286.0): a stopped probe container is seen on the next tick, not when the last healthy record's 5-minute interval runs out
Finding the container costs no network call, so it now runs before the interval check. Red-proof RP-D1b.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:11:22 +02:00
admin d4167420d1 v0.286.0: CHANGELOG + README (R-753 visitors apart, R-772, R-773)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:04:26 +02:00
admin 1e216d3468 R-773: a removed app restored from its backup gets its sign-up lock back (record opened_by restore + block, before anything starts)
gates / gates (push) Successful in 29s
An installed app the household never closed keeps what it had (decision 49). Red-proof RP-D2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin b53721db34 R-772: a health probe that could not run records not_checked + NOT healthy, is looked at again on the 10 s cycle, and the app page says so
The state stays the containers' (probeSaysUnhealthy skips a not-checked record), so R-630 holds.
Red-proof RP-D1 — felhom.eu audits/visitors-2026-10-01/D.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin 1e8d045815 R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw
- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure
  trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes
  the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …)
  and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port).
- EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite
  that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network.
- clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw);
  the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64.
  Dashboard login, claim, share and escrow counters key on it; the setup gate logs it.
- Dashboard login messages: keys, informal voice, both languages.
Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 21:01:42 +02:00
admin c1b123c649 REPORT: v0.285.0 — two controller versions per box, R-751, live results
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 08:39:51 +02:00
admin ac8ea72025 v0.285.0 — a box keeps two controller versions (decision 56, R-745); the update clean-up's nil-stack crash (R-751)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-01 07:36:26 +02:00
admin a70c398dee v0.284.2 — the image clean-up sees digest-pulled (untagged) images (decision 53, R-736)
gates / gates (push) Successful in 26s
Found live on 9202: the product pins tag@digest, and such images are stored untagged (repo:<none>);
`docker image ls` without -a did not list them, so the retention saw almost no app image. Now `image ls -a`;
an anonymous <none>:<none> entry is never a candidate; the one-time marker is v2 so the corrected sweep runs
once everywhere. Test TestImageRetention_SeesUntaggedDigestPulledImages, red-proofed. 0.284.0/0.284.1 were
never floored.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 23:03:32 +02:00
admin 00fddd3816 v0.284.1 — the image clean-up runs on the household's Remove button too; one summary line per pass (decision 53, R-736)
gates / gates (push) Successful in 26s
Found live on 9202: v0.284.0 wired the remove half into DeleteStack only; the app page's Remove runs RemoveStack.
Now RemoveStack reads the app's image repositories before its compose down and runs the retention after. Every
retention pass logs one line (images seen, candidates, deleted), so a pass that kept everything is visible.
Tests TestImageRetention_TheRemoveButtonRunsIt / ADoneUpdateRunsItWithThePrevious, red-proofed. v0.284.0 was never
floored (scratch 9202 only).

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:54:15 +02:00
admin 5a3437669f v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
gates / gates (push) Successful in 27s
Image retention: after a done/undone guarded Update and at remove, an app's images older than its running
and previous one are deleted — never an image any container, installed compose or installed/previous record
names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs;
a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed
behind the setup gate's door and opens when after_install succeeds or the household says it changed the login.
Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:29:33 +02:00
admin d48da6c7f3 CONTEXT + REPORT: v0.283.0/v0.283.1
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 11:00:41 +02:00
admin 29d3c533aa v0.283.1: a Stop holds during the volume dump in production and at the crash recovery (R-721, found live on v0.283.0)
gates / gates (push) Successful in 28s
Red-proofs RP43, RP44. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 10:52:19 +02:00
admin 6be6c53e29 v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 10:31:55 +02:00
admin 1cfb1244d6 REPORT: v0.282.0
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 17:19:37 +02:00
admin 4110da50e9 v0.282.0: after_setup (the app's own sign-up switch), close sign-up now (decision 49), probes read lists + done_status (R-715)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 16:19:12 +02:00
admin 6fc3059167 REPORT: v0.281.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 11:24:58 +02:00
admin 149467c795 v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 09:57:58 +02:00
admin 3a12c7341a REPORT: v0.280.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 09:22:53 +02:00
admin 7fa8768cfd v0.280.0: the setup gate (decision 46); R-710 'I changed it' + absent-record window; R-709 password fields off the page; password:N:special generator
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-29 08:51:46 +02:00
admin 2548b4c924 REPORT: v0.279.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 19:02:26 +02:00
admin 0c702f834a v0.279.0: after_install (decision 45), known default logins on the page, Part D empty-backup alarm, night chain (R-705), R-706
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 18:38:30 +02:00
admin 2e9a948cbd REPORT: v0.277.0 + v0.278.0, live off-site proof
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 15:56:46 +02:00
admin 7cba0bfca7 v0.278.0: a hold left by an earlier install no longer holds the new one (R-704)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 15:34:57 +02:00
admin 1ee6dbe184 REPORT: v0.277.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 11:51:52 +02:00
admin 6479933e8e v0.277.0: kept data loads from the off-site copy too (R-691 (2))
gates / gates (push) Successful in 25s
Use my kept data / Load consider the off-site snapshot when it is newer than every local copy or
the only one; the unit is downloaded alone, judged (drive, data, recorded data version) and only
then restored. The page names the copy and its date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-28 09:57:11 +02:00
admin 0c1023e503 REPORT + CONTEXT: v0.276.0 released, floor 0.276.0
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 18:04:03 +02:00
admin 820e8efde1 v0.276.0: a restore and a drive move keep the app's records (R-697, R-700)
gates / gates (push) Successful in 26s
A drive move persisted through the restore's fresh app.yaml write and dropped the pin: the syncer
then copied the catalog verbatim and the next start jumped the app past its ladder (R-700).
persistDriveFlip now changes HDD_PATH and nothing else. The restore's write carries the life
records (conversion copies, desired_state, update history) from the app.yaml it replaces, and a
second conversion no longer overwrites the first kept copy's record (R-697).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 17:59:04 +02:00
admin 54bb4da343 REPORT + CONTEXT: v0.275.0 released, floor 0.275.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 13:02:06 +02:00
admin 7fcda8f1a4 R-699: a unit that holds no data is never an update-precondition copy (found live on 9202)
gates / gates (push) Successful in 25s
A just-installed app's unit, captured by the status refresh before any backup, satisfied
the precondition on its manifest time; tandoor's PostgreSQL was converted with no backup
of its database. Listed still; never a copy on Tier 1 or Tier 2. Red-proof RP6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-27 12:04:03 +02:00
admin b6810f14ff v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the
definition the data belongs to; a restore never starts data under another version's
definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's
definition); every tier's time is its data's; the conversion-copy release needs a dump on
the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept
view joins the folder's owning group, language switch resyncs (R-691); a restore-generated
login is not shown as the password (R-694). Red-proofs in
felhom.eu/documentation/audits/version-travel-2026-09-26/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-26 10:35:22 +02:00
admin fb2bcdd5d6 REPORT: v0.273.0 + v0.274.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 14:39:12 +02:00
admin 64cbefe317 CHANGELOG: v0.274.0
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 14:28:57 +02:00
admin 064f23896a kept data: the list names a leftover by the app that binds it through HDD_PATH, never the file browser (R-692, found live on 9202)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:51:26 +02:00
admin 43e99d160c kept data: the choice at reinstall, the list, the read-only view, the load (09 decision 36); R-690 fixed
gates / gates (push) Successful in 26s
An install over an app's kept drive folder (appdata/<app> non-empty) asks the household:
"use my kept data" (a load from the newest copy of THIS drive's install, own unit or
second-drive mirror, then the template's after_load) or "start fresh" (the folder is
renamed into <drive>/kept/<app>/<date>/ with the removed app's unit; nothing deleted).
The install API answers 409 kept_data_choice until one is chosen; DeployStack refuses
too. New page Megorzott adatok / Kept data (/kept-data): Load / Look / Delete (typed
confirmation, the only deletion of kept data). FileBrowser gets a read-only source.
The drive-full warning names the kept folders. <drive>/kept is protected and outside
every backup leg.

R-690: the removed-app restore (R-487) never found a unit on a DATA drive — it asked
GetStackComposePath (true for every catalog app) and restored nextcloud with no env.
Now isStackDeployed; pinned with a production-shaped provider.

Red-proofs: audits/night-2026-09-26/E/redproofs/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:32:30 +02:00
admin 5a731b45b9 CHANGELOG: v0.273.0
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:30:50 +02:00
admin 7162f489c9 README + REUSE: the PostgreSQL conversion (v0.273.0)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:30:08 +02:00
admin 1c5dd07604 stacks: R-687 — an empty leg reports steps [], and a taken files_may_change step names its whole copy
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:24:19 +02:00
admin b6399fdf0b stacks: the recovery line names CONVERTING, not UNDOING, for a restart during the conversion (found live on 9202)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 13:18:56 +02:00
admin 2caae38a71 stacks: the box converts a PostgreSQL major as a guarded-update step (09 6.4 part 10, decisions 35/37/38)
gates / gates (push) Successful in 25s
A step whose ladder entry carries engine_conversion {service, engine, from, to}
converts the database: the old engine alone, the check (owners, roles,
extensions, per-table row counts), pg_dumpall validated by its completion line,
the volume emptied only after the undo copy's marker is validated again, the new
engine alone, the load with ON_ERROR_STOP, the check again + PG_VERSION. Any
failure goes to the existing undo; a restart during converting is undone.
A PostgreSQL major move without the mark is refused before anything moves.
The old datadir's copy is kept until a backup is proven after the conversion.
17 tests, 9 red-proofs (audits/night-2026-09-26/B/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 12:57:03 +02:00
admin 3d49df1e5a REPORT + CONTEXT: v0.272.0, floor 0.272.0
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:33:46 +02:00
admin 44ae4dea70 controller v0.272.0: the backup page says when a whole-box backup does not fit (R-685); R-671, R-670, R-677
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:19:27 +02:00
admin 71accca427 rules: Peti's box retired 2026-09-25 — the fence is DooPlex and ep0
gates / gates (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 11:04:55 +02:00
admin cdfc817d5e REPORT + CONTEXT: v0.271.0 (automatic app updates), floor 0.271.0, the demo boxes' first real night
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-25 04:55:09 +02:00
admin 9cf13a3add controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 22:07:24 +02:00
admin 1495ca43fb REPORT + CONTEXT: v0.270.0 (floor 0.270.0, both demo boxes arrived)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 17:04:48 +02:00
admin 504eae018b v0.270.0: no update for a current app (R-679); an interrupted install is reported (R-681); a restore brings back the pinned version's health check (R-669); R-674
gates / gates (push) Successful in 24s
Five red-proofs. MinAgent 0.131.0 unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 16:49:43 +02:00
admin 7c6140d95b REPORT + CONTEXT: v0.269.0/v0.269.1 night (floor 0.269.1)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 14:53:45 +02:00
admin 5b1b191ffc v0.269.1: an installed app keeps the image digest it runs until a guarded Update moves it
gates / gates (push) Successful in 26s
Found live on 9202 (night 2026-09-24 Part B): the sync rendered the ladder's newest
tested digest into a RUNNING app's compose, so the next restart would pull a new image
with no backup and no undo. stacks.CarryDigests keeps the running digest for an
installed app; a fresh install still takes the tested digest. Red-proofed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 13:01:31 +02:00
admin 3c6b49b31c controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 12:18:39 +02:00
admin 7c3b3a9694 REPORT + CONTEXT: v0.268.0 (2026-09-24)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 08:52:55 +02:00
admin 206b0357d1 controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-24 08:15:27 +02:00
admin 80e6ad8c47 controller v0.267.0: tests off DooPlex's Docker, cut-off copies refused, two pages true
gates / gates (push) Successful in 26s
R-650: internal/dockerexec — every docker exec routed through it; under
go test a real docker is refused (opt-in FELHOM_TEST_REAL_DOCKER=1; a stub
under the temp dir is allowed). api/stacks/web tests run under a silent
stub (TestMain). TestR650_NoBareDockerExec pins it repo-wide.
R-640: a dump without its engine's completion marker is refused before
the first mutation (unit + off-site restore) and again before any load.
R-499: the Tier-2 page's system-disk sentence has four true branches.
R-518: the backup button states the measured ~8 min stop.
R-626: measured on 9202, not reproduced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 20:25:28 +02:00
admin 15e630aa02 REPORT: v0.266.0 addendum (R-649)
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 18:14:42 +02:00
admin 964ae7538f controller v0.266.0: a failed install removes what it started (R-649, operator ruling)
gates / gates (push) Successful in 27s
compose down (volumes kept) before the record reads not deployed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 18:06:29 +02:00
admin 411a43f8ef REPORT + CONTEXT: v0.265.0 live proof and floor raise
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 17:39:48 +02:00
admin 0054d4bd69 controller v0.265.0: R-634 cause fixed, held apps say so, OOM storm alarm, R-647 leftovers
gates / gates (push) Successful in 27s
R-634: a whole-box backup no longer stops/restarts a DEPLOYING app (the
measured cause of containers running under 'not deployed'); StopStack
and StartStack refuse a deploying stack for every caller.
R-625: held badge 'Stopped - restore needed', no Update button.
R-636: kernel oom_kill counter; 20+ in 30 min -> one app_oom_storm.
R-647: held error per reader, copy_holds key, two log wordings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 17:16:57 +02:00
admin 0a3026180a REPORT + CONTEXT: v0.264.0 live proof and floor raise
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 14:39:12 +02:00
admin bc278944a3 controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s
app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 13:51:21 +02:00
admin c3a2aba0d2 docs: CONTEXT and REPORT for v0.263.0-v0.263.2 (the undo)
gates / gates (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 12:25:44 +02:00
admin 2cd66663f3 v0.263.2: the undo keeps the probe of the pinned version (R-637)
gates / gates (push) Successful in 25s
Found live on 9202 (romm): .felhom.yml flows into the stack dir on every
catalog sync, so "the old .felhom.yml" saved at update time was already the
new one, and the serving old version was judged with the new probe.

New record applied-meta/.felhom.yml, written whenever a version is pinned
(deploy, adoption, pin advance) and put back by the undo, like
applied-compose.yml. The fixture now places the new file at sync time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:53:53 +02:00
admin 5d38573a1a v0.263.1: the undo asks the old probe even on an app marked unhealthy (R-637)
gates / gates (push) Successful in 27s
Found live on 9202: the periodic probe (current .felhom.yml, new port) flips
the app to unhealthy, and the update's health wait probed only 'running'
apps - so the undo's old probe was never asked and a serving old version was
judged "did not start". With the undo's override, an unhealthy app is probed
and the old check decides; never settled on container state.

New seam probeRunFn; the test drives the real wait loop and reproduces the
live message when the fix is switched off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:35:25 +02:00
admin 8fc2b4a1a9 v0.263.0: a failed update puts the app back by itself (09 decision 15, R-637)
gates / gates (push) Successful in 26s
The guarded update gains a folder copy of the app's named volumes, taken
after the pull where the app stops anyway (decision 19, chosen by the
2026-09-23 bake-off). On a failed health check the box undoes: every copy
validated by its finished-marker first, volumes refilled, definition and pin
from the job's own pre-update copies, the old version checked with the OLD
.felhom.yml probe. It holds only if the undo fails, and the hold sentence
says so and what state the data is in. Bind-mounted folders are never
touched.

- R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639
  (pre-update copies incl. .felhom.yml kept until the undo is over).
- journal phases copying/undoing with power-cut recovery.
- app.yaml last_update_undone + one line on the app page (hu/en).
- R-642: start/restart never answer "completed".
- Removal deletes kept undo copies.

MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-23 11:12:49 +02:00
admin b9deec1907 REPORT for v0.262.0 + v0.262.1
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 21:49:20 +02:00
admin 3d41758be0 v0.262.1: the busy refusal is a CONFLICT, not a server error (R-633)
gates / gates (push) Successful in 24s
Caught by the live proof on 9202, not by a test. The v0.262.0 guard fired exactly right and
answered HTTP 500: router.go maps remove errors by grepping the error TEXT for "not deployed" /
"still running" / "not found" / "protected", and the busy sentence contains none of them.

A 500 tells the UI something broke; this is "wait a moment". Now a typed *stacks.RemoveBusyError
matched with errors.As and answered 409, carrying both the Hungarian bytes and the bundle key.

Its test asserts the sentence contains none of the words the text mapping greps for, so the type is
load-bearing rather than decorative. Red-proof seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 21:48:01 +02:00
admin b793638484 v0.262.0: six defects two drill nights found in the update, remove and hold paths
gates / gates (push) Successful in 26s
R-630 (P1): waitUpdateHealthy kept the probe inside `if hc != nil && len(hc.Checks) > 0`, and when
findProbeContainer returned "" its else set last="no probe container" and LOOPED - the settle path
sat in the outer else, unreachable. So verifying could only time out and failAndHold then stopped a
working app. Measured on paperless-ngx: three containers healthy, failed at +313.0s, front door 404
after. It now falls through to the same settle path with a WARN naming the candidates.

The probe target is decidable now: HealthCheckConfig.Container plus findProbeContainerMeta resolve
by exact stack name -> explicit container -> a UNIQUE prefix -> nothing with the candidates
returned. The old rule took the FIRST prefix match. A skipped stack records why instead of silence.

R-634 (half): RemoveStack refused on the !Deployed FLAG while the machine had containers, a compose
file and an app.yaml. It now asks whether anything EXISTS. The mechanism producing the bad record is
still not diagnosed and R-634 stays open for it.

R-633/R-626: RemoveStack consults UpdateGuards.Busy and IsUpdating and refuses with the app's own
sentence - the product already refused this clash for update and for restore. And because `down`
returning 0 is a request not a result, the project is watched for 25s afterwards, anything carrying
its label is removed by name with its labels logged, and the answer carries `verified`.

R-621: failAndHold writes compose logs --tail 400 into <stackdir>/hold-logs/<ts>/ BEFORE the down
that destroys them. Two existing tests pin the compose sequence and correctly caught the new step;
their expectations are updated with the reason that the ORDER is the assertion.

R-614: RemoveStack calls ClearUpdateState.

NOT in this release: R-625 (a held app still renders an Update button). Named, not half-done.

Three new sentences, each born as a key in both bundles. Four red-proofs seen failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-22 20:46:50 +02:00
admin 93cee16843 CONTEXT + REPORT for v0.261.0 (R-608, R-609)
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 15:00:29 +02:00
admin 811f75736e v0.261.0 — the controller no longer swaps itself out from under an app update (R-608, R-609)
gates / gates (push) Successful in 23s
The controller self-updates daily at 04:30 by default, and after any hub report
once a floor sits above the box. That swap restarts the controller container.
The window proposed for automatic app updates is 02:30-05:00. It contains 04:30.

R-608 — a two-way lock, wired in main.go (stacks never imports selfupdate):
- stacks.Manager.AnyUpdating() -> Updater.SetAppUpdatingCheck, consulted in the
  same three places as the existing backupRunning gate.
- Updater.IsUpdateRunning -> Manager.SetSelfUpdatingCheck; UpdatePreflight
  refuses `self_updating`.
- MEASURED: the gap was narrower than assumed. The update's `backing-up` phase
  already takes the backup single-flight, so that one phase was covered. The
  other six were not, and `starting`/`verifying` are where data may have moved.
- The lock must NOT latch: a held app does not block the controller's own
  updates, including the release that might fix the hold.

R-609 — the 409 carries `data.reason`, additively. transient (busy, updating,
deploying, migrating, self_updating) vs terminal (held, downgrade). Found while
writing the test: the router refuses a HELD app on its own line before the
preflight, so `held` would have been the one reason missing.

Five red-proofs, each seen to fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 14:23:59 +02:00
admin d0d431b42b Correct a stale count repeated in four places: 10 floating pins, not 23
gates / gates (push) Successful in 25s
Recounted at catalog 18a6d2d8: 66 unique pins — 48 full X.Y.Z, 6 two-part
lines, 4 major lines (10 float), 8 exact versions wearing a variant suffix.
The '23' carried since v0.233.0 matches no definition the catalog supports.
Definition written down beside the number so it can be rechecked.

Also: CONTEXT said the fleet floor was 0.257.0; the hub says 0.259.0.

Comment and doc only — no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 13:02:48 +02:00
admin bdeb1c0d8e REPORT + CONTEXT for v0.260.0 (R-524)
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 12:59:26 +02:00
admin 8f8a64cad7 v0.260.0 — a box ahead of the catalog reads „Naprakész", and the pin never moves backwards (R-524)
gates / gates (push) Successful in 24s
MEASURED 2026-09-15 (BIGNIGHT Phase 6): privatebin updated 2.0.5 -> 2.0.6, catalog
reverted to 2.0.5, and the box read „Frissítés elérhető — ma" over an Update that
would have moved the pin BACKWARDS onto a possibly-migrated datadir.

- stacks.CatalogOrder: the comparison gains a fourth answer (Ahead) and moves out of
  web, so the badge and UpdatePreflight cannot drift apart.
- The badge: ahead reads „Naprakész"/"Up to date", tag-ok, with a title saying why.
- The refusal: UpdatePreflight returns `downgrade` (409), born as a bundle key; the
  API now renders update refusals through errText so it reaches English households.
- Ahead is narrow: every differing service must be orderable AND newer, else Behind.
- Ordering is util.Version.Compare behind a tag normaliser — no second comparator.
- Three red-proofs, each seen to fail.

R-589 was already fixed in v0.258.0; only its register row was stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 12:48:55 +02:00
admin 19ef0329ab CONTEXT + README: v0.259.0, and the two probe traps that cost the most
gates / gates (push) Successful in 25s
The composed-sentence-into-page-data class is now five instances and has its
own warning block in the README's i18n section, with the two things that make
a live probe lie: the felhom_lang cookie is ignored behind auth (so an English
probe of /backups returns Hungarian and reads as unfixed), and an apostrophe in
an English value is escaped so no assertion on the rendered page ever matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 08:07:27 +02:00
admin 0fe04bb6d5 v0.259.0 — the claim page and the backup warnings answer in the reader's language (R-596, R-598)
gates / gates (push) Successful in 28s
The 2026-09-20 English drill ended one screen short: the claim page was English
and its answers were Hungarian, so a household who mistyped the code from their
e-mail could not tell a typo from a dead code. Fourteen call sites carrying nine
messages now go through s.msg; the backup page's two protection warnings — which
are promises about whether the customer's files are safe — follow the same route.

Hungarian is byte-identical, proved structurally by the go-parity gate against the
frozen base capture and red-proofed on a single added full stop.

data["Title"] was DEAD (claim.html is standalone; .Title is layout.html's) and is
deleted rather than translated — a translated dead field is a permanent false
signal about where the page's title comes from.

Six existing copy-contract tests were kept, not weakened: each now resolves its key
through the real bundle, so it still convicts on a reworded Hungarian sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 07:45:56 +02:00
admin f6909492cc docs: v0.258.0's report, and what the walk then found
gates / gates (push) Successful in 27s
The release fixed four Go-side leftovers. The walk that judged it on a box installed
from scratch that day found two more of the SAME SHAPE — a composed sentence handed
to a renderer as page data — on the claim page (R-596, P1) and the Backup page
(R-598). That shape now has three instances on file with R-573, which is worth naming
as a class rather than fixing one at a time.

Verdict recorded: not yet ready for an English-speaking tester.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 19:27:33 +02:00
admin 7c4a33b6d1 v0.258.0: the last four Hungarian things an English household met
gates / gates (push) Successful in 24s
R-589 the update badge, R-590 the data-folder backup sentence, R-573 the two channel
banners, R-572 two dead helpers. All four were built in Go, which is why neither the
template parity fixtures nor TestI18nEnglishPages could see them; slice 5's LIVE proof
is what found them.

R-590 is the one that matters most: it is a promise about the customer's files. It
said, in Hungarian and under an already-English folder label, that a drop-zone is
temporary and unbacked. The test now asserts the CONSEQUENCE in both languages — and
that the two languages do not produce the same string, which would mean the English
fell back.

R-572 is not what its row said. The row claimed a template renders "vasárnap" on an
English page; measured, NO template and no Go file called pruneLabel or
nextPruneLabel. They were dead func-map entries returning Hungarian, so they are
deleted rather than translated — translating dead code would add machinery with no
reader and a test pinning a fiction. Deletion is fail-loud and that was proven: a
template naming the removed function panics loadTemplates at startup.

Five red-proofs. One of them says something about the gate rather than the code: the
Go-parity gate does not measure localeFuncs keys against the base capture, so the
citation to TestLocaleFuncsHungarianBundleMatchesFuncMap is what carries them — the
test was extended to make that citation true.

MinAgent: 0.131.0 (unchanged). Hungarian byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 18:10:53 +02:00
admin ecefdf2dc5 docs: the floor is at 0.257.0 and it delivered the feature (R-560)
gates / gates (push) Successful in 24s
The operator asked for the floor at the end of the translation work. Raised the same
session with the declared MinAgent 0.131.0 — above the vouched golden, so the
declaration carries it. demo-felhom went 0.255.0 -> 0.257.0 by itself in under 12
seconds and THEN rendered the English tagline, which is the observable worth having:
the floor delivered the feature, not a version string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 16:40:07 +02:00
admin eb806f5780 docs: v0.257.0 — README, REPORT, CONTEXT for the catalog read path (R-560)
gates / gates (push) Successful in 25s
README gains "Catalog copy in a second language" under §18: the format, the
field-by-field fallback, whole-list replacement vs key matching, the no-write-through
rule, the four templates that carry copy, and why an older controller is unaffected.

REPORT records the nine red-proofs — including the two that convicted a hollow TEST
rather than the code — and the live proof on both demo boxes, naming the method
(endpoint level, no browser on DooPlex) rather than implying a click-through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:43:52 +02:00
admin 60f0a86bd4 v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
gates / gates (push) Successful in 25s
The READ PATH for a second language in `.felhom.yml`. An `i18n: {en: …}` sibling
block inside the same file; `Metadata.For(lang)` merges it FIELD BY FIELD over the
Hungarian, so a missing or blank English field shows the Hungarian one and a
half-translated app is a legal, shippable state.

`For("hu")` is the parsed struct with `I18n` cleared and nothing else — measured
against all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`.
Lists replace whole; every other list is matched by its own key, never by position.
`For` never writes through the receiver: the metadata is the stack manager's, shared
by concurrent requests, and an in-place merge would leak one household's language
into another household's page.

Pages reach catalog copy only through `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`,
and `TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every
direct `.Meta.<copy>` read in `internal/web` so the NEXT page to read one fails the
suite instead of quietly rendering Hungarian to an English household.

Eight red-proofs. Two of them convicted a hollow TEST rather than the code: a struct
copy shares its slices' backing arrays, so the obvious DeepEqual mutation check
passed a deliberately broken merge; and a one-entry fixture cannot tell key matching
from position matching. Both rewritten, both then seen to fail.

MinAgent: 0.131.0 (unchanged). Older controllers are unaffected — `LoadMetadata`
uses non-strict `yaml.Unmarshal`, so a pre-0.257.0 box drops the whole block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-20 14:31:52 +02:00
admin e81ad613ae v0.256.1: the push log says whether a household sentence was attached (R-558)
gates / gates (push) Successful in 25s
Found while trying to PROVE v0.256.0 on a live box: there was nothing to look
at. The box logs the Hungarian message only, the hub logs the Hungarian message
only, and the payload is inside TLS - so "did the box attach a second
sentence?" was unanswerable from either end. That is the fork in the diagnosis
when an English household reports a Hungarian mail.

  [INFO] Event pushed: app_deployed (info) [+household(en)] — Alkalmazás ...
  [INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás ...

The sentence itself is not logged: it is the same sentence twice and one copy
is already on the line. Pinned in both languages.

A feature whose only failure mode is "the wrong language arrived" needs an
observable that says which branch was taken. Without one, every diagnosis is a
guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 17:06:20 +02:00
admin bef39598d0 v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s
MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 16:54:20 +02:00
admin 8fb2f9ef9d v0.255.0 — the globe on the sign-in-flow pages: styled, and inside the card
gates / gates (push) Successful in 23s
Two defects in v0.254.0's globe, both plain on a browser and neither catchable by anything
that existed — every test read the MARKUP, and the fault was in which CSS file the browser
fetched.

The shells requested /static/style.css with NO ?v=, while layout.html has carried one since
v0.166.0. A browser holding a copy from before v0.254.0 kept serving CSS with no .lang-globe
rules, so the globe came out as a bare unstyled <details> — a stray triangle and two plain
words at the edge of the window. It was FIVE shells, not the three named: both guest share
pages have the same fault for any CSS change, and their visitor is the likeliest of all to be
holding an old copy. And .Version was missing from three of those five data maps, which is
exactly how the next one would be forgotten — it is now filled at the one choke point every
shell renders through.

The globe also floated outside the card, pinned to the corner of the VIEWPORT, reading as part
of the browser rather than the page. It now sits inside the card, centred under the footer, with
the menu opening upward via the shared rule — so the dashboard and the shells cannot drift.

AND A THIRD, caught by a test that already existed: putting the version on the guest share pages
would have printed the controller build onto a page a stranger with a capability URL can open.
TestShareGuest_HeadersTilesNoAdminChrome refused it. Those two now take an opaque per-build tag
— same cache-busting, no disclosure. The fill is ONE function shared with the parity harness,
because a fixture rendered through a different data path is a picture of a page nobody serves,
which the previous release got wrong twice.

15 shell fixtures re-captured; 91 identical, every dashboard page among them.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 15:07:49 +02:00
admin 2e9d40255b docs: record the fleet floor raise to 0.254.0 (operator asked)
gates / gates (push) Successful in 25s
0.253.0 -> 0.254.0 with min_agent 0.131.0 declared — the floor sits above the vouched golden,
so the declaration is what carries it (R-472), and the agent versions were checked first
because a floor is HELD for a box whose agent is below the requirement.

demo-felhom took it by itself in ~40 s. The box's own log is the observable that counts
("settle-gate: GO — at/above floor 0.254.0"), not the hub's claim, and the release's visible
change is there too: one globe on the sign-in page, zero of the old text links.

The two DOWN customers did not get it and will take it unattended when they return, from
0.115.0 and 0.245.0 — said plainly rather than left to be inferred from a green dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:42:13 +02:00
admin 0b1486df8f fix: the recovery page's globe must write the HOUSEHOLD's setting, not a cookie nothing reads
gates / gates (push) Successful in 24s
/recovery is in the AUTHENTICATED route table — its reader is the household, not a visitor. The
first draft of v0.254.0 gave it the anonymous form, which sets the felhom_lang cookie that
langFor deliberately ignores once there is a session: the button would have appeared to work
and done nothing. Found by the live probe on demo-hp reporting no globe on /recovery (it 302s
to /login without a session) and then reading the route table.

executeTemplateLang now branches on hasSession: household form with its session CSRF, or the
visitor form without. The parity harness and TestI18nDirectRenderPagesFollowLanguage carry the
same branch, so the fixture is the form the real page serves — the trap this release already
walked into once with the shells.

A per-session CSRF token cannot be a fixture value, so it is blanked on both sides of every
parity comparison, exactly as relative ages already were. What stays pinned is that the field is
THERE and WHICH form it sits in — the half that says whether the globe writes the household's
setting or the visitor's cookie.

Evidence regenerated: 3 change shapes across 106 fixtures, 5 byte-identical (both guest share
pages and the catch-all — the three that must not change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:28:24 +02:00
admin 48f3336956 v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00
admin eb6aa58aa7 docs: record the fleet floor raise to 0.253.0 (operator asked)
gates / gates (push) Successful in 24s
min_controller_version 0.250.0 -> 0.253.0 with min_agent 0.131.0 declared — the floor sits
above the vouched golden 0.246.0, so the declaration is what carries it (R-472).

demo-felhom took it by itself in ~20 s and is healthy; its own log is the observable that
matters ("settle-gate: GO — at/above floor 0.253.0"), not the hub's claim alone. The two DOWN
customers did not get it and will take it unattended when they return, on versions nobody has
carried forward from — said plainly rather than left to be inferred from a green dashboard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 12:22:53 +02:00
admin 7c05b59708 v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00
admin 5270bad76e v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +02:00
admin 736f54b496 REPORT: v0.251.0 — R-553 and R-563, the five sites, red-proofs, live evidence
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:12:29 +02:00
admin 0194342264 CHANGELOG v0.251.0 + CONTEXT: R-553 and R-563 — decisions read a kind, not a word
gates / gates (push) Successful in 20s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:04:02 +02:00
admin f806baf4c7 R-563: the remote-backup page polls from a status attribute, and „Fut…" is finally translatable
- backups_remote.html: the status stat carries data-status="{{.Offbox.LastStatus}}"; the poll reads
  v.dataset.status === 'running' instead of v.textContent.indexOf('Fut'). The English page could never
  see a running backup before this (slice 1 shipped the English page with that word left Hungarian).
- The running label becomes {{T "backups_remote.fut"}} — hu „Fut…" unchanged, en „Running…".
- 12 backups_remote parity fixtures re-captured. Proven (audit switch/fixture diff): each differs from
  its predecessor ONLY by the data-status attribute and that one poll line; the other 94 are
  byte-identical. The displayed Hungarian is unchanged.
- Tests: TestR563_PollStartsFromAttribute (hu and en), TestR563_AttributeFollowsTheStatus.
  Red-proofed twice: poll back on textContent → both languages fail; word back inline → the English
  page shows Hungarian.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 21:02:47 +02:00
admin c00fed6db3 R-553: four decisions stop reading their own Hungarian words (sites 1-4)
Every Hungarian sentence is byte-identical; each decision now reads a signal set where the message is
made. util.KindErrorf builds the same bytes fmt.Errorf did while carrying a sentinel for errors.Is.

- Deploy status (api/router.go): deployStatusFor() by kind — stacks.ErrAlreadyDeployed (409),
  ErrRequiredField / ErrPathMissing / ErrNotEnoughMemory (400). The „kötelező" / „memória" /
  "does not exist" / "already deployed" text chain is gone.
- Off-site failure class (backup/offbox.go): ErrOffsiteQuota replaces the „tárhelykeretet" match. The
  restic/ssh signatures stay text matches on purpose — that output is not ours and is not translated.
- Alert placement (web/alerts.go): monitor.HealthReport carries WarningKinds parallel to Warnings;
  the "not on a separate drive" warning is inline by KIND. The hub report is untouched (builder.go
  copies Status/Issues/Warnings only) — pinned by a wire test.
- Stale off-site note (web/handlers.go): settings LastWarningKind + backup.OffboxWarnNoAppsSelected.
  The text test survives ONLY for kind == "" (a box whose last run predates 0.251.0) and is removed
  when R-570 closes; slice 2 must not translate that producer before then.

Tests (all red-proofed by restoring the pre-fix predicate — see the audit's redproofs.txt):
TestR553_Deploy_DecisionSurvivesWordingChange, TestR553_DeployHandlerUsesTheKind,
TestR553_DeployProducersCarryKindAndKeepTheirWords (through the real DeployStack),
TestR553_OffsiteQuota_{Decision,HeadLine}SurvivesWordingChange, TestR553_OffboxRunRecordsTheKind,
TestR553_StorageWarningsCarryKindsAndKeepTheirWords, TestR553_DiskWarningPlacementSurvivesWordingChange,
TestR553_HubReportWarningsAreUnchangedOnTheWire, TestR553_StaleNote*, TestR553_WarningKindIsPersistedAndCopied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 20:58:45 +02:00
admin e236dca486 REPORT: localisation slice 1 (R-556) — releases A, B, C, per-page table, red-proofs, live evidence
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:50:59 +02:00
admin be2efe5624 v0.250.0: i18n slice 1 release C — the language switch on every dashboard page (R-556)
gates / gates (push) Successful in 20s
- addLanguageData: the v0.247.0 hide condition (switch only on non-Hungarian pages or with ?lang=) is
  deleted; every template is converted, so the offer no longer leads to a half-English page.
- 89 layout parity fixtures re-captured; each equals its predecessor plus exactly one switch form after
  the version span (checked byte-for-byte, 89 of 89). The 17 standalone fixtures are unchanged.
- TestLanguageSwitch_EndToEnd: a Hungarian household with no ?lang= sees the form (red-proofed by
  restoring the condition: this test and 89 parity cases fail).
- CHANGELOG v0.250.0, README §18, CONTEXT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:43:01 +02:00
admin ac149c2aa4 i18n slice 1 release C: storage, sharing, sign-in, guest, catch-all and debug pages in English
- 11 templates converted (storage, storage_network, storage_init, storage_attach, sharing, login,
  claim, launcher_shared, launcher_share_password, catchall, debug); every key translated.
  Six ASCII-only Hungarian JS fragments the extractor missed were found by eye and converted by hand
  (", majd a(z)", "FIGYELEM:", "jelenlegi:", "mp", "p", " db").
- login, claim, both guest share pages and the catch-all render through executeTemplateLang (the
  household language; no session CSRF, no escrow reminder). renderLogin now takes the request.
- Page titles: TitleKey for storage, network storage, the two drive wizards and sharing.
  TestHandlerTitleKeysMatchHungarianTitle pins handler literal == hu.json value for every TitleKey.
- Tests: TestDirectRenderHandlersFollowLanguage (real routes, en + hu),
  TestI18nDirectRenderPagesHaveNoAdminChrome (escrow reminder due; none on the guest page).
- Fixture correction: the release C cases had invented page titles; the cases now carry the handlers'
  real titles (and the wizard pages their real page name), and those 12 fixtures were RE-CAPTURED from
  the unconverted templates at 0555091. The diff to the old fixtures is the <title> line (and the nav
  highlight on the two wizard pages) only.
- i18n gate: HU_FORMAL_CEILING 12 -> 16 — four more „ön" forms in the converted copy, unchanged by rule (R-516).

Hungarian: byte-identical (TestI18nParity against fixtures from unconverted templates).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:29:53 +02:00
admin 0555091f83 i18n slice 1 release C: parity cases + fixtures captured from UNCONVERTED templates
storage, storage_network, storage_init, storage_attach, sharing, login, claim,
launcher_shared, launcher_share_password, catchall, debug. Case completeness checked with the
coverage probe on a trial conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:07:37 +02:00
admin 11b790e314 v0.249.0: i18n slice 1 release B — backup pages in English, Hungarian byte-identical (R-556)
gates / gates (push) Successful in 20s
Seven backup pages + the restore-progress JS converted against fixtures captured unconverted
(f8ebc47); recovery renders through executeTemplateLang; English retrieval claims registered;
the Fut comparison left unconverted (R-563).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 18:01:09 +02:00
admin f8ebc47728 i18n slice 1 release B: parity cases + fixtures captured from UNCONVERTED backup templates
backups_apps, backups_remote, backups_restore, backups_restore_wizard, backups_escrow,
tier2_config, recovery. Case completeness checked with the coverage probe on a trial conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 17:43:33 +02:00
admin ff68b0b433 v0.248.0: i18n slice 1 release A — apps and settings in English, Hungarian byte-identical (R-556)
gates / gates (push) Successful in 20s
Ten pages converted against fixtures captured from unconverted templates (6be55a0); marker
coverage test; English page mask narrowed; English retrieval-promise stems; common keys;
title keys and infraMeta from the bundle; old wording tests read the Hungarian expansion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 17:36:08 +02:00
admin 6be55a0eb3 i18n slice 1 release A: parity cases + fixtures captured from UNCONVERTED templates (25 states, 10 pages)
dashboard, stacks, logs, monitoring, app_import, app_export, deploy, settings_system,
settings_security, settings_notifications. Captured before any of these templates carries a
marker; case completeness was checked with the coverage probe on a trial conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 17:19:51 +02:00
admin 1af89a54af i18n slice 1 (A.2-A.4, A.6): narrowed English mask, marker coverage test, English retrieval stems, parse timing
Five v0.247.0 fixtures re-captured from UNCONVERTED v0.246.0 (89dd3e94) because their
one-word Hungarian data values had to become multi-word; two new cases (backups_tier_due,
app_info_installable) captured the same way — the coverage test found both gaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 17:15:23 +02:00
admin cc2ad41516 REPORT: v0.247.0 i18n spike — live parity on demo-hp, red-proofs, observations filed
gates / gates (push) Successful in 21s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 14:58:32 +02:00
admin 612c417024 v0.247.0: i18n spike — the dashboard can speak English, Hungarian byte-identical
gates / gates (push) Successful in 19s
Message bundles (internal/i18n) expanded into templates before parsing, one
template set per language. Launcher, /backups, /apps/<slug> and the layout
converted; household language setting, POST /settings/language, ?lang= override,
report field. Parity test against fixtures captured from unconverted templates;
copy gates read templates expanded; new i18n_missing_gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 14:50:57 +02:00
admin 89dd3e94b1 re-run CI against a register that now carries R-551 and R-552
gates / gates (push) Successful in 18s
CI job 731 (29e2acb) failed the observations gate correctly: REPORT.md cited
R-551 and R-552 while they existed only in an uncommitted felhom.eu register.
They are pushed now (felhom.eu 3c1882a). The code commit 0fe315b passed CI (730).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 11:34:01 +02:00
admin 29e2acb5f8 REPORT: controller v0.246.0 - restore record, recovery-code readiness, live proof
gates / gates (push) Failing after 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 10:59:21 +02:00
admin 0fe315b759 v0.246.0: an interrupted restore is told; the recovery-code reminder waits until the box can take it
gates / gates (push) Successful in 15s
MinAgent: 0.131.0 (unchanged). Requires hub v0.117.0 for restore_interrupted.

R-550 (operator ruling: fix). A design reversed and recorded: the restore
op-status was in memory by choice. Now restore-status.json in DataDir, written
atomically at both ends of an op. At startup a record still marked running
becomes a failed, interrupted result kept per app until that app's next
restore, shown on /backups/restore and the off-site wizard, and raised once as
restore_interrupted. Cooldowns stay in memory.

R-546. The R-543 reminder bar consults the agent's own preflight ok (every
blocking item, not a copy of pbs_storage_id), cached 60 s, probed only while
paused. /backup/escrow shows a waiting card that polls and reloads instead of
red crosses and English diagnostics. POST /api/escrow/start refuses 409 before
staging or starting - the direct path chaos night used. Unknown readiness keeps
the bar.

Red-proofs (each seen failing): restore record across restart; main() calls
both startup functions; startup helper with loading skipped; restore page card;
bar held back; waiting card; start refusal. go build/vet/test ./... green, 28
packages; controller_gates --fast all OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-17 10:45:29 +02:00
admin 714d5bce09 REPORT: v0.245.0 — the recovery-code ask, red-proofs and the two-box live validation
gates / gates (push) Successful in 15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-16 21:20:31 +02:00
admin ad398b60d9 v0.245.0 — R-543: the household is asked for the recovery code, the page says "szunetel" until then
gates / gates (push) Successful in 14s
Off-site backup is ON by default and does not RUN until the household creates its
recovery code. The pause is the zero-knowledge escrow design and is untouched here;
what was missing is that nothing ASKED, while the app-backup page promised the very
copy that had never run.

- a reminder bar on every authenticated page while the off-site tier is configured
  and its escrow is not complete, linking /backup/escrow. It is the R-241 bar, second
  instance: same session-cookie dismissal, back next visit, gone for good when
  escrowed. No second banner system. It hangs off executeTemplate, the single render
  choke point, so it cannot reach only the pages someone remembered.
- the tier-1 file sentence renders by tier3State's own vocabulary instead of the
  app's shape: active -> "vedi", escrow_pending -> "vedene ... szunetel" + the route,
  no copy at all -> says so and names both ways out.
- both fixes red-proofed: the bar test fails on BOTH pages with the hook removed; the
  sentence test quotes the exact v0.244.0 promise when the state is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-16 21:02:51 +02:00
admin 2f8ff2414c v0.244.0: the backup page stops promising what it does not hold (R-537/R-538/R-536)
gates / gates (push) Successful in 17s
R-537 — the contents label is now PER TIER. One string computed from the app's
shape was rendered on all three tier rows; a Tier-1 unit has no file-copy step, so
for the four class-A apps it was claiming „Adatok" for files it does not hold.

R-538 — a unit restore REFUSES before anything is touched when the unit cannot
return the app's drive-side files, and names the route that can. It runs before the
stack is stopped because the measured harm included the app's own wastebasket going
unreachable, which still held every byte.

R-536 — „Alkalmazás telepítve" moved from the deploy's acceptance to its completion,
with app_deploy_started and app_deploy_failed as the honest pair.

Each fix red-proofed: seen failing with its own sentence, passing when restored.
Requires hub v0.116.0 for the two new event types. MinAgent unchanged (0.131.0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-16 16:55:55 +02:00
admin 383a30b3c0 REPORT: v0.243.0 FileBrowser password, per-tier backup page, tier skip, OOM visibility
gates / gates (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-15 11:37:08 +02:00
admin d3eacbb7cc backup tile: unknown size shows a dash, not 0 B (R-517 follow-up, measured on 9201)
gates / gates (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-15 10:53:20 +02:00
admin 843b319f35 v0.243.0: FileBrowser generated admin password (R-513); per-tier whole-guest backup truth (R-517); skip absent-storage tiers (R-518); OOM-killed worker visible (R-514)
gates / gates (push) Successful in 14s
MinAgent: 0.131.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-15 10:12:08 +02:00
admin 406755fa8f docs: v0.242.0 report, context; R-489 measured limit recorded (row kept open)
gates / gates (push) Successful in 14s
A volume recreated by a unit restore carries no compose label, so the
before/after difference misses it; proven on the scratch guest. Docs only,
no release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 23:04:48 +02:00
admin d698ce343b controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
gates / gates (push) Successful in 14s
R-487: the local backup lists are keyed on the drives, not on what is
deployed — a removed app whose unit was kept is listed with the restore
that reinstalls it, the picker answers for it, and the restore opens the
unit where it sits. R-491: a removal clears the app's update hold.
R-490: /api/system/info reaches the API router and reads the default
storage path. R-489: volumes_removed is the real before/after difference,
[] when none. R-476: a Tier-2 copy is dated by its data, not its manifest.
R-456: the boot-orphan rule is pinned. Every fix red-proofed.
2026-09-13 22:50:18 +02:00
admin c1f62ddae8 REPORT: v0.241.0 delivered by the floor and proven live; R-491 filed
gates / gates (push) Successful in 13s
2026-09-13 21:58:02 +02:00
admin 3e813307cc controller v0.241.0: a bind-data app leans on off-site before its own unit; the hold names what the copy holds (R-479)
gates / gates (push) Successful in 13s
Operator ruling 2026-09-13. An app with classified binds walks second
drive -> off-site -> own unit (its unit holds no files); volume apps keep
2 -> 1 -> 3. RestoreHold.CopyHolds records what the chosen copy holds and
the sentence ends with it; older holds keep their tier-only sentence.
Tests on both halves; red-proof: a layout-blind order fails the bind case.
2026-09-13 21:47:33 +02:00
admin 3013a1cc93 rules: unprompted-work.md declares unconditional: true (the instructions gate requires a scope or that declaration)
gates / gates (push) Successful in 15s
2026-09-13 21:31:31 +02:00
admin 3b15ce101f rules: unprompted-work.md — the rules for goal and nightly sessions, byte-identical in all three repos 2026-09-13 21:29:43 +02:00
admin 21a9d35f70 REPORT: R-490 filed — /api/system/info shadowed, monitoring memory card never renders (from the R-465 audit)
gates / gates (push) Successful in 14s
2026-09-13 19:47:00 +02:00
admin 24d7c54c79 REPORT: v0.240.0 delivered by the floor and proven live; R-488/R-489 filed
gates / gates (push) Successful in 14s
2026-09-13 19:38:03 +02:00
admin bdcbd50b42 controller v0.240.0: seven defects from the any-tier proof and the first nightly rotation
gates / gates (push) Successful in 13s
R-486 (P1): removing an app with its backups KEPT keeps its Tier-2 record,
so the second-drive restore is no longer refused over an intact mirror.
R-484: postgis/pgvector/timescaledb images are Postgres (logical dumps).
R-485: the backup card sizes the recovery unit and the mirror(s).
R-480: a held update's sentence leaves the card once the hold is lifted.
R-477: the update's off-site lookup is one snapshots call, no stats.
R-478: a copy older than this install's deploy does not count.
R-474: "delete backups" deletes the unit, the mirror(s) and the prefs.

Tests and red-proofs per row; evidence in felhom.eu
documentation/audits/v0240-2026-09-13/ and nightly-2026-09-13-adventurelog/.
2026-09-13 19:26:50 +02:00
admin 0e3d831030 REPORT: v0.239.0 any-tier update and MinAgent header gate, proven live; R-477..R-480 filed
gates / gates (push) Successful in 13s
2026-09-13 17:54:27 +02:00
admin b93c1543da controller v0.239.0: any backup tier lets an app update (R-475)
gates / gates (push) Successful in 14s
Operator ruling 2026-09-13. The update precondition walks Tier 2, Tier 1
(own recovery unit, "helyi") and Tier 3 (off-site, 15 s bound; unreachable
counts as absent with a WARN) and leans on the first FRESH copy; the
backup_max_age rule applies to whichever tier is chosen. No copy anywhere:
back up first. Refused only when nothing exists and no backup can be taken.
RunAppBackupNow tolerates a Tier-2 failure (WARN) and marks the captured
unit proven current. The hold names the tier (második meghajtó / saját
meghajtó / távoli mentés) and the date; pre-v0.239.0 holds keep their text.
A successful off-site restore now lifts an update hold. The backups page
still uses Tier2UnitRestorePoint unchanged.

Scenarios G-M tested; red-proofs M, L, the tail and the off-site clear in
felhom.eu documentation/audits/rulings-r472-r475-2026-09-13/.
2026-09-13 17:16:24 +02:00
admin f946b0d0ca gates: the newest release header must state its MinAgent; backfill v0.233.0-v0.236.0 (R-470, R-472)
gates / gates (push) Successful in 12s
From hub v0.112.0 a floor above the golden is served only with a declared
MinAgent, read from this CHANGELOG's newest `## vX.Y.Z` header. New fast,
blocking gate minagent_header_gate.py: that block must contain a line
starting `**MinAgent: X.Y.Z**`; prose, a code span or a non-bold mention
does not count (decoy test; red-proof F: relaxing the match to a body
mention fails test_decoy_body_mention_is_not_the_line).

Backfill: v0.233.0, v0.234.0, v0.235.0 and v0.236.0 carried no MinAgent
line. Each now reads `**MinAgent: 0.129.0** (unchanged)`, the value of the
nearest earlier header (v0.232.0). Proof it did not move: zero commits
under controller/internal/agentapi since 2026-09-01 (last: bb50e12,
2026-08-14), and the highest featureMinAgent entry is 0.129.0.
2026-09-13 17:09:33 +02:00
admin cf8a371c39 REPORT: slice 4 (v0.237.0–v0.238.1) proven live — A, B, E, F, H and the restore walk
gates / gates (push) Successful in 13s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 12:30:47 +02:00
admin cbcca03061 v0.238.1: the nightly backup leaves an app alone WHILE it is being updated, not only once it is held (slice 4 follow-up)
gates / gates (push) Successful in 13s
Found live in v0.238.0 Scenario F on demo-hp: during an update's 5-minute health wait the app is not
yet held, and the periodic recovery-unit capture at 10:17:09 wrote the never-started definition
(alpine:3.20) into its PRIMARY unit, 53 s before the hold landed. The Tier-2 mirror the hold names
survived only because Tier 2 runs daily; a nightly Tier 2 inside a verify window would have mirrored
the broken definition over the copy the customer is told to restore from.

backup.Manager.isHeld — consulted by the capture sweep, the Tier-2 run and the volume dump — is now
also true while a guarded update is moving the app, via SetUpdatingCheck wired in main.go to
stacks.Manager.IsUpdating. Test with positive control + red-proof; wiring pinned.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 12:25:09 +02:00
admin 129201abab v0.238.0: the page follows the update, and a held app offers no way to start it (update arc slice 4 Part 4)
gates / gates (push) Successful in 13s
No behaviour change on the box — the surface only.

- Frissítés follows the job: the button shows the phase label (polling GET /api/stacks/{name}
  every 3 s) and the page reloads when updating goes false.
- An updating card offers no lifecycle button; a held card (failed update OR failed restore) shows
  the hold sentence with a Mentések link and nothing that would start it; a failed update that held
  nothing shows its sentence above the buttons. app_info shows the same three notices.
- The updating/held checks run BEFORE isOperational, which counts `restarting` as operational — how
  the 2026-09-01 spike saw a green Frissítés beside a crash loop. Pinned with StateRestarting
  fixtures; red-proofed by moving the checks after it (both tests fail).
- No new CSS, no version number.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 12:07:35 +02:00
admin 0d402f711d v0.237.0: the Update button takes a backup first, and tells the truth (update arc slice 4 — R-448, R-443, R-439)
gates / gates (push) Successful in 13s
POST /api/stacks/{name}/update is now a guarded job answering 202:
cheap refusals (hold — R-439, busy, migration, deploying, memory via the
deploy's own memoryVerdict, a fixed 2 GB disk floor, and no restorable
Tier-2 copy) → backup-first when the proven copy is older than
update.backup_max_age (24h) → safety dump BEFORE the pin moves → pin →
pull (failure puts the pin back) → up → health (.felhom.yml check or 60 s
settle, update.health_timeout 5m). Not healthy → the app is stopped and
HELD (RestoreHold reason update_failed, same store and gate as R-379) and
the page names the backup to restore from; the pin stays. Success is only
ever update_phase=done after health (R-443). UpdateStack is deleted.

The restorable-unit predicate is EXTRACTED to backup.Tier2UnitRestorePoint
and shared with the backups page (row pinned unchanged). The copy is aged
by the last successful Tier-2 copy, not the manifest created_at — measured
on demo-hp that created_at moves only on definition changes.

Crash safety: update-journal.json before each phase; RecoverUpdates before
the boot sweep, ResumeInterruptedUpdates after the guards are wired.

Three unattended start paths ignored a hold and now honour it: the
drive-return gate (restart + boot recreate) and the nightly volume dump.
The nightly capture and Tier-2 run skip held apps so the restore point
survives. No automatic rollback — measured per-app; route back = restore.

Tests A–H across stacks/backup/api/web/cmd; six red-proofs seen to fail.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 11:41:31 +02:00
admin 1552716722 REPORT: name the felhom.eu --no-verify push (golden-currency only) and the closed-register gate fix
gates / gates (push) Successful in 13s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 09:07:26 +02:00
admin 7410f1c841 REPORT: v0.236.0 (R-442) proven live on demo-hp — data gone, refusal shown, SSD app not refused
gates / gates (push) Failing after 14s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 09:05:34 +02:00
admin 42a73e667a v0.236.0: "delete my data too" deletes the data, or says that it could not (R-442)
gates / gates (push) Successful in 13s
Removal resolves the drive from the app's own app.yaml HDD_PATH (the 07 ~L437
rule), never the global cfg.Paths.HDDPath which no box sets. A data removal
that cannot be resolved, or whose drive is absent, is refused with a typed
RemoveRefusedError -> 409 + exact Hungarian sentence, before compose down, and
the app is kept. SSD app -> hdd_paths_removed: [] never null; missing folders
stated; backup-path refusals reach the response.

15 tests, two red-proofs run (pre-fix fallback -> C fails with err=nil and the
handler 200s; "no drive refuses" -> D fails).

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-13 08:52:25 +02:00
admin bab82c471e REPORT: v0.235.0 proven live, and the one thing the task did not anticipate
gates / gates (push) Successful in 13s
Two real catalog pushes travelling the real 15-minute cycle. The non-image change
reached the pinned app; the image change did not; and the restart that used to
take 18.3 seconds and pull a new image took 0.1 seconds, did not recreate the
container, and pulled nothing. The Update button still moves the version, with the
pin advancing 17 seconds before the pull. The frozen app read 'Frissites elerheto
- 56 napja' while the other eight read Naprakesz. Teardown returned the container
to the baseline digest byte for byte.

The correction at the top: the task's Scenario B says to freeze to the stored
definition and says nothing about keeping that store current. The store is written
when the PIN is written, so a fix delivered afterwards - Scenario A's own case -
lands in the live file and not in the store, and the first freeze reverts it. Seen
live at 08:20:29Z. Fixed in the same run; the observation is kept as its evidence.

Also named: the syncer now imports the stacks PACKAGE for two pure symbols rather
than duplicating a compose parser, which honours the task's intent and not its
letter; and syncer.Start() had to move after adoption, which the task did not say.

Three red-proofs run and reverted. A fourth defect was caught by a test: the
syncer would have written an empty compose file over a live app.
2026-09-06 10:39:13 +02:00
admin 2a56f557d0 v0.235.0: a delivered fix must also refresh the stored definition
gates / gates (push) Successful in 14s
Found by the LIVE validation on demo-hp, not by review. Scenario A passed - a
non-image catalog change reached the pinned app on the real 15-minute cycle - and
that is exactly what exposed the gap: the stored applied-compose.yml is written
when the PIN is written, so the fix landed in the live compose file and not in the
store. The first time the catalog then moved a version, the freeze would have
rendered the pre-fix definition and reverted every fix delivered since - silently
undoing the half of the operator's ruling that says fixes keep flowing.

The equal-images branch now refreshes the store as it delivers. The images cannot
move in that branch by construction, so no version moves and no intent is
rewritten. RenderPlan gains StackDir so the syncer can write it.

TestFixRefreshesTheStoredDefinition asserts both halves: the fix reaches the
store, and it survives the freeze that follows.
2026-09-06 10:04:21 +02:00
admin 8a0e0a59ad v0.235.0: freeze the version, keep the fixes flowing (operator ruling 2026-09-06)
gates / gates (push) Successful in 12s
Slice 3. R-447 was BLOCKED because R-438 established that RestartStack's use of
up -d to pick up template changes was CHOSEN and written down in its own comment.
The operator ruled Option 1, and this implements it.

The rule: while the catalog offers the same version you run, its fixes flow to
you; the moment it moves to a newer version you are frozen until you update.

NOTHING was added to any of the thirteen compose up -d call sites. Most of them
are repairs - the boot reconciler, the drive-return gate, the app-stop guard -
and a repair path that refuses to repair leaves a customer's app down, which is
worse than the problem. They are made safe by removing the reason.

app.yaml gains pinned_images: what the app is SUPPOSED to run. It is NOT
installed_images, which is an observation; letting a reading become a deployment
is the R-166 category error one field over. Four writers, each also storing the
exact definition as applied-compose.yml. UpdateStack advances the pin and
re-renders BEFORE the pull, because pull and up -d act on the file on disk, and a
pin set afterwards would pull the frozen version and report success.

The syncer renders instead of copying, through one nil-safe seam. Catalog images
equal the pin -> verbatim, so fixes and self-healing both survive; they differ ->
the WHOLE stored definition, never a substitution of refs into a newer template
(wger 2.6 needs a DB config the older template cannot supply). This is
deliberately not 'skip deployed apps', which was option B and was rejected.

AdoptPins runs once at boot after the backfill, files only, and skips loudly
rather than inventing a pin. syncer.Start() moved to after it: the initial sync
would otherwise run while every app was unpinned and overwrite a deployed app's
version once per boot.

THE BADGE HAD TO CHANGE OR SLICE 2 WOULD HAVE INVERTED SILENTLY. TemplateImages
reads the LIVE compose file, which is now the frozen one, so the comparison would
have answered Naprakesz on exactly the apps that are behind - with every test
green, because the new field has the same type. It now reads CatalogImages.

+16 tests (1729 -> 1745), 28 packages green. Three red-proofs run and reverted.
A test also caught the syncer writing an empty compose file over a live app.
2026-09-06 09:45:34 +02:00
admin 998aa31958 REPORT: v0.234.0 — the backfill proven live, and a test of mine that went red overnight
gates / gates (push) Successful in 12s
Adds section 6b (the startup backfill on both demo boxes: all nine apps already
had records by the time it was ready, so the pre-0.233.0 shape had to be
recreated on demo-hp - said plainly rather than papered over; two seeded with
digests matching independently-read ground truth, seven untouched, nine badged)
and 6c (the render test hardcoded a date and an age, was green the day it was
written and red the next morning, now derived; R-457 names six candidate files).

Also records what was deliberately NOT staged live: the backfill's refusal of a
partial observation needs a degraded app, and manufacturing one risks the false
customer email class that already cost 61 mails.
2026-09-03 12:02:30 +02:00
admin 38d28b5b62 v0.234.0: seed installed_images at startup, so the label appears on an app nobody touched
gates / gates (push) Successful in 13s
The operator looked at demo-felhom the morning after v0.233.0 and found OpenGist
- up 15 hours, running exactly the catalog pin - showing no badge at all.
v0.233.0 wrote the record only from the four bring-up paths, so an app nobody
restarts carried no record indefinitely. On a quiet box that is every app, which
is the box we most want to see. The known limitation WAS the feature not working.

BackfillInstalledImages runs once at startup, beside BackfillDesiredState and
before the boot reconciler. It READS containers: starts nothing, restarts
nothing, writes no compose file. It never overwrites an existing record.

And it REFUSES to seed a partial observation, which is why this is not a
three-line loop: the badge reads a service-count mismatch as BEHIND, so seeding a
degraded app from what is visible would render 'Frissites elerheto' over an app
that is perfectly current. The bring-up paths may write a partial because they
follow a successful up -d where a gap is real news; a backfill meets any state.
Same data, two writers, two admission rules - deliberately.

Also fixes a calendar bomb of mine: the render test hardcoded catalog_since and
the string '46 napja', but the render path reads time.Now(), so it was green on
the day it was written and red the next morning. Now derived. Filed as R-457
with six other candidate files named as unchecked, not accused.

+5 tests (1724 -> 1729), 28 packages green. Red-proof of the partial guard run
and reverted; the wiring and its ORDER pinned by an AST walk.
2026-09-03 11:56:43 +02:00
admin 32da46cd64 REPORT: the mirror base images ARE byte-identical to Docker Hub - measured, not assumed
gates / gates (push) Successful in 13s
The throttle cleared 40 minutes later, so the check was run instead of left as a
one-command IOU. 'docker pull docker.io/library/<img>' answered 'Image is up to
date' for both bases - Docker Hub's own manifest resolved to the images already
local, the ones the mirror supplied and 0.233.0 was built from. The two manifest
indexes are also identical between registries.

Also corrected in passing: the two sha256 values recorded earlier are local IMAGE
IDs, not manifest-list digests. I conflated them once during this very check, so
the report now says which is which.
2026-09-02 21:03:24 +02:00
admin 50312b63bc REPORT: the badge is proven live too, and the stale-password claim was mine to retract
gates / gates (push) Successful in 13s
Naprakesz on /stacks (x2) and /apps/bookstack; NO badge at all on /apps/docmost,
a deployed app with no record - absent is UNKNOWN, not current; and 'Frissites
elerheto - 52 napja' on both surfaces, the age real arithmetic on bentopdf's
catalog_since. Staged by editing one compose tag with no restart and no up -d,
reverted byte-identically. ASCII fragments with positive and negative controls.

Section 7 now opens with the correction rather than burying it: I reported the
vaulted password as stale on both boxes; it was fine, and I had stripped only
double quotes from a single-quoted value. The keeper is that I read the
controller's 'Failed login' past what it discriminates.
2026-09-02 20:49:01 +02:00
admin 2fb558552a REPORT: v0.233.0 live — the record is proven on metal, the badge render is not, and why
gates / gates (push) Successful in 13s
The record: proven on demo-hp through the boot reconciler (a real production
caller, no hand-set state) on a single-service AND a multi-service app, with all
three digests matching ground truth read independently beforehand.

The badge render: NOT validated. The vaulted dashboard password is stale on BOTH
demo controllers; the five attempts are listed rather than summarised, and the
controller's own log is the discriminator that says wrong password, not wrong
host header. Filed as R-453 and raised in STATUS.md item 9.

Also recorded: the build was blocked by a Docker Hub 429 and the base images came
from Google's Hub mirror, with both digests written down so the identity check is
one command when the throttle clears - KNOWN, not measured here.
2026-09-02 20:37:01 +02:00
admin 8025304acc v0.233.0: record what each compose service actually installed, and badge whether it is current
gates / gates (push) Successful in 12s
Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no
auto-update, the three lifecycle buttons byte-identical.

Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each
entry carrying ref + repo digest + first-seen timestamp. Written by
Manager.recordInstalledImages after a successful compose up from StartStack,
RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never
from docker-compose.yml: the syncer overwrites a deployed app's compose on a
15-minute cycle and the two disagreed for 25 minutes in the spike's own
measurement. A failed write NEVER refuses the action - the deliberate opposite
of SetDesiredState, because this is an observation and that is an intent. Not
called from StartStackServices (the R-47 DB-only window). Its own docker seam
with a context and a 30s timeout, which neither existing exec helper has.

Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the
recorded ref per service against what the current template pins and returns a
*MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS.
NO RECORD RENDERS NOTHING: absent means unknown and never means current. No
version number reaches the customer and no registry is queried.

Known limitation, filed not hidden: 23 catalog pins float, so those apps can read
Naprakesz when the image behind the tag has moved.

+17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real
RestartStack plus an AST walk of the four call sites. Three companion red-proofs
run and reverted.
2026-09-02 20:18:01 +02:00
admin 960d29b061 REPORT: the CI outcome - four red runs, two real causes, all now green
gates / gates (push) Successful in 12s
Adds section 11. The sharpest result of the whole sweep is in it: my own decoy-coverage gate
identified a repository by its DIRECTORY NAME and went blind the first time CI ran it, because the
act-runner checks out into a folder called hostexecutor. The gate written that morning to catch
name-for-fact was matching a name, in the first ten lines of its own main loop (R-428).

The other cause was my push ordering - two repos citing R-421 pushed before felhom.eu carried the
row - which instructions_gate convicted exactly as designed.
2026-09-01 12:48:50 +02:00
admin 22983885f1 re-run CI against a register that now carries R-421
gates / gates (push) Successful in 13s
The earlier run convicted correctly: instructions_gate found this repo citing R-421 while
felhom.eu's OPEN-ITEMS.md did not yet have the row. My ordering, not the gate's fault - the register
lives in felhom.eu, so a repo citing a new row must be pushed after it.
2026-09-01 12:45:54 +02:00
admin 670def61b4 REPORT: the decoy sweep - 29 gates, 16 fooled, 10 fixed, 6 honestly untested
gates / gates (push) Failing after 14s
Opens with the survey table. Records the three numbers, every live hole with its decoy and row, the
six gates no plausible decoy could be built for, the meta-gate's 20-name exemption list, and which of
the five defining rows actually closed (R-419; R-378 explicitly did NOT).

Includes my own mistakes by name - five decoys withdrawn as illegitimate, a 36-vs-44 arithmetic
artefact I announced before checking, an rc==0 read as a hole for a gate where rc is not the
question, a bash heredoc that ate my backticks, and an R-419 fix that was too strict and rejected
genuine markers until its own gate convicted this very report.
2026-09-01 12:42:30 +02:00
admin 681cc663ef decoy sweep: eight holes in this repo's gates, all measured, all fixed (R-421)
gates / gates (push) Failing after 13s
Every gate was DECOYED - the label constructed without the fact, the gate run, the verdict recorded.
No verdict here was reached by reading, because reading is exactly how the five prior instances hid.

SCOPE IS A FACT TOO, and it was the big one. Six gates decided what to look at with os.listdir - one
directory level. Every one was green AND CORRECT, because no template subdirectory exists today; every
one would have gone blind the moment anyone added templates/partials/, which is an ordinary act. A
single planted file carrying an emoji, a native confirm(), hand-rolled row markup, a dangling JS id
reference, a templated secret and an unregistered retrieval promise passed all six.

THE CONTROL IS WHAT MAKES THAT A MEASUREMENT: mojibake and docker-v already used os.walk, saw the
identical planted file, and convicted. So the cause was the listing, not the decoy.

COMMENTS ARE NOT CODE, AND COMMENTS ARE NOT CONTROLS. debug-routes matched `case subpath == "x"` in
raw text, so a case left in a commented-out block counted as a live handler - which is R-400's
original defect (seven dead controls on the page an operator opens when something is already wrong)
reached through the one door its own gate could not see. app-row-dedup's MUST_USE check had the same
shape: a commented-out {{template "app_list_row"}} satisfied it.

Stripping is deliberately crude in debug_route_gate, and that is correct there: its own docstring
insists on ten lines that cannot rot. A // inside a string literal truncates that line, which can
only ever HIDE a reference, never invent one - it fails in the safe direction.

NOT FIXED, and left open with its decoy rather than quietly patched: R-425, offbox-rename scans a
fixed three-entry FILES list, so banned NAS branding in a NEW offbox template passes. The scope was
correct when written and silently narrows every time the feature grows a file.

test_gate_decoys.py holds 10 decoys and declares COVERS, which felhom.eu's new decoy-coverage gate
AST-parses - a substring search for coverage would be the very shape this sweep exists to find.

No Go code. No version bump. No image. No golden owed.
Survey: felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md
2026-09-01 12:39:17 +02:00
admin 3db62fc6b2 REPORT: the CI step is MEASURED, not assumed - job 481 shows the payload works
gates / gates (push) Successful in 12s
Gitea's act-runner does populate GITHUB_EVENT_PATH with a commits array carrying per-file lists.
Job 481 read 3 commits / 12 distinct paths, classified CODE (5 document, 7 code), and ran the gates
with --scope=code. Green.

Still not observed: the docs branch in CI, and an advisory in a CI log - the second additionally
needs a golden debt to exist at that moment. Neither is being arranged artificially.
2026-09-01 12:03:28 +02:00
admin 13dd00bcff gitignore Python bytecode - the new gate test writes controller/scripts/__pycache__
gates / gates (push) Successful in 13s
test_golden_notice.py imports controller_gates.py to assert the golden-notice is registered
non-blocking, which makes CPython write bytecode beside the scripts. felhom.eu already ignored it;
this repo did not, so it would have reappeared on every test run and been one careless 'git add'
away from being committed.
2026-09-01 12:01:54 +02:00
admin a4444088ad R-404: the golden NOTICE, in the repo where the debt is created - NOT A RELEASE
gates / gates (push) Successful in 12s
No version heading on purpose. No Go code, no image, no version bump; giving this one would create
the exact golden debt the change is about.

Until today this repo - where a release actually happens - had NO golden-currency check at all,
while felhom.eu ran one on every push including documents-only ones that can neither create the
debt nor clear it. The person who could act heard nothing; the person who could not act was
blocked, thirteen --no-verify uses' worth.

golden_notice.py is ADVISORY IN EVERY CASE, and that is the only correct behaviour rather than
timidity: at the moment a release is committed the golden legitimately does not exist yet, so
blocking there would refuse the commit that STARTS the process - and blocking later is the mistake
being undone.

NO SECOND IMPLEMENTATION: it IMPORTS felhom.eu/scripts/golden_currency_gate.py and calls that
gate's own released_versions()/newest_baked(), so it is the same comparison read in the other
direction. Cross-repo shape copied from instructions_gate.py; never a copy of the script, because a
copy recreates the drift these gates exist to detect. An absent sibling clone is INCONCLUSIVE and
silent about currency - it never guesses.

controller_gates.py GAINED A FIFTH `blocking` FIELD. It could not express a reporting-only gate at
all before: every registered gate's non-zero exit failed the run, so the only way to add a notice
was to give it the power to refuse a push. The capability was added rather than the notice
compromised (R-420). False for exactly one gate, and test_golden_notice.py asserts it stays one.

Tests N1-N4 with a positive control that every other gate is still blocking. RED-PROOF RUN: making
the debt branch return 1 fails N1 - in production that would refuse the commit that starts a
release.
2026-09-01 12:01:26 +02:00
admin a017367f9f REPORT: disclose the five red CI runs, the pre-push bypass, and two instruction defects
gates / gates (push) Successful in 12s
2026-09-01 10:58:06 +02:00
admin 4e13fdaaa0 REPORT: v0.232.0 - the determination, the walk's three extra findings, the golden
gates / gates (push) Successful in 13s
2026-09-01 10:50:52 +02:00
admin 62c6a8a98a docs(v0.232.0): CHANGELOG, three CONTEXT rulings, README (R-411/408/407, R-414, R-412a)
gates / gates (push) Successful in 14s
2026-09-01 10:36:52 +02:00
admin 8b55de734c R-414: the fallback scratch must also be DELETABLE - caught by live validation
gates / gates (push) Successful in 12s
The system-data fallback resolved a scratch fine and removeProofScratch then refused to
delete it: its accepted-roots list is built from REGISTERED drives, and a driveless box has
none. Observed on demo-felhom: 'refusing to remove ... it is not inside a proof root', with
the copy still on disk. Every nightly proof would have left one behind, growing forever, on
exactly the boxes the fallback exists for.

My defect, introduced with the fallback in the same session. The unit tests missed it
because every one of them registers a drive; the new pair deliberately does not, and the
second asserts the guard still REFUSES a path outside every proof root, so the fix is not a
widening into uselessness.
2026-09-01 10:29:08 +02:00
admin fcef8e069c one writer at a time, and a check that can run (R-411, R-408, R-407, R-414, R-412a)
gates / gates (push) Successful in 12s
THE WALK FOUND THREE MORE ENTRY POINTS THAN THE REPORT DID. R-411 named one missing
acquireRunning. Fixing it and then pinning the invariant with an AST walk surfaced FOUR in
total, all of which issued restic commands with no flag:

  RestoreOffboxScratch      - the reported one
  OffboxRestorePrepareFull  - the SECOND request in the customer's own two-step full-restore
                              flow, and the one that actually shells `restic stats`. The UI
                              reaches it FIRST, so flagging only the restore would have left
                              the collision reachable by the ordinary path.
  RestoreSharesScratch      - R-411's exact shape on the shares tier: unlockStale + resticStep,
                              a live web caller, and its sibling PlaceSharesRestore has always
                              taken the flag.
  RestoreOffbox             - no production caller today, but the same dangerous pattern.
                              Flagged rather than left for a future caller to inherit.

OffsiteInventoryList is REGISTERED EXEMPT with its reason: it issues only `restic snapshots
--json`, measured on demo-hp 2026-08-31 not to take a lock, and flagging it would make
browsing a page refuse during a backup for no safety gain.

THE REAL DELIVERABLE IS THE WALK, not the acquire. offbox_integrity.go:28 asserted "Every
off-site operation takes acquireRunning" since v0.227.0, nothing checked it, and it was false
for months - the ninth instance of this project's most-repeated class. The walk is an AST
pass, not strings.Contains, because a commented-out call still contains the string.
Red-proofed twice: removing the acquire fails it naming RestoreOffboxScratch; an
unregistered fake entry point fails it naming the fake.

R-407: "It NEVER writes to the repository" corrected in place, not deleted (R-360's rule).
`check` takes a lock - and so does `restic stats`, which is the fact nobody had and the one
that made R-411 possible. Both recorded where the next reader will meet them.

R-414: the proof could not run at all on a box with no registered drive. Part 2.1's
determination came out as neither "missed" nor "deliberate": R-356's own test comments say
the scratch resolver "still resolves ... only the DESTINATION moves", so it was OUT OF SCOPE,
and it was never ruled out on state-only grounds - the one comment about a systemDataPath
fallback belonged to PlaceOffsiteRestore, concerned bulk USERDATA, and R-356 overruled even
that. So 07 section 6.3's rule applies and now has a fourth consumer.

The fallback is SCOPED, because the two callers ask different questions and one predicate
answering both is the R-356 defect itself: a UNIT-ONLY restore may fall back to the system
data path (07 section 7 records as FACT that a driveless app's unit already lives there
indefinitely, and that the same-device placement is intended); a FULL restore keeps today's
refusal, because it pulls bulk userdata onto a state-only tier.

And the silence ends either way: a proof that cannot start now records ProofResultCannotRun
rather than an Err, so last_proof_result is never ABSENT - absent already means "controller
too old", and a second meaning on the same field is the StatsKnown trap one level up. It is
recorded WITHOUT advancing per-snapshot due-ness, so the app stays retryable once a drive is
registered.

R-412 leg 1: a per-app push whose unit carried no dump and no tar now says so, at WARN.
Wording only - no guard, and the capture is untouched (08 section 8.2). Leg 2 stays OPEN.

16 new tests, 1689 -> 1705. Full suite 28 packages rc=0, all 13 controller gates OK.
Red-proofs run and reverted byte-identical for A3/B1 (twice), C1 and D1.
2026-09-01 10:19:36 +02:00
admin 9aea86cd48 REPORT: CI verdicts by run id - three controller runs green, felhom.eu 288 red on the declared golden debt only (12 of 13 gates OK)
gates / gates (push) Successful in 11s
2026-08-31 21:33:03 +02:00
admin 79f853fbbd docs: v0.231.0 REPORT + README + CONTEXT rulings + REUSE entries (R-87)
gates / gates (push) Successful in 12s
2026-08-31 21:31:04 +02:00
admin 303129e3af v0.231.0: the off-site proof gets a by-hand trigger, like its integrity sibling (R-87)
gates / gates (push) Successful in 12s
Without it the only way to see the job work is to wait for 05:30, which makes live
validation and any future diagnosis a next-day exercise. Same function as the scheduled
job - no second code path.

ONE deliberate difference from the integrity button: due-ness is NOT bypassed. There,
forcing means "check the store again", which is always answerable. Here due-ness IS the
target selection - an app is due when its newest snapshot has not been proved - so
ignoring it would mean inventing a second way to choose an app, exactly what having one
function prevents. When nothing is due the button says so, honestly.

Every other guard intact, including the single-writer flag: a hand-run during a backup
SKIPS exactly as the scheduled one would.

POST /api/debug/backup/offsite-proof, button beside "Restic integritas" on the debug page.
debug_route_gate pairs the two, so a button with no dispatch (R-400's shape) cannot ship.
2026-08-31 21:09:09 +02:00
admin e43b5ec07d v0.231.0 - the box proves its own off-site copy still holds something (R-87)
gates / gates (push) Successful in 11s
R-87 re-scoped by its own spike and built as Option C. MinAgent 0.129.0 unchanged.

THE QUESTION NOTHING ASKED. The weekly check proves the stored bytes are the bytes we
stored; it cannot tell us we stored the WRONG thing. A hollow recovery unit backs up
cleanly, checks cleanly at 100 percent depth, restores cleanly and gives the customer
nothing back - measured on demo-hp 2026-08-31, 120082104 B to 7036 B in one nightly run
recorded as a success (R-403). No tier and no cadence asked it. Now offsite-proof does,
nightly, on one app.

IT DOES NOT prove a restore puts data back into a running app. That stays drill work and
07 section 8 matrix row 4 is NOT moved.

THE ACCEPTANCE RULE HAS TWO PARTS AND THE OBVIOUS ONE IS A TRAP. "Check the unit against
its own packing list" PASSES a hollow unit, because a hollow unit declares nothing. So:
(1) everything declared is present, AND (2) the manifest declares what the app is supposed
to have. Part 2 is the whole value. RED-PROOFED: the naive rule makes the hollow-unit test
read verdict "pass".

THE EXPECTATION COMES FROM INSIDE THE UNIT, never the live box - the snapshot may predate
the app's shape, and GetDockerVolumes describes the running app. Database half is
DBServiceNames, the same discriminator RestoreFromRecoveryUnit uses. Volume half is
ParseComposeNamedVolumes as an EXISTENCE check, not a name match: tars are
<project>_<volume>.tar and ResolveDockerVolumeNames derives the project from the compose
file's parent dir, which inside a unit is the literal string "compose". Measured on all
eight real units on demo-hp the counts match exactly and the naming held every time - but
"held on eight" is not "derivable" (R-355). Half a rule that is true beats a whole rule
that is invented.

THREE OUTCOMES: pass, fail (readable and empty), cannot judge. An app that legitimately
has neither a database nor volumes PASSES. RED-PROOFED: alarming on any empty unit makes
that test read verdict "fail".

IT NEVER WRITES TO THE REPOSITORY and that is asserted on the ARGV as a non-effect:
--no-lock, no unlockStale, and m.runner() rather than resticStep so the unlock --remove-all
escalation is unreachable. RED-PROOFED: routing it the customer path's way makes the test
fail on "unlock" appearing in the argv.

IT TAKES acquireRunning ITSELF and skips rather than waits, because RestoreOffboxScratch
does not take it (R-408) while offbox_integrity.go states that invariant as universal.

DUE-NESS IS PER SNAPSHOT (R-86's model), never per clock. RED-PROOFED: recording a
timestamp fails the stored-value test AND breaks the rotation - night 2 re-picks night 1's
app.

ITS SCRATCH IS A SEPARATE ROOT (backups/offsite-proof) and that is a safety decision, not
tidiness: the job deletes its copy on every path, and sharing backups/offsite-restore/<app>
would mean a nightly background job deleting the verification copy a CUSTOMER is looking
at. It is also invisible to placement, so a proof copy can never be pushed into a live app.

SHARED RATHER THAN FORKED: offboxScratchDirIn parameterises the scratch resolver on its
ROOT builder, and unitOnlyHeadroom extracts the free-space gate, so the customer path and
the proof refuse at the same floor with the same Hungarian sentence. RestoreOffboxScratch's
behaviour is unchanged.

NEW EVENT offsite_proof_empty, severity error, operator-only - deliberately NOT
backup_integrity_failed, whose hub template says the store is DAMAGED. Here the store is
sound and the content is absent: different cause, different action. The hub half shipped
FIRST, in felhom.eu 1aeaa30 (hub v0.110.0, live and verified), because an unallowlisted
type is 400'd and vanishes.

33 new tests, all groups green; full suite 1689 tests, 28 packages, rc=0. All 13 controller
gates OK. Five red-proofs run and recorded in REPORT.md.

A golden carrying 0.231.0 is OWED - the fleet is on 0.230.0. Viktor's call (R-242).
2026-08-31 20:55:34 +02:00
admin 2d802d75e8 docs: record the two documentation commit hashes in REPORT.md section 4
gates / gates (push) Successful in 12s
2026-08-31 14:39:41 +02:00
admin 1cfdde968f docs(v0.230.0): R-403 — CHANGELOG, CONTEXT rulings, README, REPORT
gates / gates (push) Failing after 13s
CHANGELOG v0.230.0, leading with the measurement rather than the fix: 120 082 104 B -> 7 036 B on
the shipped v0.229.0, reproduced before anything was built.

CONTEXT records three rulings: hollowness is a MANIFEST question and never a size question; the
guard fences one shape and NOT shrinking, because the derived-copy rebuild is a design decision; and
the rehydrate happens inside the restore because a follow-up job races the 5-minute capture. Plus
the shape the live run taught: a warning that fires on everything costs the same as the comforting
lie it replaces.

README documents the refusal, what each surface says, and why the capture job is deliberately not
guarded. REPORT leads with Part 1's result, carries the six red-proofs, the per-row Scenario D table
with its seven-app control, and eight observations including R-404 filed-not-acted-on and three
mistakes of mine recorded rather than tidied away.
2026-08-31 14:39:13 +02:00
admin b48a7fa326 R-403: the restore OUTCOME names the package's date, not the run's
gates / gates (push) Successful in 11s
Live on demo-hp the confirm said 11:43 (the preserved package) and the outcome said 14:23 (the
copy's newest run) for the same restore. A customer reading both cannot tell which one they had, and
one of the two is the flattering sentence. Part 2.3's rule is 'not a plain green success ANYWHERE',
and the outcome is an anywhere.

tier2UnitSourceMsg now asks UnitRestoreDate, the same resolver the confirm uses, so the two cannot
disagree. TestR403_OutcomeNamesThePackageDateNotTheRunDate pins it, with a negative control for the
ordinary case.
2026-08-31 14:32:29 +02:00
admin 5429d651ee R-403 fix, caught by the live run: the stale flag fired for every healthy app
gates / gates (push) Successful in 11s
UnitRestoreDate also compared the package's date against the run's and flagged 'older'. A recovery
unit is ALWAYS captured shortly before the run that mirrors it, so that comparison is true for every
healthy app. Measured on demo-hp: bookstack, kimai, opengist and privatebin all had src and dest
manifests at 12:03:49Z against a run at 12:14:24Z - perfectly healthy, and all four would have been
told their package was stale.

A warning that fires on everything is a warning nobody reads, which costs the same as the comforting
lie it was meant to replace. The second return is now UnitLegPreserved and nothing else.

TestR403_AHealthyAppIsNeverCalledStale pins it; red-proof: reinstate the comparison -> it fails.
2026-08-31 14:22:32 +02:00
admin 2358e561b7 R-403: a poorer copy must never delete a richer one
gates / gates (push) Successful in 11s
MEASURED FIRST, then fixed. On the shipped v0.229.0, on demo-hp, an app's Tier-2 copy went from
120 082 104 B (4 database dumps + 3 named-volume tars) to 7 036 B (none of either) in ONE nightly
run, and the run recorded itself a success: 'Tier 2 copied docmost -> ... (14.9 KB, 0 leg(s), 0s)'.
Evidence: felhom.eu/documentation/audits/DRILL-r403-tier2-delete-2026-08-31/.

The mechanism was three individually-correct lines: RunTier2 guards the unit leg with os.Stat only
(does the folder exist), rsyncMirror is rsync -a --delete, and nothing between them compared source
to destination. An EMPTY unit is a folder that exists.

THE GUARD. One predicate, unitCarriesData/unitIsHollow (r403_hollow.go), asking the MANIFEST and
never the byte size - a big compose tree with no dumps is dangerous, a tiny unit for a tiny app is
fine. Fail closed on an absent or unparseable manifest. RunTier2 skips the unit leg when the source
is hollow AND the destination is not; the other legs still run, the run is not failed, and the skip
is recorded for the SURFACE (CrossDriveBackup.UnitLegSkipped + UnitPackageDate) as well as logged.

--delete STAYS and shrinking stays legal. 07 section 8 row 5's derived-copy rule is unchanged; the
fence is exactly one shape. TestR403_DataLegShrinkIsUnaffected is the guard on the guard.

THE HONESTY. A preserved package is older than the run that preserved it, so the card carries a
notice and the unit-restore confirm names the PACKAGE's date - read from the mirrored manifest's own
created_at, not from the status record - plus a clause saying why it is older.

THE CAUSE. RestoreTier2Unit now refills a hollow or absent primary unit from the mirror it just
restored from, INSIDE the call before returning. The hollow manifest was written two seconds after
a restore by the 5-minute capture job; any follow-up job races it. The capture itself is NOT guarded:
a capture describing an empty drive as empty is correct, and with the primary refilled there is no
hollow state left to describe. Never over a complete primary, never after a failed restore.

recordTier2Success and tier2UnitConfirmMsg keep their old signatures as thin callers, so no existing
test needed editing. New seam unitRehydrate, separate from tier2Mirror on purpose.

22 new Go tests. Red-proofs run and reverted: A6 (predicate -> size threshold), B1 (guard removed ->
the copy's 3 files are DELETED and the seam is called), B6 (a general never-shrink rule -> the shrink
case fails), C2 (only-when-hollow dropped -> the complete primary is overwritten).
2026-08-31 14:02:13 +02:00
admin fed272e62a docs: REPORT.md — delivery is DONE (golden 0.229.0 vouched, floor raised)
gates / gates (push) Successful in 11s
Section 6 now records the completed delivery instead of an owed bake; observation 2 records that
R-242's sixth conviction was paid the same day and the declared --no-verify bypass is historical.
New section 15 carries the bake, the round trip, the third independent reader, both proven
pre-gates, the three-field vouch, the R-120 gate passing, and the floor proven ACTING.
2026-08-31 12:38:15 +02:00
admin 0957f43d10 docs: record the two documentation commit hashes in REPORT.md section 3
gates / gates (push) Successful in 12s
2026-08-31 12:22:02 +02:00
admin 8aa95b5831 docs(v0.229.0): R-102 + R-103 — CHANGELOG, CONTEXT rulings, README, REUSE, REPORT
gates / gates (push) Failing after 13s
CHANGELOG v0.229.0. CONTEXT records three rulings: the source moves and the destination does not;
two predicates and not one wider one (R-356's cost restated); and a destructive operation reached
from a non-destructive surface carries the difference in the CONFIRM, not the label. README documents
the new action and route and corrects the coverage note to the measured count. REUSE maps the four
unit-directory-relative primitives and the new manager methods, with the traps.

REPORT covers the live drill on demo-hp (docmost, class B, primary unit moved aside — 3 volumes of 3
and 1 database of 1 in 28.65 s, accented filename byte-identical verified as hex, the app reading its
own row over TCP; Scenario D with the guest app.yaml also aside, secrets recovered=2/2), the settled
count (A=7 B=45 C=1, and why the earlier 9/43/1 was wrong), the five named red-proofs, and seven
observations including R-403 and a process error of mine that changed the box and is now in memory.
2026-08-31 12:21:28 +02:00
admin 4c8f0d2919 R-103: the Tier-2 refusal becomes an action
gates / gates (push) Successful in 12s
An app whose Tier-2 copy holds no file legs but a full recovery-unit mirror - 45 of the 53 catalog
templates - was told to press a button on a DIFFERENT page. Since R-102 the data it is asking for is
restorable from the copy it is looking at.

New POST /backup/tier2/unit-restore and backupTier2UnitRestoreHandler: same guards, same
restoreOpBlocked() refusal (R-351b), same async shape as the file restore beside it, plus a
fail-closed pre-flight so the app is never stopped for a mirror that could not be opened. The
outcome reuses unitRestoreOutcomeMsg and adds which copy overwrote the live data.

The row offers the action where the refusal was, in a danger style, as a SEPARATE button. The two
are not merged: one adds what is missing, the other overwrites. The confirm carries that difference
in words and names the copy's date - and says so differently when that date is only an ATTEMPT
(R-101). It is built from named Go constants rather than assembled inside an HTML attribute, so a
test can assert it verbatim; fmtTimeStr now delegates to a package-level fmtRFC3339Local so the
confirm and the outcome cannot render the same date two ways.

tier2NoCoverageMsg is NARROWED to the case that remains - no legs and no openable unit - and still
names the route that works. tier2UnitNotCoveredMsg is NOT deleted: it is appended where the FILE
restore ran and is still exactly true of it.

Tests C1-C2 and D1-D6 plus four more. Red-proofs: C1 (widen CanRestore to include HasUnit -> the
unit-only cases fail), D6 (drop EndRestoreOp from the handler goroutine -> 'the restore never
published a result').
2026-08-31 11:42:03 +02:00
admin 0f9b796615 R-102: the recovery unit on the second drive becomes a way back
Tier-2 mirrors each app's whole recovery unit to <dest>/backups/secondary/<app>/recovery-unit/ on
every run and has done for months. Nothing read it. In the one failure Tier-2 exists for - the
primary drive is lost, and the primary unit with it - the surviving copy could not be opened by any
action in the product (07-backup-architecture 6.3, 7.2).

Part 1.2: RestoreFromRecoveryUnitAt(stack, unitDir) holds the whole body; RestoreFromRecoveryUnit is
the thin caller naming the primary unit. ONE implementation, two callers. The SOURCE moves; the
DESTINATION does not - live Docker volumes, the live database container, the guest's definition, all
unchanged. The R-47 mutation order, the secret reconciliation with unit-over-guest precedence, the
fail-closed data-key gate and the no-unit fallback with CountsUnknown are untouched.
reimportDBDumpsAtCtx is the bounded-context twin of reimportDBDumpsFrom; the 35-minute bound is now
named once so the two paths cannot drift. The R-354 volume-replay seam is reused rather than a second
one invented, which is what lets the acceptance test assert the volume leg's source directory.

Part 1.3: RestoreTier2Unit resolves the recorded copy, refuses fail-closed unless the mirror carries a
parseable manifest - a directory is not a package - and delegates. The single-writer flag is taken
inside RestoreFromRecoveryUnitAt, not beside it.

Part 2.1: Tier2Coverage gains UnitRestorable and the copy's dates. CanRestore() is NOT widened; it
still answers only 'can the file restore run?'. One predicate answering two questions is R-356, which
refused 40 running apps for months.

Tests: A2-A6 and B1-B5, plus two non-regression guards. The Tier-2 fixtures build their mirror with
the production RunTier2, so the claim is 'the copy Tier-2 writes is the copy this restore reads'.
Red-proofs: A5 (swap volumes/recreate -> fails on the order), B2 (point the reader back at the
primary -> fails with the mirror never reaching the redeploy, and with permission denied once the
primary tree is unreadable).
2026-08-31 11:30:34 +02:00
admin c732006d26 R-102 phase 1.1: split the recovery-unit path helpers, zero behaviour change
Every unit path helper took (nsRoot, stackName) and joined backups/primary/<stack>/... . That
hard-coded 'primary' is the mechanism of R-102: Tier-2 mirrors the whole unit directory to
<dest>/backups/secondary/<stack>/recovery-unit/ every night, and because no reader could NAME a
unit outside backups/primary/, that mirror has been captured for months and read by nothing.

Adds four unit-directory-relative primitives - UnitComposeDir, UnitManifestFile, UnitDBDumpDir,
UnitVolumeDumpDir - each taking the recovery-unit DIRECTORY itself. The four existing
(nsRoot, stackName) helpers become thin wrappers over them and keep their exact signatures and
their exact return values; every current caller compiles untouched.

ONE implementation, two callers - the rule restoreDockerVolumesFrom already follows in this repo.

TestR102_PathWrappersAreByteIdenticalToToday pins the wrappers against hand-written literals (not
re-derived from the helpers under test). Red-proof: UnitComposeDir join changed to 'compose2' ->
the test fails on all three fixtures.
2026-08-31 11:16:02 +02:00
1126 changed files with 255239 additions and 9110 deletions
+33 -9
View File
@@ -6,17 +6,19 @@ paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "co
## The ONE entry point
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this
repo.** It runs the local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`,
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate`,
`secret_in_markup_gate`, `retrieval_promise_gate`, `debug_route_gate` — plus `reuse_refs_check`,
`instructions_gate` and `observations_gate` on the repo root, streaming each gate's own output and
exiting non-zero if any fails. **The runner's `GATES` table is the list; this sentence is a pointer to
it, not a second copy** — it has already drifted once (it said "seven" while nine were registered).
**Run `python3 scripts/controller_gates.py` from `controller/` (or `python3
controller/scripts/controller_gates.py` from the repo root) after ANY change in this repo.** It runs
every gate in the runner's `GATES` table, the shared `reuse_refs_check`, `instructions_gate` and
`observations_gate` among them, streaming each gate's own output and exiting non-zero if any fails;
one, `golden-notice`, is ADVISORY and never refuses. **The `GATES` table is the list; this sentence is a
pointer to it, not a second copy** — it drifted twice (it said "seven" while nine were registered, then
named ten local gates while fourteen were).
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them.
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them
except `gate-decoys` (the decoy suite, ~22 s, edits real files in this tree for a moment; R-1116), which
runs in the full run only — so the hook and CI do not run it.
- **A missing gate script is a FAILURE, never a skip.**
- **The shared `reuse_refs_check.py` and `instructions_gate.py` live in `felhom.eu/scripts/` and are
- **The shared `reuse_refs_check.py`, `instructions_gate.py` and `observations_gate.py` live in `felhom.eu/scripts/` and are
never copied here** — a copy would recreate the drift they detect; an absent sibling clone FAILS.
- **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It is
per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run WARNS
@@ -54,3 +56,25 @@ a probe stayed in D state 3m50s after kill -9; a buffered write with no fsync bl
needs journal access); and statfs/getdents returned HEALTHY on a namespace that EIOs every byte —
fast, and wrong.
-->
## A gate ships with a decoy test that has been seen to fail (R-421)
**A decoy is the LABEL without the FACT** — a directory with the right name and no bake log, a
handler case that exists only in a comment, a note whose prose mentions the marker it lacks. Write
one for every new gate, run it, and watch it convict. `felhom.eu/scripts/decoy_coverage_gate.py` (run by felhom.eu's `repo_gates.py`, for all four repos) refuses a gate
registered without one, or without a named exemption carrying its row.
**Earned by five instances, every one found by accident:** R-410, R-400, R-378, R-419, R-94. The
2026-09-01 sweep read all 29 gate scripts and fooled 16 of them. The four shapes to test against:
1. **name-for-fact** — it matches a path or directory NAME while the fact lives inside the file.
2. **substring-for-field** — it matches a token anywhere in a body instead of in the field carrying it.
3. **declaration-for-reachability** — it checks a thing is declared, not that it RESOLVES.
4. **constant-for-measurement** — it compares a value against itself.
**Scope is a fact too.** Eight of the sixteen were `os.listdir` (one level) where `os.walk` was meant:
green and correct today, blind the moment anyone adds a subdirectory. Prefer `os.walk`, and prefer a
glob over a hand-maintained list of files.
**A decoy nobody would write proves nothing** — say the gate is sound and move on. Five of mine were
withdrawn as illegitimate and are named in `felhom.eu/documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
+101
View File
@@ -0,0 +1,101 @@
---
unconditional: true
---
# Unprompted work — rules for any session without a task file
> Goal sessions, nightly sessions, "work the register" sessions. **A session that starts from
> `/goal` or a standing brief inherits these rules exactly as it inherits the gates.** They are the
> part of `PROMPT-TEMPLATE.md` that a task file used to carry and a goal does not. Same wording lives
> in `felhom.eu`, `felhom-controller`, `felhom-agent` and `app-catalog-felhom.eu` `.claude/rules/`, and in the workspace
> root's unversioned `.claude/rules/`; change all five or none.
## 1. What you may pick up on your own
- A register row **you or another CC session filed**, with owner CC, at P3 or a bounded P2, that
needs **no operator decision**, touches **no customer data by design**, and introduces **no
mechanism nobody has measured**. Smallest first.
- A defect you find while exercising the product, filed as a row **before** you fix it — **unless it is small**:
a small finding is fixed in the session and never filed (the size rule, `OPEN-ITEMS.md` „How a row is filed").
- Hygiene: register compression, stale citations, rows with no owner, documents that contradict
live source.
**Not yours, ever, without a task file or an operator word:** money; anything that changes risk to
customer data; anything that changes a promise the product makes to a customer; anything that
reverses a documented design decision (`documentation/architecture/` — a design decision is not a
defect, R-370); anything on DooPlex or ep0; baking or vouching a golden; promoting a
catalog version; a new external dependency; **a hub image build or hub deploy in a session the operator does not
attend** (operator ruling 2026-10-07, `09` §3 decision 162).
## 2. When you may decide instead of ask (operator grant, 2026-09-14)
You may take a decision yourself when **all** of these hold: the architecture folder and the register
give a clear direction; your choice follows that direction; it is reversible without customer-data
risk; and you can write it in the `09-update-architecture.md` §3 shape — one answerable sentence, the
options, what each costs, why this one. **Then record it** as a dated decision in `CONTEXT.md` and
the owning architecture document, tagged *decided by CC unattended — operator may reverse*, and put
it **first** in the morning note. A decision you cannot write in that shape is one you do not take.
## 3. The discipline a task file used to carry
1. **Baselines first.** Read each repo's `main` hash and version from live source before touching it.
2. **Read the architecture document for the area, and name it** in the report, before any claim.
3. **Red-proof every correctness fix.** A test never seen failing has not been shown to test anything.
4. **Live-validate on a Tier-0 box** through the endpoints the UI invokes. `demo-hp` is `ssh hp`.
Throwaway apps only; the standing apps and `bentopdf` stay.
5. **Evidence off the machine at the end of each phase**, before any revert (R-320).
6. **One release per repo per session**, with a CHANGELOG entry (controller: with its `MinAgent`
line) and REPORT overwritten. **A controller release needs no golden** (operator ruling 2026-10-15
11:26, `09` §3 decision 250; decision 7 stands): push the image only when it is ready for EVERY box (the
household's update button offers the highest tag); raise the floor, MinAgent declared, only after it ran
on Tester 1, demo-hp and demo-felhom through the normal update path and the box walk's checks are green
on all three — not at night unless the night brief says so. **Agent releases still stop for the
operator's vouch.** The golden is baked weekly and vouched by the operator; CC never vouches. **No `--no-verify`.**
**A version number is pushed once** — `felhom-controller/controller/build.sh` now refuses a second
push of a tag that exists, and a registry it cannot reach is a refusal too. This is a FACT about
what the build does, not a new rule: a fix for something already pushed is the next patch version,
and a box on a bad image is moved by the normal update path, never by re-tagging. Earned on
2026-10-15, when 0.315.0 was pushed three times from three different commits and a cache kept
serving the first one (`09-update-architecture.md` §0).
7. **An enumerated gap becomes a row in the same session — or, if it is small, is fixed in it** (the size rule).
Prose is not a record.
8. **Hungarian text is searched with ASCII fragments**, with a positive and a negative control.
9. **Never leave a half-state.** If time runs out, revert to clean and say what was reverted.
10. **Teardown, three layers, stated** — machine, host, hub — or "provisioned nothing".
11. **Every helper prompt carries the brief's fences in full** (operator ruling 2026-10-07). A helper session (a
subagent, a fork, a workflow agent) gets the brief's fence list word for word — every protected machine, every
„no", every delivery and Docker limit — not a summary and not „the usual fences". Earned on 2026-10-06 night: two
helpers whose prompts carried only part of the fences ran `docker volume prune` on the bench and a Docker-using
gate on DooPlex.
## 4. The morning note
One screen, plain language, in this order: **decisions you took** (§2) first; what you exercised;
what broke and whether you fixed it; rows opened and closed with the register size before and after;
what needs the operator, each with what happens if they do nothing. No file paths, no function
names, no row numbers as the subject of a sentence.
## 5. Instruction files
**Instruction files (`CLAUDE.md`, `.claude/rules/*`) are kept true by the session that finds them wrong**
(operator ruling 2026-10-06, `09` §3 decision 150). A session MAY, without asking: correct a stale fact (a command, a
count, a version, a path, a description of what a gate does), add a fact it proved, and remove a reference to something
that no longer exists. Each edit is named in the report (file, line, before, after, why). A session MAY NOT, without the
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.
+45 -4
View File
@@ -16,7 +16,16 @@
# A failing run must reach a person — a detector nobody hears is the defect R-29 filed, rebuilt one
# layer up. That is the last step, and it runs ONLY on failure.
name: gates
on: [push]
# R-1129: `workflow_dispatch` is the manual PROOF that the failure mail can be sent. A push run is
# unchanged by it: the proof step below is skipped on a push, and `force_alarm` is then empty.
on:
push:
workflow_dispatch:
inputs:
force_alarm:
description: "Fail this run on purpose so the alarm step sends its real mail (a proof, not a gate result)"
type: boolean
default: false
jobs:
gates:
@@ -43,8 +52,28 @@ jobs:
- name: Run the gate entry point
# The ONLY thing CI runs. No go build, no go test, no linting, no deploy. The
# exit code IS the result: no `|| true`, no pipe that could swallow it.
env:
# R-986: the security-scan gate fetches the PINNED gitleaks here (sha256-checked); no Go, so govulncheck says
# NOT CHECKED in CI, out loud. Warn-first either way: the scan lists, it never fails this job.
FELHOM_SCAN_FETCH: "1"
run: cd ws/felhom-controller/controller && python3 scripts/controller_gates.py --fast
- name: Manual proof - alarm key present, optional forced failure (dispatch only, R-1129)
# Runs ONLY on a manual dispatch, never on a push. It prints the key's LENGTH, never the key,
# so "CI can see the secret" is an observable rather than an assumption. With
# force_alarm=true it then fails on purpose so the alarm step below sends its real mail;
# the commit itself stays green, because its push run skips this step.
if: github.event_name == 'workflow_dispatch'
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
run: |
python3 -c 'import os;print("RESEND_API_KEY length: %d" % len(os.environ.get("RESEND_API_KEY","")))'
if [ "$FORCE_ALARM" = "true" ]; then
echo "force_alarm=true - failing on purpose so the alarm step runs (R-1129 proof)"
exit 1
fi
- name: Alarm on failure
# THE POINT OF THE WHOLE THING. Probe P5 measured that a failed run produces NO mail, NO
# notification row and NO log line from Gitea itself — a red tick in a web UI nobody watches
@@ -58,6 +87,8 @@ jobs:
if: failure()
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
# Empty on a push, so a push run's mail is exactly what it was before R-1129.
FORCE_ALARM: ${{ github.event.inputs.force_alarm }}
run: |
python3 - <<'PY'
import json, os, sys, urllib.request, urllib.error
@@ -69,13 +100,23 @@ jobs:
repo = os.environ.get("GITHUB_REPOSITORY", "?")
sha = os.environ.get("GITHUB_SHA", "?")
run = os.environ.get("GITHUB_RUN_NUMBER", "?")
srv = os.environ.get("GITHUB_SERVER_URL", "https://gitea.dooplex.hu")
# R-1135: the link is for a PERSON, so it is the public address and the run ID.
# NOT GITHUB_SERVER_URL: the runner registers at the in-cluster address, so that variable
# reads http://gitea.gitea-system.svc.cluster.local:3000, which nobody outside the cluster
# can open (forced-test mail, job 1997). NOT GITHUB_RUN_NUMBER: Gitea 1.26 reads
# /actions/runs/<n> as a run id first, so a run number that is also an older run's id
# opens THAT run (measured 2026-10-18: felhom.eu /actions/runs/1116 opened the run whose
# id is 1116 = run number 613). The link is printed too, so the job log carries it.
run = os.environ.get("GITHUB_RUN_ID", "?")
srv = "https://gitea.dooplex.hu"
print("ALARM-LINK %s/%s/actions/runs/%s" % (srv, repo, run))
body = json.dumps({
"from": "Felhom CI <monitoring@felhom.eu>",
"to": ["admin@felhom.eu"],
"subject": "[felhom CI] gates FAILED in %s" % repo,
"subject": ("[felhom CI] FORCED TEST, not a real failure - gates FAILED in %s"
if os.environ.get("FORCE_ALARM") == "true"
else "[felhom CI] gates FAILED in %s") % repo,
"text": (
"The gate entry point exited non-zero.\n\n"
"Repository : %s\n"
+5
View File
@@ -0,0 +1,5 @@
# Python bytecode from the gate scripts and their tests (R-404 added test_golden_notice.py,
# which imports controller_gates.py and so writes controller/scripts/__pycache__).
__pycache__/
*.pyc
+7
View File
@@ -0,0 +1,7 @@
# .security-accepted — hits security_scan_gate.py (felhom.eu/scripts, R-986) lists as ACCEPTED, not WARN.
# One per line: <tool> <id> <where> # reason. govulncheck: OSV id + module dir; gitleaks: rule@<sha256[:12] of the line> + file.
# The first scan's verdicts (2026-10-19): felhom.eu documentation/audits/night-2026-10-19/P7/triage.md
gitleaks generic-api-key@93610586e88d controller/internal/stacks/samba_test.go # false: a test constant or a decoy value, no live secret
gitleaks generic-api-key@a882b8f2ea6b controller/internal/backup/offbox_offer_shapec_r241_test.go # false: a test constant or a decoy value, no live secret
gitleaks generic-api-key@b3c473a524c2 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
gitleaks generic-api-key@c9c6aa2fcae6 controller/scripts/test_gate_decoys.py # false: a test constant or a decoy value, no live secret
+4862 -1
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -27,7 +27,7 @@ integrations, git-sync, notifications. Disk/host/Proxmox concerns are delegated
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` |
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
| the authoritative design | `felhom.eu/documentation/architecture/01/02/03-*.md` |
| the authoritative design | `felhom.eu/documentation/architecture/NN-*.md` (01–03 for this repo; 07 backup, 09 updates, 10 language) |
## Session-critical invariants
@@ -37,8 +37,8 @@ The rest live in `REUSE.md`. These cost incidents to learn:
- Docker's `.State` says "running" even for unhealthy containers — the `.Status` parse is the truth.
- In-memory `Deployed` is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
- `compose up -d` exits 0 on crash-loops — the post-start status check is the detection.
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller)
cannot be stopped from the UI.
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller,
and always samba) cannot be stopped from the UI.
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
the catalog `REUSE.md` maps the families).
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use
@@ -61,7 +61,7 @@ credentials) — they load when you touch a template or stylesheet.
| Surface | Command |
|---|---|
| Gates (after ANY change) | `python3 controller/scripts/controller_gates.py` — from `controller/` |
| Gates (after ANY change) | `python3 scripts/controller_gates.py` — from `controller/` |
| Green gate | `go build ./... && go vet ./... && go test ./...` |
| Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it |
+759 -1
View File
@@ -1,5 +1,72 @@
# CONTEXT.md — Project Memory
> **2026-10-24 night (unattended restore-holes run) — on `main`, NOT released (next v0.343.0, MinAgent 0.158.1 unchanged).**
> R-1251: a pending put-back blocks every start (`backup/appstop_putback_wait.go`: restore hold `putback_pending`, the
> hold store every start path honours; `ResumePutBack` in the boot recreate and `restartStacks`; `ResumeWaitingPutBacks`
> at start and on a drive return; a failed put-back = the D20 hold; the copy's folders synced before `SetUndo`).
> R-1252: `tier2CaptureSetFull` returns `classified`; reconcile only then; `BackupBlockRejected` via the stack adapter.
> R-1253: `AppStopGuard.BusyWith` / `BeginExclusive`; the exporter refuses a stop-first export. Proven on Tester 1
> (R-1251) and 9202 (R-1252, R-1253): felhom.eu `audits/restore-holes-2026-10-24/`. Release: Monday's list (STATUS).
> **2026-10-24 (attended release run) — v0.342.0 gains the TESTED line (operator rulings D41 = 50 %, D42, the iperf3 line
> test; felhom.eu CONTEXT 07:22/07:26).** `agentapi.LineTest` reads the agent's `GET /host/line-test` (agent ≥ 0.164.0 —
> its weekly 10-second iperf3 upload to ep0 inside the tunnel; demo-hp measured 90.7 Mbit/s where the push floor said 10).
> qBittorrent's limit = 50 % of the TESTED line only; the watch uses the higher of test and floor; the system page names
> the source. MinAgent stays 0.158.1 (an older agent = „not measured" = no limit). Release order: agent 0.164.0 (vouch),
> hub 0.169.0, then this controller through the normal path.
> **2026-10-23 night — v0.342.0 on `main`, NOT released: the upload and CPU watch (`internal/upwatch`; operator ruling
> 2026-10-23 19:22; felhom.eu `08` §9 [DESIGN — proposed], `audits/SPIKE-upload-watch-2026-10-23.md`).** Exact
> per-container counters (Docker Engine API), per-app judging, explainers qBittorrent / Transmission / Plex, the
> operator-only `app_unexplained_upload` / `app_unexplained_cpu` (+ `_cleared`; hub 0.169.0, also unreleased), the line
> from a one-second sample of the off-site push (settings.json `upwatch`), qBittorrent's default limit (50 %, never over
> the household's). `0.342.0-rc.1` ran on scratch 9202 (its off-site is off, so the line was seeded by hand there).
> Open: R-1244 (release), R-1245 (limit size), R-1246 (who is told), R-1247 (active line test), R-1248 (a `tc` ceiling).
> **2026-10-23 (day, later) — v0.341.0: the night leg takes a `security_fix` ladder step (D39 = B, `09` §3 decision 255,
> R-1239; operator ruling by starting the run).** `legCandidate`: verdict `proven` OR `security_fix`, every other guard
> unchanged and in order; log `[update-leg] <app>: security fix <ids> taken (<from> → <to>)`; `LegStep.Security`. The
> catalog side (format, gates, writer, `box_walk.py --security`) is catalog `3898593`. On ≤ 0.340.0 the leg skips such a
> step (`not_proven`) and a hand press takes it — both proven on 9202 (felhom.eu `audits/security-lane-2026-10-23/`).
> **2026-10-22/23 night — 0.340.0 candidate on `main`, unreleased.** The stray-copy fix (`66a69d4`, `912e592`) and two
> review rounds (`943c517` C5/C3/C6/C8; `9d9e404` N2–N5, N9; `1babfe9` two races): the capped copy's container has a
> fixed name + label, is removed by name on any error, leftovers are removed at start before the scheduler/web server
> (holding the destination's lock), one copy per destination at a time, `RunTier2` refuses during a restore of the app
> in one step with its record (`beginTier2Run`), every leg asks again; a restore is refused while a copy runs or a stray
> may run (per-name strays, cleared by a later removal). Proven on 9202 with rc.5 and rc.8; rc.8 runs on the test box
> (Tester4-16fdef) for the night. Release: 0.340.0 with MinAgent 0.158.1, the operator's yes (STATUS morning list).
> **2026-10-22 night — four fixes on `main`, NOT released (night brief: no release). The next release is 0.340.0, MinAgent 0.158.1.** R-49 (D21 = B): `.felhom.yml` `backup.volumes` class `excluded` (`6a17c95`); R-1187(c): an import over an installed app that is not running is refused (`d5f88a2`, reproduced and proven fixed on 9202); R-1189(d): `nightchain.CatchUp.After` runs the off-site proof + integrity once after a catch-up (`d5f88a2`); R-1214: the second-drive copy runs in a 512 MB memory-capped container for clean `/mnt/` paths with no symlink (`03c0a69`, `ae39467`). 9202 ran rc.1–rc.4 and is back on 0.339.0. rc tags pushed: 0.340.0-rc.1..rc.4 (never offered: not X.Y.Z).
> **2026-10-20 — v0.336.0 is the floor (MinAgent 0.158.1, 08:54 UTC).** R-893 option A (operator D20): a failed off-site restore
> copies the app's live volumes first and puts them back exactly, then starts the app (proven on 9202: schema, data and row-count
> md5 equal before and after a forced failure); R-1169 the held sentence. Follow-up row: the put-back's ERROR log level and an
> empty `backups/restore-undo/` folder.
> **2026-10-19 (day) — v0.332.0 is the floor (MinAgent 0.158.1): one sign-in for the file manager (R-1100, D16) and the
> typed-back escrow confirm (R-1151).** Every dashboard folder link → `fileBrowserOpenLink` → `GET /__files/open` (pass:
> 60 s, one use, host + session bound; none for `Sec-Fetch-Site: cross-site`) → traefik forwardAuth `GET
> /__felhom_gate/files` (`web/files_sso.go`) swaps it at `/_felhom/sso` for the host-only `felhom_files` cookie and then sets
> the per-box SECRET header (`infra.LoadOrCreateFileBrowserSSOHeader`, `<data>/filebrowser-sso-header`, NEVER logged)
> naming `felhom-household`. **A forwardAuth answer's redirect must be ABSOLUTE** (traefik resolves a relative Location
> against the auth server — rc.2 sent browsers to `http://felhom-controller:8080/…`). Escrow: the claim sends
> `{"defer_upload": true}`; `/api/escrow/confirm` forwards the two typed words; an agent < 0.160.0 needs no confirm.
> Residuals R-1155 (FileBrowser's own token), R-1161 (the „live code" wording). felhom.eu `REPORT-day-2026-10-19.md`.
> **2026-10-18 (evening) — v0.331.0: OnlyOffice opens and saves from FileBrowser (`server.internalUrl`) and Nextcloud
> (`DocumentServerInternalUrl` with its slash); a FileBrowser restart runs the R-1126 gate at once (R-1018); a removal deletes volumes still labelled with the
> app's project that the definition no longer declares (R-1117).** MinAgent 0.158.1.
> **2026-10-18 (day) — v0.330.0: „anyway" asks the agent's disk list first and starts again the apps it stopped when the
> agent refuses (R-1144).** MinAgent 0.158.1.
> **2026-10-18 (night) — v0.329.0: each app reaches only its own `appdata/<folder>` (R-1142, compiled-in folder map:
> paperless-ngx → paperless); the allow-list is EMPTY (homepage's socket removed, R-981); a drive retirement asks the agent
> first and a refusal changes nothing (R-1063); a plain message for a virtio disk without a serial (R-1069); `:latest`
> only for a release tag (R-1143).** MinAgent 0.158.1.
> **2026-10-17 (late) — v0.328.0 is the floor (MinAgent 0.158.1): the catalog allow-list (R-971, operator decision D2 = A).**
> `internal/privpolicy` refuses container rights not on the compiled list before ANY compose write; refusal = nothing
> written + `app_definition_refused` (hub ≥ 0.157.0) + an app-page line. Three security-review rounds fixed before the
> release; a live finding on 9201 (rc.4) fixed in rc.5. Follow-ups R-1142 (cross-app appdata), R-1143 (`:latest`).
> This file serves as persistent project memory across Claude Code sessions.
> It replaces the auto-generated "Memory" from the claude.ai Project.
> **Update this file at the end of each working session** with current state,
@@ -7,7 +74,698 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-08-31 (v0.228.0 — R-399/R-400: the check reads the data, the debug page stops lying)
Last updated: 2026-10-17 (v0.327.0 — FileBrowser published only after its check; real memory amounts in the install check)
> **2026-10-17 (afternoon, 2) — v0.327.0.** R-1126 (D11 = A): FileBrowser off `traefik-public` until this run's default
> login is checked (`stacks/filebrowser_gate.go`, connect by container ID); FileBrowser's own admin-password setting
> re-applies every start, so it cannot carry ours. R-1125 (D10 = A): catalog requests = measured use; the refusal and the
> install page say what to do; the page reads `stacks.InstallMemory` like the check. R-1129 was a misreading (CI mail
> always worked).
> **2026-10-17 (afternoon) — v0.326.0.** R-977: `EnsureFileBrowserAdminPassword` probes the default login once per
> FileBrowser RUN (`fbStartedAt`, `fbProbedRun`), with ~20 s for a new run to answer; measured on 9202 before (default 200
> for 10 min after a re-created volume) and after (replaced in 2 s). The tunnel-before-claim question is the operator's
> (R-1126). R-1116: `gate-decoys` in the full run (not `--fast`: 22 s, edits real files — R-1128).
> **2026-10-17 (day) — v0.325.0 (burndown 3).** R-1111 `reserveAttempt` is the one limiter body (dashboard sign-in,
> share password, escrow re-auth). R-1112 `family.json` stores fingerprints; an old file is converted when the
> controller STARTS (`webServer.OpenFamilyStore` — rc.1 opened it lazily, found live on 9201). R-1113 the household's
> pass reuses its family session (`Store.HouseholdSession`, new one under half-life). R-1109 samba compose label
> `felhom.smb-conf-sha256` (every box with sharing on recreates samba once). R-1110 `TestNoBareDrivePathToPathHelpers`.
> R-1114 `scripts/shared_scripts.py` + `FELHOM_WORKSPACE`. Same day, catalog `5d9450b`: five apps' Redis → RDB
> (R-1096), applied at each app's next start. Known flake filed: a stacks test writes `update-journal.json(.tmp)` into
> the package folder (empty DataDir), and the R-578 walk can trip on it.
> **2026-10-17 (night) — v0.324.0 RELEASED to the three test boxes (per-box floors, 21:52 UTC).** R-1106 / R-127 b
> (operator decision 1A): `appbackup.DBCredentialVars` + `DBDataVolumes`; `backup/restore_dbcreds.go` (pure
> `planDBCredentials`, `dbCredentialPlanFor`, `offsiteCredentialOverlay`); `unitRestoreEnv` refuses a DB credential it
> cannot source and O4 never generates one; the same-version off-site restore writes the snapshot's password via the
> optional `secretValueSetter` → `stacks.SetStackSecretValues` (load-then-save; also marks the reinstall's `type:
> password` logins as restored — R-926). R-1107: restic 0.19.1 pinned; fixtures for three versions. R-1108, R-1065,
> R-1017 (+ `userdataKept` fix), R-983 (6), R-976 (2) — written by two helper sessions in detached worktrees, merged
> and reviewed here. Proven live on demo-hp. **Global floor 0.324.0 / MinAgent 0.158.1 at 02:46 UTC 2026-10-17**, after all three test boxes
> made the night's off-site backup on it; per-box floors cleared. Next: R-1096 (Redis plan in `audits/night-restore-2026-10-17/P6/`), R-1109…R-1115.
> **2026-10-16 (evening) — v0.323.0 RELEASED to the three test boxes ONLY (per-box floors); the GLOBAL floor stays
> 0.322.0 until each test box has made one real night's off-site backup on 0.323.0** (the brief's rule; the check and
> the floor command are in felhom.eu `STATUS.md`). R-1105: runtime `debian:13.7-slim` (digest-pinned), Docker repo
> codename from `/etc/os-release`; restic is the official 0.18.1 release binary from a `restic` build stage (signed
> `SHA256SUMS` + vendored key in `third_party/restic/`, published sum, pinned per-arch sum). Every restic assumption
> re-proven with REAL outputs of 0.14.0 and 0.18.1 (`internal/backup/testdata/restic/`, `TestR1105_*`); the one that
> moved is the forget plan — 0.17+ keeps each group's oldest snapshot while monthly slots are unused (only ever keeps
> more). Removed packages the binary never runs (postgres/mysql clients, sqlite3, fdisk, e2fsprogs, parted, sshpass).
> felhom-samba 1.2.0 (Alpine 3.24.2), and `build-samba-image.sh` refuses a reused tag. Live on demo-hp 9201: both
> restore directions byte-identical on the real store; the drill's data came back; the full restore after a reinstall
> leaves a DB app unusable (old volume, new password — R-1106, not restic). Open: R-1107 (restic 0.18.1's binary adds
> 50 fixable scanner findings; 0.19.1 has 19 — the operator's choice), R-1108 (a share added before the first password
> is not exported). **Decision (CC, inside the brief, reversible):** the packages above were removed though the brief
> named only the two database clients — none is run by the binary (every exec call read), and the image carries fewer
> known holes. Full report: felhom.eu `REPORT-debian13-restic-2026-10-16.md`.
> **2026-10-16 — v0.322.0 RELEASED by CC; the global floor 0.322.0 / MinAgent 0.158.1.** R-970: `/api/config`,
> `/api/config/apply`, `/api/config/hash` are gone; the self-update and geo routes no longer take the box's hub key
> (`selfUpdateAuthMiddleware` removed), and the CSRF Bearer exemption is gone. The route table is `buildMux` in `main.go`
> so `TestR970_HubKeyOpensNoDoor` drives the real table. R-972: Go 1.27.2, x/crypto v0.58.0, runtime
> `debian:12.13-slim` pinned by digest + `apt-get upgrade`. **Decision (CC, inside the brief, reversible): the runtime
> stays on Debian 12** — Debian 13 brings restic 0.18.0 and the off-site code relies on measured 0.14.0 behaviour; the
> move is R-1105. Data tab: the export field is `fab_encryption_key`, `autocomplete="new-password"`; the 2nd-copy
> status rows are gone. R-1103: the installed-only branches of `app_fields.html` removed; their rules live in the
> app-page and settings-tab tests. Full report: felhom.eu `REPORT-burndown2-2026-10-16.md`.
> **2026-10-16 — v0.321.0 RELEASED by CC; the global floor 0.321.0 / MinAgent 0.158.1.** R-1101: in `generated` the page asks
> FileBrowser whether the stored password still logs in (`stacks.FileBrowserStoredPasswordRefused`, only a 401/403 counts)
> and, refused, offers the reset; no „change it" line for FileBrowser; its config switches off its update banner and
> „Install app" prompt. The Data tab's backup part is one section in plain words. R-1095: `/apps/{slug}/install` of an
> installed app → 302 settings; the installed-only `app_fields.html` branches stay (R-1103). Catalog the same day: RomM's
> saves in a named volume (R-1097), `default_creds` off eight apps (D10 = A). Next: R-1103; R-1067 waits on Tester-2 (0.292.0).
> **2026-10-16 — v0.320.0 RELEASED by CC; the global floor 0.320.0 / MinAgent 0.158.1.** A protected infra stack is
> installed by definition on the app page (`Deployed || Protected`; v0.315.0 had shown FileBrowser „not installed" with
> Install and no login); the deploy API and `DeployStack` refuse a protected stack. FileBrowser `operator` state →
> „Új jelszó beállítása" (`stacks.ResetFileBrowserAdminPassword`: its CLI with the server stopped, verify, encrypt,
> `generated`) — pressed on demo-hp; **the N100 waits for the operator's yes**. Every installed app has ONE login answer
> (`web/app_login.go` `loginAnswerFor`; catalog `app_info.login_kind`). „Fájlok ↗" + per-drive media folders; a folder with
> no FileBrowser source is text. Decisions inside the brief (felhom.eu `REPORT-logins-folders-2026-10-16.md` §0): no backup
> sentence on media rows; no data tab for infra; „none" behind the family gate says the family sign-in. Next: R-1100
> (one sign-in), R-1101 (stale FileBrowser password), R-1102 (eight „known password" lines, decision D10).
> **2026-10-16 — v0.319.0 RELEASED by CC; the global floor 0.319.0 / MinAgent 0.158.1.** App page style A (operator
> 2026-10-16): the hero, the tab bar and every section are panels on `--bg-1`; the install page's form groups use the same
> rule. „Belépés az alkalmazásba" pairs a `type: password` field with its `*_USER/_USERNAME/_EMAIL` name
> (`web/app_login.go`); a field the box holds no value for is a sentence, never an eye — page and reveal endpoint share
> `storedValuePresent`. Measured cause on demo-hp: Calibre-Web installed 2026-08-21, `ADMIN_PASSWORD` entered its template
> 2026-09-29, and `InjectMissingFields` never fills a `type: password` field; its stored user name is real (renamed in the
> app on 2026-10-01). The settings tab no longer renders `app_deploy_fields`; `/apps/{slug}/install` for an installed app
> still does (R-1095). Evidence: felhom.eu `audits/app-page-tuning-2026-10-16/`.
> **2026-10-16 — v0.318.0 RELEASED; the global floor 0.318.0 / MinAgent 0.158.1 (set by the operator).** The internal data
> place („Belső tárhely", agent ≥ 0.158.1 reports kind `internal`): bound on one drive-gate tick, registered on the next as
> the default drive (web/internal_place.go); eject/decommission/wipe of it refused. Partitions in the format/attach wizards
> (agent `formattable`: only while nothing on the disk is mounted); the whole disk under a registered part refused
> server-side (`deviceBacksRegistered`). R-1084: the whole-box backup offer asks the same-disk question (`offerRelationFor`).
> R-1082: `/mnt/sys_drive` never offered (`withoutSystemVolume`, by device). Live on a fresh one-disk scratch box: felhom.eu
> `audits/storage-identity-buildB-2026-10-15/`. Next: R-1094 (the whole-disk refusal sentence), with the next release.
> **2026-10-15 (late evening) — v0.316.0/.1/.2 built and on demo-hp; .3 committed, NOT built.**
> **Measured before fixing:** the tabs rendered unstyled through the public address because of a
> CACHE, not the box — the stylesheet URL was `?v={{.Version}}`, identical across three different
> 0.315.0 builds; the origin sends NO ETag and NO Last-Modified, so a copy can only expire; and
> Cloudflare caches it while rewriting the browser TTL to four hours. Evidence and its caveat:
> `audits/app-page-2026-10-15b/P0/VERDICT.md`.
> **Two mechanisms now:** `build.sh` refuses a second push of a version (a registry it cannot reach
> is a refusal too), and every shell's stylesheet address is a per-build tag.
> **The app page is the approved one-column mockup**: one width, hairline sections, underlined tabs,
> and a box only for „the app is waiting for you" (blue) or „this is risky" (amber). The
> auto-update SUCCESS banner is now meta text.
> **THE LESSON OF THE EVENING, and it cost a bad push:** I put the guard in `controller/build.sh`,
> but the release runbook runs `build/felhom-controller/build.sh` — a SEPARATE COPY from
> 2026-07-18. Proving the refusal against a real tag therefore re-pushed 0.315.0 a fourth time
> (R-1081). The build directory's script is a symlink now. A guard in a script nobody runs is not a
> guard. The same shape bit twice more in one evening: the asset tag shipped dead because only one
> of TWO render paths set it, and both times a BROWSER found what the suite could not.
> Not yet validated: the operator's own look; 0.316.3's two cosmetic fixes.
> **2026-10-15 (evening) — v0.315.0, ONE APP PAGE WITH FOUR TABS.** An app was six pages and every „Vissza" went to the
> app LIST; it is now `/apps/{slug}/{settings,data,guide,logs}` plus `/apps/{slug}/install`, one shared header and one
> set of banners (`internal/web/app_page.go`, `templates/app_shell.html`). Operator decisions recorded for this release:
> **D1** — Export lives on „Adatok és mentés", not under Beállítások. **D2** — an after-start field's CARD is rendered
> generically from the catalog's `after_start: true`, and its ACTION is Go code in a registry keyed by slug + env var
> (`internal/web/after_start.go`), so customer text can never reach a catalog-defined command. **The FAQ is a section of
> the guide, not a tab**, and **the guide tab replaces the details page**. **302, never 301**, for every old
> `/stacks/{name}/…` GET — a cached 301 cannot be withdrawn; every POST on those paths is untouched.
> New catalog block `app_info.guide` (sections + faq + `checked_version`), rendered only when `checked_version` names the
> image tag its author checked it against; no template carries one yet (the content is the companion task). The Pi and
> „csak x86" badges are gone; `PiCompatible` and the catalog key STAY this release, or a box on 0.314.0 would read
> „csak x86" on every app.
> **Found by a red-proof, and worth remembering:** the after-start handler's „the catalog must have DECLARED this field"
> check could be deleted with the whole suite green — and a POST for an undeclared field then reached Plex and opened the
> setup gate. Two guards that look like one are two tests.
> **Two defects the whole test suite could not see, both found in Chrome on the workstation** (this is
> the case for CC running on Windows): the tab bar had no CSS at all and the hero logo was unsized,
> because the shell invented class names beside ones the design system already had rules for; and the
> log tab's `<script>` was never closed, so the page rendered, every marker matched, and the live log
> poller was dead ("SyntaxError: Unexpected token '<'"). A marker proves a string is present, not that
> the browser will run it. `TestAppPage_ScriptTagsAreBalanced` now pins the second on all seven URLs.
> Live on demo-hp 9201; the floor stays 0.314.0 until all three test boxes have taken it.
> Not yet validated: the operator's own look at the page, and a real Plex claim through the new card.
> **2026-10-15 (evening) — v0.314.0, the floor since 13:11 UTC (raised by CC's token after the three test boxes; no golden).**
> A restore puts the setup gate back as it was (`internal/stacks/restore_gate.go`, felhom.eu `09` §3 decision 253, R-1057);
> `util.ParseVersionPre` puts `X.Y.Z-rc.N` below `X.Y.Z` for the floor ONLY — never for the tags offered (R-1053);
> Prowlarr's start runs the media wiring (`linkOnlyApps`, R-1058); every Plex call goes to the container's address
> (`mediawire.PlexURL`, R-1060 — Plex refuses the name `plex` as non-local). Still open: the real Plex claim on demo-hp
> 9201 (the operator's code), R-1052's new-box proof (a throwaway customer is session only).
> **2026-10-15 (afternoon) — release rule `09` §3 decision 250:** a release needs no golden; the floor carries it after
> the three test boxes; the golden is weekly. Test images use `-rc.N` tags — the household button skips them, but the
> floor never moves a box running one (`maybeAutoUpdate: not parseable — skip`, R-1053): write the release tag back.
> **2026-10-15 — v0.313.0, the media build (R-1023 and the rows under it; `09` §3 decisions 245, 247, 248).** Media apps bind
> every PRESENT drive through an owned `docker-compose.override.yml` (`media_library:` marker in the template); a real
> restart (force-recreate + start times); the drive wiring (`internal/mediawire`, keys read at use — Plex's own token,
> decision 248; Jellyfin's key named „felhom"); drive events with the notice, „restart now/later" and a stream check; safe
> removal keeps the drive; Plex's code asked after the start (`after_start`). Floor 0.313.0 and golden 0.313.0 vouched by the operator 2026-10-15 11:29. Traps found on Tester 1 and fixed: qBittorrent 5 logs in with 204; the agent's disk
> `state` stays „attached" for a pulled registered disk (presence = backing device); the drive gate raced a safe removal.
> A Plex status probe (`/library/sections`, 401) OPENED an unclaimed Plex while Plex started — removed within the hour;
> never gate on a status a starting app also returns.
> **2026-10-14 (night) — v0.312.0, https only (R-1033, P1; `09` §3 decision 244).** DECIDED BY CC UNATTENDED — operator
> may reverse: the redirect lives in traefik on the box (`felhom-https-only` in the `felhom-forwarded` chain), not in
> Cloudflare, because every box's `cf_api_token` lacks „Zone Settings" (403, measured on all three zones). Cloudflare's
> „Always Use HTTPS" stays the operator's defence-in-depth option once the keys carry the permission. HSTS one day.
> **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (no release today: kernel night 8→9, `09` §3
> decision 178).** R-899 (decision 177): a daytime press never cancels the night's whole-guest backup —
> `internal/quiesce/nightowed.go` ledger, owed tiers never fire the valve; a press reaches the agent as `trigger=manual`.
> R-304: the agent's new 424 `older_unchecked` → `RecoveryOlderUnchecked`, bundle key `recovery.older_unchecked` (hu+en).
> MinAgent unchanged (0.131.0): both changes degrade safely against an older agent. Afternoon (`a40729a`): the
> operator-only `recovery_older_package` event (decision 183; needs the hub of the same day), no eject/format on a
> backup-target drive (R-298 side fix), R-717 comments.
> **2026-10-04 night — v0.293.0 (R-860; floor 0.293.0 on the demo customers only, golden 0.293.0 vouched):**
> `internal/sockheal` — the controller exits after 60 s of socket REFUSALS (never timeouts, only after Docker answered
> once) so Docker restarts it on the current socket, then restarts any other socket user on an older inode (traefik).
> Measured: only a `docker.socket` restart re-creates the file. Live: 9202 104 s, demo-hp 9201 120 s, ids unchanged.
> The global floor stays 0.292.0 until Tester 2 may move (operator ruling 97 limited Tester 2 to three acts).
> **2026-10-04 — v0.290.0 (floored, golden 0.290.0 vouched, needs hub v0.128.0):** `offsiteGuard` EXCLUDES a young
> snapshot superseded the same day in its group (R-824) and REFUSES above the hub's `max_remove` (half); a due
> abandonment on the pinned tier is handed to the hub (`OffsiteAbandonClient`), which deletes after 7 days unless
> cancelled (decision 74); a recovery cancels at the hub. Weekly windows ON fleet-wide since 2026-10-04.
> **2026-10-03 (evening) — v0.289.0 + v0.289.1 (floored 0.289.1, golden 0.289.1 vouched, needs hub v0.127.0):** the
> hub-provisioned off-site tier is reached through an APPEND-ONLY key the hub's registrar pins
> (`offsiteapply.HubRegistrar`, `PinnedProber`); the box never receives the Storage Box password; transport `rclone:`
> over ssh 23 (`OffboxTarget.Transport = "rclone-pinned"`; the household NAS stays sftp). Retention only inside a hub
> window behind the fake-snapshot guard (`backup/offbox_window.go`) — weekly windows are OFF; the guard is too strict
> after manual runs (R-824, next). Move-aside is the hub's; abandonment deferred to the operator (R-823). v0.289.1: the
> provider rclone NOTICE line is stripped from restic output, and an unreadable snapshot count is never a measured 0
> (it caused one false `offsite_snapshots_dropped` mail, R-825). Both demo boxes migrated live with history kept.
> Evidence `felhom.eu/documentation/audits/offsite-lock-build-2026-10-03/`.
> **2026-10-02 (afternoon) — v0.288.0 (floored, golden 0.288.0 vouched):** `userdata_kept` in hdd-data and both remove
> results; the dialog says the household's files stay and names them (decision 67). Rule for later work: a remove NEVER
> deletes a `${USERDATA_PATH}` folder — only the app's `${HDD_PATH}` binds, volumes and backups.
> **2026-10-02 — v0.287.0 (floored, golden 0.287.0 vouched): the family gate.** `internal/family` (members, sessions asked
> on every request), `stacks/family_gate.go` (door before the first start; anchored exceptions; record at install and at a
> removed app's restore; `min_controller`), `web/family_gate.go` (forwardAuth, `/__family/*`, the Család card). The family
> cookie is never accepted by RequireAuth. Rules for later work: a family door is never removed while the app is
> installed; an exception is a literal prefix, anchored; never an exception on an app with no login. Live proof
> `felhom.eu/documentation/audits/family-gate-2026-10-02/A/items.txt`. Catalog: Grimmory + MeTube published behind it.
> **2026-10-01 night — v0.286.0 (9202 only) → v0.286.1 (floored, golden 0.286.1 vouched).** Operator ruling `09` §3
> decision 63: extend the box's own gate; first the box tells visitors apart (R-753). `internal/infra`: `felhom-tunnel`
> network `172.16.253.0/29` (ip-range `.4/30`), cloudflared ALONE at `.2`, traefik at `.3`; traefik's websecure trusts
> forwarded headers from `172.16.253.2/32` only and passes `felhom-forwarded@file` (removes client-writable
> host/path/address headers, fixes `X-Forwarded-Port: 443`). `EnsureBaseStack` now RECONCILES a running traefik/cloudflared
> whose rendered files changed (recreate; refuses dropping a certificate resolver). `internal/web/clientaddr.go`: `clientIP`
> = the hop traefik saw (rightmost XFF, only when the peer is traefik), the tunnel hop → `CF-Connecting-IP`; `rateKey`
> IPv6 per /64. **Readers take the visitor from the RIGHT, never the leftmost** — catalog apps that read the leftmost have
> `<router>-xff` (19, catalog). R-772: `HealthProbeResult.NotChecked`, seen on the next tick (0.286.1). R-773: a removed
> app restored gets its sign-up lock back (`SetupGateByRestore`). Open: R-776..R-779 (felhom.eu register). The permanent
> family gate (R-780) was SPIKED and passed — not built; the operator decides.
> **2026-10-01 — v0.285.0.** Operator ruling `09` §3 decision 56 (R-745): `stacks/controller_image_retention.go` keeps
> the running controller image + the previous (swap record `update-state.json` via `UpdateState.RecordedPrevious`; version
> order as fallback) + anything above the running version + non-version tags; deletes older/untagged by exact name/ID,
> 3 min after every start (main.go, after the one-time app sweep). Measured: the agent's swap rolls back to the RUNNING
> image (what `/etc/felhom-controller-image` named), never to a controller-supplied previous. R-751: nil-stack panic in
> `RetainImagesAfterUpdate` fixed; both retention seams are no-ops in the stacks tests (`TestMain`).
> **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per
> service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a
> one-time sweep at start; R-741 → `stacks/install_hold.go` (after_install apps held behind the gate's door until the
> login is replaced). Decision 52 (same-tag re-tests) needed NO controller change (measured by a unit walk).
> **2026-09-30 — v0.283.0 / v0.283.1.** Decision 50: `settings.DefaultOffboxOnForNewApp` from the deploy-done hook
> (an earlier per-app choice wins); one press for older apps; `backup/offbox_fit.go` size card (estimate at each run
> + background from the page, never `du` in a request). R-721: `stacks.Manager.WantsStopped` asked by the quiesce
> resume, the volume dump (through `stackAdapter` — the v0.283.0 miss), the update leg and `AppStopGuard.Recover`.
> R-724/R-725 page slips. Floor 0.283.1, MinAgent 0.131.0. No golden (the operator's choice before Tester-2).
> **2026-09-28 evening — v0.279.0.** `stacks/after_install.go` (decision 45: a fresh install replaces a known default
> login; the deploy-done hook now set on every box), `web/known_login.go` (the page's default-login rule),
> `backup/hollow_watch.go` (Part D: a running app's empty copy → digest + page), `web/night_chain.go` + `RunUpdateLegNow`
> (R-705), `Router.removeVerificationCopy` (R-706). Also: v0.278.0 (R-704) earlier the same day.
> **2026-09-28 — v0.277.0 (MinAgent 0.131.0).** `backup/kept_load.go`: `KeptBestCopy` (local newest vs off-site —
> off-site only when NEWER or the only copy), `KeptOffsiteCopies` (ONE `snapshots --json`, 20 s bound; `offsiteNewest`
> now carries `paths`), `LoadKeptOffsite` (unit-only download via `restoreUnitReadOnly` into the PROOF scratch → judge:
> `KeptCopyAt` + `data` block required + `unitVersionCheck` → `prepare` → `keptUnitRestore` (seam) → scratch removed
> before the outcome). `KeptCopyKey` names a copy on both pages. Golden 0.276.0 baked + vouched with agent 0.137.0 the
> same day.
> **2026-09-27 — v0.276.0, floor 0.276.0 (MinAgent 0.131.0).** `stacks/life_records.go` `carryLifeRecords` — the restore's fresh `app.yaml` keeps `desired_state`, `conversion_copy`, `earlier_conversion_copies`, `failed_update_step`, `last_update_undone`, `last_auto_update` (NOT the pin, NOT `installed_images`); `recordConversionCopy` moves a superseded copy to `earlier_conversion_copies`; `ReleaseConversionCopies` → `releaseOneConversionCopy` per kept copy. Drive move: `persistDriveFlip` (load-then-save, `HDD_PATH` only) + `upFromAppConfig`; `flipEnv` removed. **A new `app.yaml` writer that is not a new install must load-then-save** (R-697, R-700). Tests `r700_records_carried_test.go`.
>
> **2026-09-27 — v0.275.0, floor 0.275.0 (MinAgent 0.131.0).** `backup/data_versions.go`: `stampDataFile` (called by the
> DB leg, the volume leg and `RunAppBackupNow` — a new writer of a unit data file MUST call it), `foldUnitData`,
> `RecoveryManifest.Data`; `captureRecoveryUnit(stack, dataRun)` keeps `compose/` when the pins moved since the data
> (frozen); `unitVersionCheck`/`ErrUnitVersionMismatch` in the unit restore and the off-site reconstitute (which now
> writes the snapshot's definition when versions differ); `unitDataTime` = THE unit data time (+ proven flag, R-699);
> Tier 2 `DataDate`/`DataUnproven`; Tier 3 `offsiteDataTime`; `stacks.DumpStampSource` (optional guards interface) +
> `ConversionCopy.Service`; `stacks.RestoredVersionPosition` + the restore pages' first sentence; R-695 single-flight
> FB sync; R-691 `keptReadGroups`/`group_add` (CC-unattended decision, `07` §6.5); R-694 `restored_logins`.
> D4 (open) option A is the built behaviour — `07` §6.6.
> **2026-09-25 midday — v0.272.0, floor 0.272.0.** `web.noSpaceLine` (agent prefix `skipped: not enough space: `,
> key `backup.tier.no_space`); `backup.SetUndoCopyRemover` → `stacks.RemoveUndoCopies` from `clearUpdateHoldAfterRestore`
> (R-671); `stacks.LoadProbeMetadata` (R-670); `stacks.BehindSinceAge` in both badge producers (R-677). Rulings: Peti's
> box retired; `09` decision 34 — no resume of the update leg after a restart.
> **2026-09-25 night — v0.271.0, floor 0.271.0 (MinAgent 0.131.0).** `stacks/unattended.go`: `RunUpdateLeg` — one
> app at a time, ONE step per app per night (decision 33), only `proven` entries, never `needs_person`,
> `files_may_change` only with `backup.FreshWholeCopy` (the hold's truth table), never the step in `app.yaml`
> `failed_update_step` while the ladder print is unchanged (R-680), transient refusals retried once, no step at or
> after W+5h (`backupwindow.UpdateLegStopOffsetMin`). Chained by `chainUpdateLeg` inside the `offbox-backup` job
> (every path incl. panic). `quiesce.Options.UpdateLegFn` = decision 20 + 31 (the gate waits until W+5h, then only
> for a step in flight, cap W+5h30m). Self-update waits for the whole leg (decision 32). Switch `settings.json`
> `app_update.unattended`, absent = ON, card on /settings (`POST /settings/app-update`). Page line
> `last_auto_update` („Automatikus frissítés %s-kor — sikeres."), report `update_leg`. R-678: `ScanStacks` before
> `finishUpdate` on done/undone. `stacks.update_window` REMOVED. Decisions 31–33 taken unattended (operator may
> reverse). Open: R-686 (no resume after a restart), R-687 (live-proof gaps), R-685 controller half (the backup
> page line for a space skip). Record: `felhom.eu/documentation/audits/DRILL-night-2026-09-25.md`.
> **2026-09-24 evening — v0.270.0, floor 0.270.0.** `UpdatePreflight` refuses `already_current` (R-679).
> `stacks/install_interrupted.go`: `.felhom-install-pending` marker + `RecoverInterruptedInstalls` at start (after the
> deploy-done hook) → compose down, stale pins cleared, `app_deploy_failed`, `.felhom-install-interrupted` → the
> apps page sentence (R-681). The recovery unit captures `stacks.AppliedMetaFile`; a restore calls
> `RecordRestoredAppliedMeta` (R-669). Next: part 7 (the update leg, `09` §6.4.2), R-678, R-680, R-682.
> **2026-09-24 night — v0.269.0 + v0.269.1. Fleet floor 0.269.1 (MinAgent 0.131.0; needs hub v0.123.0).**
> `backup.RestoreTier2Whole` (`tier2_whole.go`, decision 26): `mergeRestoreFiles` by the four file rules
> (never delete; never an older file over a newer one; bring back missing; an older differing live file kept
> beside as `.felhom-<UTC>`), then `RestoreFromRecoveryUnitAtWith(AcceptMissingFiles)` from the MIRROR; refuses
> before anything moves (not a file app / no proven openable mirror / < 2 GB). `sameDevice` fails CLOSED (R-668).
> Decision 27: `RemoveKeepsDataOnly` → 409 `err.stacks.remove_keep_data_while_support`; `keep_data_only` on
> hdd-data. Decision 28/29: `stacks.ObserveUnhealthy` (RestartCount delta ≥ 6 in 10 min, OOM ≥ 20 in 30 min)
> → `stopUnhealthyApps` → `HoldUnhealthy` (`unhealthy_stop`, trip 2 within 24 h) → `app_stopped_unhealthy`;
> Start lifts it. R-664/R-665: `entry.NewMeta` = the step's own `.felhom.yml`. §6.4 part 6 box half:
> `stacks/digest.go` — `RenderWithLadderDigests` for update + fresh install; **`CarryDigests` for an installed
> app's sync (v0.269.1, decision 30)**. Open next: R-669 (applied-meta after hold + restore), R-679/R-680 +
> part 7 (the update leg, `09` §6.4.2), R-681/R-682 (interrupted install/remove). Record:
> `felhom.eu/documentation/audits/DRILL-night-2026-09-24.md`.
> **2026-09-24 — v0.268.0 (R-658, R-659, R-660, R-651; `09` §6.4 part 5). Fleet floor 0.268.0.** The undo
> selects volumes with `DeclaredVolumeNames` (the compose definition), never the project label; the restore
> labels volumes (project/volume/version, no config-hash). The hold names the newest WHOLE copy
> (`backup.WholeOnTier` = the restores' refusal predicate `DeclaredDriveFileLegs`; off-site only for file apps)
> or, with none, `hold.update.no_whole_copy` + no Mentések button + `app_hold_no_whole_copy` (needs hub
> v0.122.0). `UpdateHeldStacks` is the fourth app-down suppression. Remove deletes `applied-compose.yml` +
> `applied-meta/`. The ladder: `stacks/ladder.go` — `nextLadderStep` picks the NEWEST entry whose `from` is
> the pin; a non-head step pins `catalog-cache/templates/<app>/steps/<StepKey(to)>.yml`; missing/wrong →
> `update.error.pin_failed` before anything moves; no match → template, logged. `LadderStepsLeft` on the
> page. Open next to it: R-661 (Tier-2 no whole action), R-664 (a step has no .felhom.yml), R-665, R-666,
> R-667 (crash-loop clock resets on a flapping container).
> **2026-09-23 (night) — v0.267.0 (R-650, R-640, R-499, R-518).** Every docker exec goes through
> `internal/dockerexec`; under `go test` a real docker is refused (opt-in `FELHOM_TEST_REAL_DOCKER=1`; a
> stub under `os.TempDir()` passes). `api`/`stacks`/`web` tests run under `RunWithStub` (TestMain). Restore:
> `appbackup.CheckDumpComplete` — a dump without its engine's end marker is refused before the first
> mutation (unit + off-site) and again before any load. Tier-2 page: `systemBackupFact` → four sentences.
> Backup button: the measured ~8 min. R-626 not reproduced (closed by measurement).
> **2026-09-23 (late evening) — v0.265.0 (R-634, R-625, R-636, R-647).** A whole-box backup no longer
> stops/restarts a DEPLOYING app (`ListDeployedStacks` skips `Deploying`; the volume leg re-asks via the
> optional `deployingReporter`; `StopStack`/`StartStack` → `ErrStackDeploying`). Held apps: badge
> `badge.update.held`, no Update button. OOM: the kernel `oom_kill` counter (the `OOMKilled` flag is sticky);
> ≥20 kills / 30 min per container run → one `app_oom_storm`. Web `readerFuncs` render hold/error per reader
> in EVERY language. Needs hub v0.121.0. Floor 0.265.0. Open: R-649 (operator), R-650 (tests on real docker).
> **2026-09-23 (evening) — v0.264.0 (`09` §6.4 parts 2–3; R-606, R-620, R-646).** Two events,
> `app_update_undone` / `app_update_held`, one per app per outcome, via `Manager.SetUpdateEventSink`
> (main.go) → `NotifyAppUpdateUndone` / `NotifyAppUpdateHeld`; on by default and seeded once on existing
> boxes (`app_update_events_seeded`), with two toggles on the notifications page (the page pushes the
> list to the hub on save). Update sentences are stored as key + args (`UpdateErrorKey/Args`,
> `update.error.*`, `update.refusal.*`, `update.phase.*`, `hold.*`) and rendered per reader
> (`updatePhaseText` / `updateErrorText` / `holdText`, `RestoreHoldForLang`, `UpdateErrorIn`); stored
> Hungarian unchanged. Startup `BackfillAppliedMeta` (R-646). Disabled notifier WARNs once per type
> (R-620). Needs hub v0.120.0. Floor 0.264.0. Leftovers: R-647. Evidence:
> `felhom.eu/documentation/audits/undo-fleet-2026-09-23/`.
> **2026-09-23 — v0.263.0 → v0.263.2 (R-637, `09` §3 decisions 15 + 19).** A failed health check after
> an update is now UNDONE: phase `copying` (after the pull, the app stopped anyway) copies every NAMED
> volume `cp -a` into `<vol>.pre-update-<stamp>` (label `felhom.undo-copy-of`, finished-marker last);
> on failure `undoing` validates every copy first, refills the volumes, puts back definition + pin +
> the pinned version's `.felhom.yml` from the job's OWN copies, and checks the old version with ITS
> probe → `undone` (`app.yaml` `last_update_undone`, one page line) or a HOLD prefixed with the undo's
> failure and the data state. Bind folders are never touched. Journal recovery: `copying` → old version
> back; `undoing` → resumed. **Two live-only lessons, both now in code:** (1) the periodic probe with
> the CURRENT `.felhom.yml` flips the app `unhealthy`, so the undo's health wait must probe an
> `unhealthy` app with its own override (`waitUpdateHealthyMeta`, v0.263.1); (2) `.felhom.yml` flows
> into the stack dir on every catalog SYNC, so the pinned version's file must be recorded AT PIN TIME
> — `<stack>/applied-meta/.felhom.yml`, written at deploy, adoption and pin advance (v0.263.2). Apps
> pinned earlier have no record until their next pin (R-646). R-642: start/restart never say
> "completed". **The fleet floor is still 0.262.1** — raising it is the operator's decision. Evidence:
> `felhom.eu/documentation/audits/undo-bakeoff-2026-09-23/`, `…/undo-live-2026-09-23/`.
> **2026-09-21 (evening) — v0.261.0 (R-608 + R-609).** The controller self-updates daily at **04:30** by default and after ANY hub report once a floor sits above the box; that swap restarts the controller, which is the supervisor of a running app update. The window `09` §3b Q1 proposes for automatic app updates is 02:30-05:00. **It contains 04:30.** **A two-way lock, wired in `main.go` — `stacks` never imports `selfupdate`:** `Manager.AnyUpdating()` -> `Updater.SetAppUpdatingCheck` (a sibling of the existing `SetBackupRunningCheck`, consulted in the SAME three places), and `Updater.IsUpdateRunning` -> `Manager.SetSelfUpdatingCheck` with `UpdatePreflight` refusing `self_updating`. **MEASURED: the gap was NARROWER than assumed** — the update's `backing-up` phase already took the backup single-flight, so only the other six phases were exposed. The live probe landed in `safety-dump`, i.e. in the real gap. **THE PROPERTY THAT MATTERS: the lock must NOT latch** — `Stack.Updating` clears on done, failed AND held, so a held app does not block the controller's own updates for ever. **R-609:** the 409 now carries `data.reason` — transient (`busy`,`updating`,`deploying`,`migrating`,`self_updating`) vs terminal (`held`,`downgrade`). **Found while writing the test: the router refuses a HELD app on its OWN line BEFORE `UpdatePreflight`**, so `held` — the reason an unattended caller needs most — would have been the one missing. Five red-proofs, each seen to fail. **Proven live on guest 9202 with a negative control:** with no app update the manual swap gives the AGENT refusal; with one in flight it gives OURS. The sentence changing IS the proof. Deployed to **9202 only**; the fleet floor is 0.260.0 and 0.261.0 is a separate operator ask. Measurements: `felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/`.
> **2026-09-21 — v0.260.0 (R-524, update arc).** A box that updated before the catalog was reverted under it read „Frissítés elérhető" over an Update that would have moved its pin BACKWARDS (measured BIGNIGHT Phase 6, privatebin 2.0.6 vs catalog 2.0.5). **The comparison gains a fourth verdict and MOVES OUT OF `web`:** `stacks.CatalogOrder` — Unknown/Current/Behind/**Ahead** — is read by BOTH the badge and `UpdatePreflight`, because a comparison implemented twice drifts. Ahead reads „Naprakész"/"Up to date" (`tag-ok`, same word and class as level — nothing for the household to do) with a title saying why; the Update is refused `downgrade` 409. **The API now renders update refusals through `errText`** — otherwise the new key would be a seam built and never wired. **Ahead is NARROW:** every differing service must be orderable AND newer, else Behind — this gate can BLOCK an update, so it errs towards letting one run. **THE TRAP, and the fixture caught it, not the design:** the first tag rule accepted only bare `X.Y.Z`, so every REAL catalog tag was unorderable and the refusal test failed with `got nil`. The rule now takes the version at the FRONT and requires the trailing suffix to be IDENTICAL on both sides — `31.0.14-apache → 31.0.15-apache` orders; `26.05.2-ls310 → -ls311`, `postgres:16-alpine`, `apache-2.57.0`, a date stamp and a digest pin do not. Ordering is `util.Version.Compare` and nothing else (one comparator, house rule). Three red-proofs, each seen to fail. **R-589 was NOT open** — it shipped in v0.258.0 and only its row was stale; a reviewer who reads ONE producer cannot see a SECOND that overrides it. **Floor NOT raised — the operator's step; and CONTEXT's own 0.257.0 was STALE — the hub says 0.259.0 (read live from `/configs`, never from a doc).** Deployed on demo-felhom 9201, demo-hp 9201 and demo-hp 9202 (scratch, upgraded from 0.245.0 for the live proof). Seven questions for Slices 6 and 7 are in `09` §3b; the state of the whole arc with drift numbers is `audits/UPDATE-ARC-STATE-2026-09-21.md`.
> **2026-09-21 — v0.259.0 (R-596 P1 + R-598, the drill's blockers).** The claim page's answers and the Backup page's protection warnings follow the reader's language. **Fourteen** live sites carrying **nine** messages, not the sixteen literals R-596 counted — and the sixteenth, `data["Title"]`, was **DEAD** (`claim.html` is standalone; `.Title` belongs to `layout.html`) and was DELETED rather than translated. `backup_handlers.go` had **nine** code literals, not twelve; three were inside comments. `degradedMessageFor` now returns a **KEY**, so the decision stays language-free in one place while the words are chosen by whoever knows the reader; `buildTierViews`/`backupTargetLabel`/`loadGuestBackup` take `lang` the `buildDataPathCards` way. **The anonymous cookie-less claim page's language chain** (`langFor` → `settings.GetLanguage` → `configLanguage` ← `cfg.Customer.Language`) **was an unpinned assumption and is now a test.** Six existing copy-contract tests were kept rather than weakened — each resolves its key through the real bundle, so they still convict on a reworded Hungarian sentence. **Two things the tests caught and review did not:** an apostrophe in an English value is escaped to `&#39;` and NEVER matches on the page (the failure reads exactly like an unwired handler — R-603), and the i18n gate convicted two of my English sentences for saying "please". **Proven live on guest 9201 through the `felhom_lang` cookie** — and the lockout proved itself unasked: Hungarian attempts locked out the English request from the same source, so the counter is per SOURCE, not per language. **The instrument trap that nearly cost a second fix (R-602): the cookie works only on ANONYMOUS pages** — `langFor` step 2 means a signed-in request reads the household's setting and ignores the cookie, so `/backups?felhom_lang=en` returns HUNGARIAN and reads like an unfixed defect. Use `?lang=` behind auth. **Floor NOT raised — the operator's step**; it still stands at 0.257.0. Guest 9201 is on **felhom-pve**, and **demo-hp answers on no route** (R-601).
> **2026-09-20 — v0.258.0 (R-589/R-590/R-573/R-572, slice 6 Part 0).** The four leftovers slice 5's LIVE proof found, all Go-side: the update badge and the lifecycle badge through `localeFuncs` (reusing `compareInstalledToTemplate`/`EffectiveLifecycle`, so the DECISION cannot drift between languages — only the words do); the data-folder card's backup sentence through `s.msgLang`, with the test asserting the CONSEQUENCE in both languages AND that the two differ; the two channel banners keyed by the checker's own CLASSIFICATION with the composed sentence as a FAIL-OPEN fallback. **R-572 was not what its row said** — nothing called `pruneLabel`/`nextPruneLabel`, so the two dead Hungarian-producing func-map entries were DELETED; deletion is fail-loud and that was proven (a template naming a removed func panics `loadTemplates`). Five red-proofs; one of them convicted the GATE rather than the code — `localeFuncs` keys sit under `_preexisting` and are not measured against the base capture, so the citation to `TestLocaleFuncsHungarianBundleMatchesFuncMap` is what carries them, and that test was extended to make the citation true.
> **THE WALK (slice 6 Part B) then judged it**, on a box installed from scratch that day by an English speaker: `felhom.eu/documentation/audits/DRILL-first-hour-en-0258-2026-09-20.md`. Verdict **NOT YET ready for an English-speaking tester** — `internal/web/claim.go` has **16 raw Hungarian literals**, every one a message on the one screen between a household and their box (**R-596, P1**), and `backup_handlers.go` + `backup_target_offer.go` carry the Backup page's two protection warnings (**R-598**). Both are the *composed-sentence-into-page-data* shape — **three instances on file now** (R-573, R-596, R-598), worth naming as a class. Everything else held with ZERO Hungarian: the download page, the bilingual console, all three customer mails, the bind page, the dashboard's first language from `customer.language` alone, both app pages, the catalog, the language switch. **R-214 closed as a side effect.** **Floor NOT raised — the operator's step.**
> **2026-09-20 — v0.257.0 (R-560 slice 5 Part A).** The catalog's `.felhom.yml` may now carry an `i18n: {en: …}` sibling block, and the dashboard reads it. `Metadata.For(lang)` merges FIELD BY FIELD (absent or blank English shows Hungarian, so a half-translated app ships); `For("hu")` is the parsed struct with `I18n` cleared and nothing else, deep-compared over all **53 real catalog files** copied into `internal/stacks/testdata/catalog/`; the three prose lists replace WHOLE and every other list is matched by its own key (`env_var`, option `value`, `match_group`, `target`, `path`) — **never by position**; `For` never writes through the manager's shared metadata. Pages reach copy only via `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`, and `TestNoDirectMetaCopyReadOnPages` AST-parses `internal/web` against a named allow-list so the NEXT page to read catalog copy fails the suite instead of rendering Hungarian to an English household. **Two of the nine red-proofs convicted a hollow TEST, not the code:** a struct copy shares its slices' backing arrays, so the obvious `DeepEqual` mutation check passed a deliberately broken merge; and a ONE-entry fixture cannot tell key matching from position matching. Both rewritten. Proven live on demo-hp (English pages English, seven Hungarian pages byte-identical apart from the CSRF token) and on demo-felhom **0.255.0** (block synced, pages identical, no parse warning in a 93-line log window). Gaps: R-589 (update badge still Hungarian in English), R-590 (the data-folder promise sentence), R-591 (`Stack.Copy()` does not deep-copy the new `Meta.I18n`), R-592 (three defects inside the new catalog gate, each found by its own decoy). **FLEET FLOOR RAISED to 0.257.0** the same session (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED for demo-felhom … from declared`; **demo-felhom went 0.255.0 → 0.257.0 by itself in under 12 s**, healthy, `settle-gate: GO — at/above floor 0.257.0`, **and then rendered the English tagline** — the floor delivered the FEATURE, not a version string. Peti's box and tester-1 are DOWN and take it unattended when they return. The catalog was fully translated the same day (50 more apps in three pushes, 1 031 of 1 032 strings): **the English Apps list shows ZERO Hungarian app descriptions across all 53**. Gap added: R-594 (the catalog gate can convict a retrieval promise but cannot REGISTER a true one).
> **2026-09-18 — v0.255.0 (fixes the v0.254.0 globe).** The sign-in-flow pages requested `style.css` with **no `?v=`**, so a browser holding a pre-0.254.0 copy served CSS with no `.lang-globe` rules and the globe rendered as a bare unstyled `<details>` — **invisible to every test, because they all read markup and the fault was in which CSS file the browser fetched**. It was FIVE shells, not three (both guest share pages too), and `.Version` was missing from three of their data maps — now set once in `executeTemplateLang`. The globe also moved INSIDE the card, centred under the footer, menu opening upward via the shared rule. 15 shell fixtures re-captured, **zero dashboard ones**. R-579.
> **2026-09-18 — FLEET FLOOR RAISED to 0.254.0** (operator asked), `min_agent` 0.131.0 declared — above the vouched golden 0.246.0, so the declaration carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.253.0 -> 0.254.0 **by itself in ~40 s**, healthy, four other containers up, own log `settle-gate: GO — at/above floor 0.254.0`, and its sign-in page shows **one globe, zero old text links**. **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.254.0.md`.
> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`<details>`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577.
> **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`.
> **2026-09-18 — v0.253.0 (R-557 slice 2 release B).** All **179** Hungarian error messages carry a key (`util.MsgError`): `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND the wrapped cause, and an inner error argument renders recursively. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error, `UpdateRefusal` gained a `Cause`. Plurals: a key with `.one`/`.other` takes its count FIRST (bundle rule, not a call-site flag) — the guard caught a real key collision. **Two tooling defects found and recorded:** the bulk converter dropped multi-line concatenations (7 producers; the parity gate could not see it, two behaviour tests could), and my counter was case-sensitive and under-reported. 705 literals left, 0 of them errors.
> **2026-09-18 — v0.252.0 (R-557 slice 2 release A).** The sentences the program BUILDS now follow the language: 226 Go literals converted (flash lines, page data, API answers, alert banners, 237 country names, the four app-named page titles — R-566 closed). Flash travels as a KEY in the redirect URL with `fa` parameters; an old link's prose is still shown verbatim. New gate `i18n_go_parity.py` refuses any key whose Hungarian is not byte-identical to the base commit (7 467 literals frozen; 3 decoys). Wire goldens freeze the report warnings and the event messages the hub MAILS — those stay Hungarian until slice 3 (R-558). Remaining: 894 literals, 176 of them `fmt.Errorf` (release B); persisted text (release C, §16 option 1). New rows R-572/R-573/R-574.
> **2026-09-17 night — v0.251.0 (R-553 + R-563 CLOSED).** Five decisions that read their own Hungarian words now read a kind: deploy status sentinels (`internal/stacks/deploy_errors.go`), `backup.ErrOffsiteQuota`, `monitor.WarningKinds`, `settings.LastWarningKind` (legacy text fallback until R-570), and `data-status` on the remote-backup status. `util.KindErrorf` keeps message bytes identical. Slice 2 (R-557) is unblocked except the one producer named in R-570.
> **2026-09-17 night — v0.250.0 (R-556 release C, slice 1 CLOSED).** All templates converted; the language switch is on every dashboard page (decision 6 superseded); login/claim/guest/catch-all through `executeTemplateLang`; Go-side messages and three app-name titles stay Hungarian (slice 2).
> **2026-09-17 evening — v0.248.0 + v0.249.0 (R-556 releases A, B).** Apps/settings and backup pages
> converted; `TestI18nParityCoversEveryMarker` (every marker rendered by a case); fixtures only written
> when missing (`-update-i18n-golden`, `-i18n-golden-only`); `executeTemplateLang` for pages outside the
> dashboard chrome (recovery now). Words a page COMPARES stay unconverted (R-563). Split Hungarian verbs
> escape the retrieval gate's Hungarian stems (R-564).
> **2026-09-17 — v0.247.0 (localisation starter; design `felhom.eu/documentation/architecture/10-localisation.md`).**
> Message bundles `internal/i18n/locales/{hu,en}.json`; templates carry `{{T "key"}}`, EXPANDED
> before parsing — one template set per language (`loadTemplates` / `parseTemplateSet`); `s.tmpl` is
> the Hungarian set. Converted: launcher, `/backups`, `/apps/<slug>`, `layout.html`. **Release gate for
> every future slice: `TestI18nParity`** — Hungarian render vs fixtures captured from UNCONVERTED
> templates; never regenerate fixtures to make a conversion pass. `settings.json` `language`,
> `POST /settings/language`, `?lang=` override; switch shown only on non-Hungarian pages (decided by CC,
> operator may reverse). Report carries `"language"`. Copy gates now read templates expanded
> (`scripts/i18n_bundle.py`) — they had gone blind/stale when copy moved. New `i18n_missing_gate.py`
> (ratchets: English gap 0, formal „ön" forms 6). Converting a page: `scripts/i18n_extract.py`, then
> REVIEW what it left (ASCII-only Hungarian slips past it). Plan rows R-553..R-562 (R-553 first).
> **2026-09-13 night — v0.242.0 (R-487 / R-491 / R-490 / R-489 / R-476 / R-456).** The local backup
> lists are keyed on the DRIVES, not on what is deployed (`ListRemovedAppUnits`, the R-237 rule one
> tier down): a removed app whose unit was kept is listed with its restore, the picker and
> `/api/backup/snapshots` answer for it, and the restore opens the unit where it sits
> (`primaryUnitDirFor`). A removal clears an update hold. `/api/system/info` reaches the API router.
> `volumes_removed` is a before/after difference — **but a volume recreated by a unit restore has no
> compose label and is missed (R-489 open, residual: list by `<project>_` prefix too)**. Tier-2 copy
> date = newest dump (`UnitDataDate`) unless the leg was preserved. **The scratch guest 9202 on demo-hp
> is where releases are validated now** (hand-set image; hub/tunnel off) — `felhom.eu/CONTEXT.md`.
> **2026-09-13 — v0.237.0 (slice 4: R-448, R-443, R-439).** Update is a guarded 202 job:
> refusals (hold, busy, memory, disk, no restorable Tier-2 copy) → backup-first if the proven copy is
> older than `update.backup_max_age` (24h) → safety dump → pin → pull (failure: pin BACK) → up →
> health (`.felhom.yml` check or 60 s settle, `update.health_timeout` 5m; failure: stop + HOLD,
> `RestoreHold.Reason=update_failed`, pin stays). `UpdateStack` deleted. **The copy is aged by the last
> successful Tier-2 copy, NOT the manifest `created_at`** — measured on demo-hp that `created_at` moves
> only on definition changes. Journal `update-journal.json`; `RecoverUpdates` before the boot sweep.
> Three unattended paths that ignored a hold now honour it (drive-return gate ×2, nightly volume dump);
> capture and Tier-2 skip held apps. **No automatic rollback** — measured per-app; route back = restore.
> **2026-09-13 — v0.236.0 (R-442).** Removal resolves the drive from the app's OWN `app.yaml`
> `HDD_PATH` (the `07` ~L437 rule), never the global `cfg.Paths.HDDPath` (set on no box); a data
> removal it cannot resolve is REFUSED (409, exact Hungarian sentence, typed `RemoveRefusedError`)
> before anything is touched and the app is kept. SSD app → `hdd_paths_removed: []`, never `null`.
> Backup-path refusals reach the response. Proven live on demo-hp — see `REPORT.md`.
> **2026-09-06 — v0.235.0. THE RULING, AND THE TRAP IT SET.**
>
> **1. OPERATOR RULING: freeze the version, keep the fixes flowing.** R-447 sat `BLOCKED` because
> R-438 established that `RestartStack`'s `up -d` was a CHOSEN behaviour with its reason in its own
> comment. Option 1 was taken: an app's version is frozen to what the customer has and only a
> deliberate Update moves it, while health-check fixes, memory limits and self-healing keep arriving
> on the 15-minute cycle. **Both halves of the old behaviour were examined; only the version change
> was unwanted.**
>
> **2. THE MECHANISM IS A RENDER, NOT A GATE — and that distinction is the whole design.** Nothing was
> added to any of the thirteen `compose up -d` call sites. Most of them are REPAIRS (boot reconciler,
> drive-return gate, app-stop guard), and a repair that refuses to repair leaves a customer's app
> down. They are made safe by removing the reason: the file they act on no longer changes version.
>
> **3. `pinned_images` IS INTENT; `installed_images` IS AN OBSERVATION. NEVER FEED ONE FROM THE
> OTHER.** Letting a reading become a deployment is the R-166 category error one field over. They will
> normally agree; when they disagree that is a signal.
>
> **4. THE TRAP THIS RELEASE SET FOR ITSELF, and it would have shipped silently.** `Stack.TemplateImages`
> is read from the app's LIVE compose file — which is now the RENDERED one. On a frozen app that file
> names the OLD version, so the update badge would have found installed == template and answered
> **„Naprakész" on exactly the apps that are behind**, with every test still green, because the new
> field has the same type and shape. The badge now reads `Stack.CatalogImages`, from the syncer's own
> clone. **A feature that silently inverts a previous feature is the failure mode to look for whenever
> a file changes meaning.**
> **2026-09-03 — v0.234.0. ONE GAP CLOSED, ONE TEST DEFECT OF MY OWN.**
>
> **1. A RECORD THAT ONLY THE BRING-UP PATHS WRITE NEVER REACHES A QUIET BOX.** v0.233.0 shipped with
> "the record appears after the next lifecycle action" written down as a known limitation. One day
> later the operator looked at demo-felhom and saw OpenGist — up 15 hours, running exactly the catalog
> pin, **no badge at all**. The limitation WAS the feature not working. `BackfillInstalledImages` now
> seeds the absences at startup by READING containers. **The general lesson: a feature that only fills
> itself in on an event nobody triggers is, on the quiet installations, not shipped.**
>
> **2. THE BACKFILL IS STRICTER THAN THE BRING-UP PATHS, AND THE ASYMMETRY IS THE DESIGN.** The badge
> reads a service-count mismatch as BEHIND. The bring-up recorder runs right after a SUCCESSFUL
> `up -d`, where a missing container is real news; a backfill meets a box in whatever state it is in,
> so a partial seed would render „Frissítés elérhető" over an app that is perfectly current. It
> therefore refuses to seed anything it cannot observe COMPLETELY. **Same data, two writers, two
> different admission rules — do not "make them consistent".**
>
> **3. A TEST THAT HARDCODES A DATE AND ASSERTS AN AGE IS GREEN ONLY ON THE DAY IT IS WRITTEN.**
> `TestGroupD_BadgeRendersOnBothSurfaces` pinned `catalog_since: "2026-07-18"` and the string
> "46 napja". The pure tests inject a clock; the RENDER path goes through the funcmap and reads
> `time.Now()`. It passed on 2026-09-02 and was red on 2026-09-03. Now derived from the same clock the
> code reads. **R-457** names six other test files that mix a literal date with `time.Now()` — as
> unchecked candidates, not accusations.
> **2026-09-02 — v0.233.0. TWO DECISIONS, AND ONE LIMITATION THAT IS NOT A DEFECT.**
>
> **1. `installed_images` is an OBSERVATION, so a failed write NEVER refuses the action — deliberately
> the opposite of `desired_state`.** `SetDesiredState` refuses the act when the record fails, because
> intent that could not be recorded recreates the exact ambiguity R-166 closed. `recordInstalledImages`
> does the reverse: refusing to start a customer's app because we could not write down which version
> it is trades a real outage for a bookkeeping gap. **The rule is "refuse on intent, log on
> observation", and the reason is in both code comments** so neither gets "made consistent" later.
>
> **2. THE RECORD READS THE CONTAINER, NEVER THE COMPOSE FILE — and the file is not a lesser source,
> it is a WRONG one.** The catalog syncer overwrites a deployed app's `docker-compose.yml` on a
> 15-minute cycle with no deployed check at all; the spike measured the file saying `v2.8.5` while the
> container ran `v2.8.6` for 25 minutes. Anything derived from that file answers "what will happen
> next time something runs `up -d`", which is a different question from "what is running".
>
> **3. THE LIMITATION, STATED: 23 catalog pins FLOAT, so „Naprakész" CAN BE FALSE.** The comparison is
> reference-to-reference and queries no registry (a box must not need eight upstream registries to
> render a page). For `postgres:16-alpine`, `mariadb:11.6` and 21 others the ref can be identical while
> the image behind it has moved — the spike caught `mariadb:11.4` and `mariadb:12.3` already moved.
> **This is a known gap with a register row, not an oversight.** Digest-level comparison needs a
> registry query and is deferred.
>
> **Nothing about updating changed.** No behaviour, no new endpoint, no auto-update, and the three
> lifecycle buttons are byte-identical (`TestScenarioE_TheUpdateButtonIsUntouched`). The behaviour work
> is slice 3 and needs an operator ruling. The whole arc's reasoning now has a home:
> `felhom.eu/documentation/architecture/09-update-architecture.md` (R-438 — its absence was a finding).
> **2026-09-01 — v0.232.0. THREE RULINGS.**
>
> **1. `restic stats` TAKES A REPOSITORY LOCK, and that is the fact the whole R-411 chain rested on.**
> Nobody had it. Clean-room measured on demo-hp 2026-08-31: nothing else running, four invocations,
> the sampler reads `locks=1`. A customer FULL restore shells `stats` in its size probe, so it holds a
> lock — and until v0.232.0 it held no single-writer flag, so the integrity check was not blocked, ran,
> met that lock, and `resticStep` removed it with `unlock --remove-all` while logging *"a stale
> exclusive lock left by a previous crash"*. There was no crash. Also measured, and recorded so the
> next reader does not re-derive it: `restic check` takes a lock; `restic snapshots` and `restic list`
> do **not**.
>
> **2. THE INVARIANT IS PINNED BY A WALK, NOT BY A COMMENT — and the walk is the deliverable, not the
> acquire.** `offbox_integrity.go:28` asserted *"Every off-site operation takes `acquireRunning`"* from
> v0.227.0 and it was false for months, which is the ninth instance of this project's most-repeated
> class. `TestR408_EveryOffsiteEntryPointTakesTheFlagOrIsRegistered` is an AST pass over
> `internal/backup` — deliberately not `strings.Contains`, because a commented-out call still contains
> the string. **On its first run it found three entry points nobody had named**:
> `OffboxRestorePrepareFull` (the request the customer's UI reaches FIRST, and the one that shells
> `stats`), `RestoreSharesScratch` (R-411's exact shape on the shares tier, with a live caller) and
> `RestoreOffbox` (no caller today). All four now take the flag. `OffsiteInventoryList` is registered
> EXEMPT with its reason — `snapshots` only, measured not to lock, and flagging it would make a page
> refuse to load during a backup for no safety gain. **Adding a line to `offsiteExempt` is a deliberate
> act and belongs in the commit that adds it.**
>
> **3. R-414's BRANCH, AND THE EVIDENCE FOR IT.** The question was whether `offboxRestoreScratchDir`
> was MISSED by R-356's unification or EXCLUDED on purpose. **Neither label fits: it was consciously
> OUT OF SCOPE.** R-356's own commit (`08eb1a6`) says so in its test comments — *"the prepared scratch
> still resolves to the registered storage path … only the DESTINATION moves, which is precisely what
> this change is about"* — and every one of its fixtures assumed a registered storage path exists.
> `demo-felhom`, with `storage_paths: []`, is the case it never had. It was **never ruled out on
> state-only grounds**: the one comment about a `systemDataPath` fallback belonged to
> `PlaceOffsiteRestore`, concerned bulk **userdata**, and R-356 deleted it deliberately. This
> function's own documented exclusion is `cfg.Paths.DataDir` — the **rootfs** — a different filesystem.
>
> **So §6.3's `[DESIGN]` rule applies and now has a FOURTH consumer.** But the fallback is **SCOPED**,
> because the two callers ask different questions and one predicate answering both is the R-356 defect
> itself: **unit-only** may fall back (§7 records as `[FACT]` that a driveless app's unit already lives
> on `systemDataPath` indefinitely and that the same-device placement is *"intended, not a defect"*);
> **full** keeps the R-252 refusal, because it pulls bulk userdata onto a state-only tier (§2.2).
>
> **AND THE SILENCE ENDS EITHER WAY.** `ProofResultCannotRun` is recorded through
> `RecordProofVerdict`, so `last_proof_result` is never ABSENT — absent already means *"controller
> older than v0.231.0"*, and giving one field two meanings is the `StatsKnown` trap one level up. It
> does **not** advance per-snapshot due-ness: nothing was proved, and marking one proved would stop the
> app being retried once a drive is finally registered.
>
> **A MISTAKE OF MINE, RECORDED BECAUSE LIVE VALIDATION IS WHAT CAUGHT IT.** The fallback resolved a
> scratch that `removeProofScratch` then refused to delete — its accepted-roots list is built from
> REGISTERED drives, and a driveless box has none. Observed on `demo-felhom`: *"refusing to remove …
> it is not inside a proof root"*, with the copy still on disk. Every nightly proof would have left one
> behind, on exactly the boxes the fallback exists for. **The unit tests all registered a drive, so
> none of them could see it.** Fixed, and pinned by a pair — one that the copy IS removed on a
> driveless box, one that a path outside every proof root is still REFUSED, so the fix is not a
> widening into uselessness.
> **2026-08-31 — v0.231.0. FOUR RULINGS, recorded so none is re-litigated.**
>
> **1. THE ACCEPTANCE RULE IS NOT "EVERY DECLARED FILE IS PRESENT".** That is the spike's own one-line
> summary and taken literally it is worthless: a hollow unit declares nothing, so everything it
> declares is present, and the check passes on exactly the shape it exists to catch. The rule has TWO
> parts and needs both — (1) everything declared is present, AND (2) the manifest declares what the app
> is SUPPOSED to have. **Part 2 is the whole value; part 1 alone is the trap.**
> `TestR87_HollowUnitForAnAppWithADatabaseFAILS` is the fence and its red-proof models the naive rule.
>
> **2. THE EXPECTATION COMES FROM INSIDE THE UNIT, NEVER FROM THE LIVE BOX.** R-403's guard could tell
> hollow from legitimately-empty because it had TWO copies to compare; this has ONE. The snapshot may
> predate the app's current shape, and the point is to judge the snapshot on its own terms — so the
> source is the unit's own `compose/docker-compose.yml`, and `GetDockerVolumes` (live Docker,
> `backup.go`) is explicitly NOT it. Database half: `DBServiceNames`, the same discriminator
> `RestoreFromRecoveryUnit` already uses, so this cannot disagree with the restore path about what an
> app is. Volume half: `ParseComposeNamedVolumes`.
>
> **THE VOLUME HALF IS AN EXISTENCE CHECK AND NOT A NAME MATCH, and that half-rule is deliberate.**
> Volume tars are `<project>_<volume>.tar`; `ResolveDockerVolumeNames` derives the project from
> `filepath.Base(filepath.Dir(composePath))`, which inside a unit is the literal string `compose`, not
> the stack. Measured 2026-08-31 on all eight real units on demo-hp: the counts match exactly
> (bookstack 2/2, docmost 3/3, kimai 2/2, opengist 1/1, privatebin 1/1, calibre-web 1/1, paperless-ngx
> 3/3, romm 3/3) and `<stack>_<volume>.tar` held in every case. **"Held on eight" is not "derivable"**
> — R-355's standing rule is that a claim about the app must never be inferred from a counter. Half a
> rule that is true beats a whole rule that is invented. Name-level matching is available the moment
> the capture records the project, and is not worth inventing before then.
>
> **3. THE PROOF'S RESTORE IS A READ-ONLY VARIANT, NOT A CHANGE TO THE CUSTOMER'S PATH.**
> `RestoreOffboxScratch` is the customer's restore, it is proven, and a customer restore taking a lock
> is correct — so it was NOT changed. What was shared instead of forked: the scratch-dir resolver is
> now parameterised on its ROOT builder (`offboxScratchDirIn`), so the drive-preference rules, the
> network-storage refusal and the R-252 wording have exactly one implementation; and the unit-only
> headroom gate is extracted to `unitOnlyHeadroom` so both paths refuse at the same floor with the same
> Hungarian sentence. The proof's own three differences are the ones that must differ: `--no-lock`, no
> `unlockStale`, and `m.runner()` instead of `resticStep` so the `unlock --remove-all` escalation is
> unreachable rather than unlikely (REUSE.md's rule: replacing `resticStep` would hide the escalation
> from the assertion that must see it).
>
> **THE PROOF SCRATCH IS A SEPARATE ROOT (`backups/offsite-proof`) AND THAT IS A SAFETY DECISION, NOT
> TIDINESS.** The proof deletes its copy on every path including failure. Sharing
> `backups/offsite-restore/<app>` would mean a nightly background job deleting the verification copy a
> CUSTOMER made and is looking at — a poorer actor destroying a richer one, R-403's shape in different
> clothes. The separate root also keeps the proof copy invisible to `DeleteOffsiteRestoreCopy`, the
> copy listing and `OffboxFullScratchReady`, so it can never be offered for placement into a live app.
>
> **4. THE ALARM IS A NEW EVENT TYPE, AND REUSING `backup_integrity_failed` WOULD HAVE BEEN WRONG.**
> That type is the nearest existing one and it carries a hub-side Hungarian template saying the
> integrity check found an error — i.e. **the store is damaged**. Here the store is sound and the
> CONTENT is missing: a different fact, a different cause, a different customer action, and telling
> someone their backups are damaged when they are not is the more expensive mistake (the same asymmetry
> `looksLikeRepositoryDamage` is shaped around). So `offsite_proof_empty` was minted, severity `error`,
> with NO `customerMessages` entry so the controller's dynamic Hungarian survives, and **the hub half —
> `allowedEventTypes` plus `operatorOnlyEvents` — ships in the same commit**, because an unallowlisted
> type is answered 400 and vanishes, and a missing customerMessages entry is not a routing block.
> **This widened the task's stated scope to `felhom.eu/hub/`** and the reason is recorded here rather
> than left as an unexplained diff.
>
> **DELIBERATELY NOT DONE:** `07` §8 matrix row 4 was NOT moved. This proves the snapshot CONTAINS a
> recoverable unit; it does not prove a restore puts data back into a running app. R-408 (the missing
> `acquireRunning` on `RestoreOffboxScratch`) was NOT fixed — the job takes the flag itself and the row
> stays open.
> **2026-08-31 — v0.230.0. THREE RULINGS, recorded so none is re-litigated.**
>
> **1. HOLLOWNESS IS A MANIFEST QUESTION, NEVER A SIZE QUESTION.** `unitCarriesData` asks whether the
> unit's manifest lists any database dump or any volume tar, and nothing else. `dirSizeBytes` lives
> two files away and is the obvious wrong answer: a unit with a fat compose capture and no dumps is
> exactly the shape that deleted 120 MB on `demo-hp`, and a 360-byte unit belonging to a tiny app is
> perfectly healthy. Size answers *how big*; the question is *is there anything to recover*. Absent or
> unparseable manifest ⇒ hollow, fail closed: a unit whose contents cannot be vouched for must never
> authorise a delete of one whose contents can. `TestR403_SizeIsNeverConsulted` is the fence.
>
> **2. THE GUARD FENCES ONE SHAPE, NOT SHRINKING — because the derived-copy rebuild is a DESIGN
> DECISION.** `07-backup-architecture.md` §8 row 5 records that the secondary is a derived copy,
> rebuilt on the next run, and `tier2.go`'s own header records that a classified app's copy
> legitimately shrinks as `export` drops out of its class set. `rsync -a --delete` stays, the data legs
> are untouched, and complete→hollow and hollow→hollow both still mirror. The ONLY refusal is a source
> carrying no data over a destination that carries some. Widening this to "the secondary never shrinks"
> would be calling a decision a defect; `TestR403_DataLegShrinkIsUnaffected` is the guard on the guard.
>
> **3. THE REHYDRATE HAPPENS INSIDE THE RESTORE, BECAUSE A FOLLOW-UP JOB RACES THE CAPTURE.** The
> hollow manifest was written **two seconds** after a Tier-2 unit restore, by the 5-minute
> `backup-cache` job. A goroutine, a scheduled refresh or a "do it on the next run" would each lose
> that race some of the time, and the failure mode is silent. `RestoreTier2Unit` refills the primary
> before it returns, and the test asserts ORDERING rather than sleeping.
> **And the capture is deliberately NOT guarded:** a capture that describes an empty drive as empty is
> CORRECT. With the primary refilled there is no hollow state left to describe. Guarding the capture
> would have made the manifest lie, which is the opposite of every other fix this week.
>
> **A defect the LIVE run caught and the unit tests did not, worth remembering as a shape.** The first
> draft of `UnitRestoreDate` also flagged "the package is older than the run" by comparing their dates
> — and a unit is ALWAYS captured shortly before the run that mirrors it, so it was true for every
> healthy app on the box. Four apps would have been told their package was stale. **A warning that
> fires on everything costs the same as the comforting lie it replaces.** The flag is now
> `UnitLegPreserved` and nothing else.
>
> **The credential rider.** `felhom.eu/scripts/read_credential.py` is now the one place that value is
> read. Three occurrences (2026-07-20, and twice on 2026-08-31, the third of which rewrote a live box's
> password hash) happened while the project already had a memory file, a worked recipe and a session
> report describing the mistake. **A note is read by whoever thinks to look; a check runs whether or
> not anyone remembers.**
> **2026-08-31 — v0.229.0. THREE RULINGS, recorded so none is re-litigated.**
>
> **1. The source moves; the destination does not.** `RestoreFromRecoveryUnitAt(stack, unitDir)` takes
> the recovery-unit DIRECTORY, so the same restore reads a unit from the primary drive or from the
> Tier-2 mirror on the second drive. What it must NEVER take is a destination: data still lands in the
> live Docker volumes and the live database container, and the definition in the guest, resolved by
> `GetAppDrivePath` exactly as the capture is. A restore that also relocated an app's data would be a
> migration wearing a restore's label, and the customer pressed a button that said neither.
>
> **2. Two predicates, never one wider one — and it is the SECOND time this is written down.**
> `Tier2Coverage.CanRestore()` answers *"can the additive file restore run?"* and nothing else;
> `CanRestoreUnit()` answers *"can the unit restore open this copy?"*. `HasUnit` keeps its third,
> distinct meaning: *"is there captured data the file restore is not looking at?"* — true even for a
> half-copied mirror the unit restore refuses, because the disclosure is still owed. The temptation is
> always to widen the predicate already there. **R-356 is what that costs:** one predicate meaning both
> *"has this app a drive?"* and *"is this app installed?"* refused 40 running apps for months, while
> they were running, with a message telling their owners to reinstall them somewhere those apps never
> offer.
>
> **3. A destructive operation reached from a non-destructive surface must carry the difference in the
> CONFIRM, not in the label.** „Teljes visszaállítás a másolatból" sits beside „Fájlok
> visszaállítása" on the same row; one overwrites the app's database and internal volumes, the other
> only adds files that are missing and never overwrites anything. The confirm says exactly that, names
> the copy's date, and says so DIFFERENTLY when that date is only an attempt clock (R-101). It is built
> from named Go constants (`tier2UnitConfirmBase` / `…DateFmt` / `…DateUnprovenFmt` / `…Contrast`) and
> asserted verbatim, because a sentence assembled inside an HTML attribute cannot be pinned and R-364
> makes grepping accented Hungarian out of rendered markup unreliable on top of that.
>
> **The count is settled and must not be re-derived.** At catalogue `459766cb1639`, by the production
> rule: **A = 7 · B = 45 · C = 1**. The C9-F1 Phase-0 count (9/43/1) was wrong by two — **radarr and
> sonarr**, whose `${USERDATA_PATH}` binds are WRITABLE (so the `:ro` default rule Phase 0 applied does
> not catch them) and are excluded by an explicit `class: excluded` entry instead. C is **bentopdf**.
>
> **R-403, filed and NOT fixed.** Two seconds after a restore that ran with the primary unit absent, the
> 5-minute status refresh (`captureAllRecoveryUnits`) rewrote the primary unit from a drive with no
> dumps, yielding `"db_dumps": []` / `"volume_dumps": null`. Measured on demo-hp 2026-08-31. The
> dangerous half — that the next Tier-2 run would mirror that hollow unit over the good secondary copy,
> `rsyncMirror` carrying `--delete` — **was not tested and is recorded as unverified.**
> **2026-08-31 — v0.228.0. TWO RULINGS, recorded so neither is re-litigated.**
>
+218
View File
@@ -0,0 +1,218 @@
# REPORT — the app page in one column, and one image per version number (2026-10-15)
**A non-overwritten sibling of `REPORT.md`.** Two sessions released this repo on the same evening;
`REPORT.md` is per-session and overwritten, and it now holds v0.317.0's (the storage work). This
file holds the app-page arc so it stops being clobberable — the convention `felhom.eu/CLAUDE.md`
prescribes for parallel sessions, applied here.
## 1. Baselines
| Repo | `main` at start | Version at start |
|---|---|---|
| felhom-controller | `8339206` | v0.315.0 |
| felhom.eu | `340804f9` | docs only |
Architecture read before any claim: `02-controller-module-map.md` „The app page",
`09-update-architecture.md`, the `felhom-ui-design` skill, `CLAUDE.md` + `.claude/rules/*`.
Edited on the Windows workstation; built and tested on DooPlex in an isolated clone; the browser
work is on the workstation, which is why CC runs here.
## 2. PART 0 — the diagnosis, before any fix
**Verdict: (a) a cache.** Evidence: `felhom.eu/documentation/audits/app-page-2026-10-15b/P0/VERDICT.md`.
**(b) is ruled out.** Exactly one guest ran 0.315.0 — demo-hp 9201 — on digest `8887343011d6`,
identical to the registry's 0.315.0 at the time of measuring. The **global floor was 0.314.0** (read
from the hub's `/configuration`), so no box could have taken 0.315.0 by itself. demo-hp 9202 and
felhom-pve (N100) 9201 were both on 0.314.0; Tester 1 (`c11-appliance`) was unreachable and, by the
floor, cannot have had it.
**The mechanism, in four measured facts:**
1. `layout.html` asked for `/static/style.css?v={{.Version}}` → `?v=0.315.0` for **all three**
different 0.315.0 builds (`e66d466`, `fe0228c`, `db52f5a`); the first had no `.app-tabs` rules.
2. The origin sends **no validator**: on the box, `ETag present = 0`, `Last-Modified present = 0`.
A cached copy cannot be revalidated — only expire.
3. Cloudflare caches that URL (`cf-cache-status: HIT`, `age: 99 → 100` over three requests) and
**rewrites the browser TTL from the origin's 3600 to 14400** — four hours.
4. The session's first request returned **`cf-cache-status: EXPIRED`**, which only happens when the
edge holds an entry that has aged past its TTL.
**The caveat, stated rather than glossed:** origin, edge and box now agree byte-for-byte
(`sha256 c1774f63…`) and all carry the rules. The stale object itself is **no longer observable** —
that first probe forced its revalidation, which is what `EXPIRED` records — so no `HIT` with a bad
body can be produced. What is shown is every precondition, the proof an aged entry existed, and the
elimination of the only other candidate. **No cache was purged by hand.**
## 3. What shipped
| Area | What |
|---|---|
| Push-once | `build.sh` refuses `--push`/`--multiarch` for a tag already in the registry, before building. A registry it cannot reach is a refusal too. Local builds untouched. |
| Clean tree | `build.sh` also refuses a tree that is dirty, not at `origin/main`, or not a git checkout. Added after it was needed — see §6. |
| Asset tag | `assetTag()` hashes version + `GitCommit` + `BuildTime`; **all nine shells** use it (seven asked for `?v={{.Version}}`). Opaque 8 hex, because the stranger-facing share pages use the same value. `Cache-Control` unchanged — the new address is the fix. |
| Layout | The approved one-column mockup: 820px column, hairline sections, underlined tabs, no hero box, 200px label grid. Two boxes only: blue „the app is waiting for you", amber „this is risky". |
| Banner | The auto-update SUCCESS banner is meta text in the hero. Every banner that needs a decision is untouched above the tabs (R-571). |
| Leftovers | „2. mentés beállítása — <app>" and „<app> — Exportálás" removed; „Cím és kulcsok" with one address row replaces the duplicate disabled subdomain field; the „already installed, read-only" banner is now the hint under the fields; the install wizard gets the same back link, hero and column. |
## 4. Commits on `main`
`9cc7ffc` push-once + per-build asset tag · `7f3e58f` the one-column layout · `48d8674` v0.316.0
CHANGELOG + the rule-file fact · `006ceab` v0.316.1 (the tag reaches the dashboard) · `f908eee`
settings tab de-boxed · `9e68e52` two data-tab leftovers · `8c7aa10` v0.316.3 CHANGELOG · `f9151f0`
**the padding fix** · `4261a38` `FELHOM_REPO_DIR` + the build names its tree · `c6a5671` v0.316.4
the clean-tree gate · `e7b64af` `workspace/` gitignored.
felhom.eu: `b6638ac4` (design docs + the audit), `22cd7c51` (R-1080 closed, R-1081 filed).
## 5. Tests and red-proofs — every break seen failing, then reverted
| # | Break | Result |
|---|---|---|
| B1 | the registry check deleted | 5 of 6 cases fail; a present tag builds AND pushes |
| B2 | an unreachable registry treated as absent | the two unanswered-registry cases fail |
| B3 | the clean-tree gate deleted | all four of its cases fail |
| C1 | `assetTag` from the version only | `TestAssetTag_ChangesPerBuild` fails on both same-version cases |
| C2 | one shell back on `?v={{.Version}}` | `TestAssetTag_EveryShellUsesIt` fails naming layout.html |
| C3 | `addShellAssetData` removed from `executeTemplate` | `TestAssetTag_ReachesTheRenderedPage`: empty buster on four pages |
| D1 | the success banner restored | `TestAppPage_AutoUpdateSuccessIsMetaNotBanner` fails |
| D2 | one tab loses its column | `TestAppPage_OneColumnLayout` fails naming the tab |
| D3 | a section drawn as a card again | same test fails on two tabs |
| E | an informational section drawn as a blue callout | `TestAppPage_CalloutsOnlyForAskAndWarn` fails on both counts |
New: `scripts/test_build_push_once.sh` (**ten** cases, driving the real `build.sh` with a fake
`docker` on PATH — nothing is ever pushed),
`TestAssetTag_{ChangesPerBuild,EveryShellUsesIt,ReachesTheRenderedPage}`,
`TestAppPage_{OneColumnLayout,AutoUpdateSuccessIsMetaNotBanner,CalloutsOnlyForAskAndWarn}`.
Full gate: `go build ./... && go vet ./... && go test ./...` → **exit 0, 37 packages ok, zero
failures**, read from `go test`'s own exit code, not a pipeline's. All controller gates OK; 53
decoys behaved.
## 6. THREE INCIDENTS, one shape
All three are the same defect: **a guard that is not on the path the work actually takes.** Two of
them I caused while fixing the first.
**(i) I re-pushed 0.315.0 a fourth time.** The push-once guard went into
`controller/build.sh` in the repo; the documented release command runs
`$FELHOM_ROOT/build/felhom-controller/build.sh`, which was a **separate copy taken 2026-07-18** with
no guard. The first thing I did with the new refusal — run it against a real existing tag to prove
it fires — therefore rebuilt and re-pushed 0.315.0 with v0.316.0's code under it
(`8887343011d6` → `b809201841f7`). Root cause fixed: the build directory's script is a symlink to
the repo's, the stale copy kept as `build.sh.stale-copy-2026-07-18`, and the refusal then proven
from the documented path. **R-1081.**
**(ii) The asset tag shipped dead in 0.316.0.** Every shell asked for `?v={{.AssetTag}}` and the
template test passed, but `addShellAssetData` was only ever called by `executeTemplateLang` — the
stranger-facing pages. The dashboard path had never needed it, because `layout.html` used to ask for
`{{.Version}}`, which `baseData` supplies. The first live load rendered `/static/style.css?v=` —
**empty, which is a constant, and a constant busts nothing.** Found in a browser. Fixed in 0.316.1;
`TestAssetTag_ReachesTheRenderedPage` now asserts the rendered value, not the markup.
**(iii) Building 0.316.3 published another session's unreleased work.** The symlink from (i) points
into the shared clone's **working copy**, which predated the `FELHOM_REPO_DIR` override — so the
override was silently ignored and the build copied the shared tree, which held 31 uncommitted files
of the physical-disk storage work. The banner said `Commit: 0225255` with no `Repo:` line; the
evidence was on screen and the image was already pushed. **Nobody deployed it — that was luck, not a
control.** The clean-tree rule had existed only as a sentence in a runbook. It is now two questions
the build asks and refuses on, plus a refusal for a source that is not a git checkout at all; it
immediately caught its own next build (the scratch `workspace/` beside the script, now gitignored).
**`0.316.3` must never be deployed.** It is neither re-pushed nor deleted: a version number is
pushed once, and that applies to a number I spoiled.
## 7. Deployed, and what happened to 0.316.4
**demo-hp guest 9201 runs `0.317.0`, healthy.** That is the other session's release, built from
`main` at a commit that contains all of this work — verified: `f9151f0` (the padding fix) and
`c6a5671` (the clean-tree gate) are both ancestors of it.
**`0.316.4` was built but never published, and was then dropped.** Its first build attempt was
refused by its own new clean-tree gate (the `workspace/` directory); by the time that was fixed,
`0.317.0` was already in the registry. Publishing 0.316.4 would have put a *lower* number in the
registry carrying nothing 0.317.0 does not already have. The household's button offers the highest
tag, so it would have been invisible anyway.
**The floor stays 0.314.0** — raising it needs all three test boxes and a green box walk, and this
evening two sessions released the same component.
| tag | state |
|---|---|
| 0.316.0 / .1 / .2 | published, superseded |
| **0.316.3** | published, **CONTAMINATED — never deploy** |
| 0.316.4 | never published (superseded) |
| **0.317.0** | published, deployed on demo-hp, carries this work |
## 8. The browser check, through the PUBLIC address
Chrome on the workstation, `https://felhom.enkisfelhom.hu`, **normal loads — no hard refresh**:
- The stylesheet URL is a real 8-hex per-build tag and the loaded sheet carries the `.app-tabs`
rules. **This is the fix working:** before it, the same page needed a hand-forced refetch.
- `.app-tab-active` computes `border-bottom: 2px rgb(46,168,245)` (`--blue-bright`); column
`max-width: 820px`; hero `border-top-width: 0px`.
- Across settings / data / guide / logs / plex / grocy / install: **1 column each, 1 `aria-current`
on an installed app and 0 on a not-installed one, 0 `.app-info-card`, 0 success banners.**
- Data tab: exactly **1 amber callout** (the same-disk warning). Settings, guide, grocy: none —
correct, nothing is asked and nothing is risky.
- **After the operator's report:** `.app-page` padding `0px 0px 32px`, `.content` padding `32px`,
measured gap **32px from the sidebar** (was 80) and **36px from the top** (was 64+).
- **Console clean on a fresh load**, on 0.316.2 and again on 0.317.0.
Screenshots: `felhom.eu/documentation/audits/app-page-2026-10-15b/browser/` —
`01-settings-boxes-still-there`, `02-settings-one-column-0.316.2`, `03-data-tab-amber-callout`,
`04-guide-tab-padding-fixed-0.317.0`.
**Scenario E's blue callout was NOT exercised live.** Plex's setup gate is open (the operator's real
claim, R-1060), so `Needed()` is false and the card is correctly hidden. Showing it live would mean
un-claiming the operator's real Plex server; not done. The branch is covered by
`TestAppPage_CalloutsOnlyForAskAndWarn` against a harness Plex with a closed gate.
## 9. Parity fixtures
Recaptured (the pages this task restyles): `app_settings_*` ×21, `app_data_*` ×9, `app_guide_*` ×4,
`app_logs`, `deploy_new{,_blocked,_memory_blocked}`. **All other fixtures are byte-identical** — and
now permanently independent of build identity: the harness normalises the asset tag on **both sides**
of the comparison, as it already did for the per-session CSRF token, so a build can no longer churn
148 files. The same normalisation was applied at the second comparison site in `i18n_wiring_test.go`,
which had the same assumption.
## 10. NOT yet validated
1. **The operator's own look** at AdventureLog and Plex on demo-hp through the public address. The
layout judgement is theirs. (The padding complaint from the first look is fixed and live.)
2. **A blue callout in a real browser** (§8).
3. **A catalog guide rendered from a real template** — no template carries `app_info.guide` yet.
## 11. Teardown — three layers
- **Machine:** nothing provisioned, no app installed, no throwaway Plex. Probe scripts, fetched
pages and the password file removed from guest 9201, demo-hp and DooPlex `/tmp`; the dashboard
password went file→file, was never printed, and no copy reached Windows or git.
- **Host:** both scratch clones and the `felhom.eu` sibling symlink deleted. The shared DooPlex
checkout was never written to by me — which is how the other session's work was distinguishable
from mine. `build.sh` in the build directory is now a symlink to the repo's (deliberate), with the
stale July copy kept beside it.
- **Hub:** nothing created; no floor change.
## 12. Observations
- **FILED: R-1081** — the registry's `0.315.0`, and now also `0.316.3`, hold content that is not
what their numbers say. Both caused by this session; both fixed at the root; no box affected.
- **NOT-A-FINDING (fixed in session):** the asset tag shipped dead — incident (ii).
- **NOT-A-FINDING (fixed in session):** the clean-tree rule had no mechanism — incident (iii).
- **NOT-A-FINDING (fixed in session):** `app_row_dedup_gate` convicted my `.app-row`, correctly —
that class already means „a row in the app list". Renamed `.app-field-row`; an over-broad rename
briefly took the list's own rule with it and was restored.
- **NOT-A-FINDING (fixed in session):** the one-column padding doubled against `.content`'s own
2rem. The mockup's numbers were the total the page should show, not an amount to add.
- **NOT-A-FINDING:** `build.sh`'s `workspace/` dirtied any clone it was run from; gitignored.
- **Register: 169 before → 170 after; 1 opened (R-1081), 1 opened-and-closed (R-1080).**
- **System poster:** no fact in `felhom-system-poster.facts.md` changed. Neither file edited.
- **Instruction files:** `.claude/rules/unprompted-work.md` item 6 in **all four** versioned copies
gains „a version number is pushed once", stated as a fact about what the build now enforces, not
as a new rule. The workspace-root copy is unversioned and not on this machine.
- **Worth the operator's attention:** two sessions released this repo within an hour, and the
second overwrote the first's `REPORT.md`. This file is the workaround; whether `REPORT.md` should
stay a single overwritten file in a repo two sessions can release is a question for the operator,
not a change I should make unasked.
+18 -448
View File
@@ -1,451 +1,21 @@
# REPORT — controller v0.228.0 (R-399 + R-400), 2026-08-31
# REPORT — the restore and backup holes (2026-10-24 night, unattended; commit only, NOT released)
**The check now reads the data, and the debug page no longer lies.** Built, pushed, deployed to
`demo-hp`, and proven there at both depths with the restic argument list read off the running process.
Brief: fix R-1251, R-1252, R-1253 (and R-1254, R-1257 in their repos); live-prove the recovery-code changes; nothing
released. Full record: felhom.eu `documentation/audits/restore-holes-2026-10-24/README.md`.
---
## 1. Confirmed baselines — re-checked at the start, no drift
| Repo | `main` @ start | matched `origin/main` | version |
|---|---|---|---|
| `felhom-controller` | `300d7e87d7cfcbb6dd355594f5d8936c06184a27` | yes | v0.227.1 → **v0.228.0** |
| `felhom.eu` | `db0812b6f261dbb925ef89ed27bfc2d2d1d3b5b9` | yes | docs only |
`git status --porcelain` was empty in both. `MinAgent` stays **0.129.0**. No drift to report.
---
## 2. Files created / modified / deleted
**Created (6)**
- `controller/internal/backup/r399_depth_test.go` — Group A (8 tests)
- `controller/internal/backup/r399_slow_notice_test.go` — Group B1–B5
- `controller/cmd/controller/r399_no_event_test.go` — B6, the non-effect
- `controller/internal/web/r400_debug_routes_test.go` — Group D
- `controller/scripts/debug_route_gate.py` — the gate
- `controller/scripts/test_debug_route_gate.py` — Group C, incl. both red-proofs
**Modified (17)**
`controller/internal/backup/offbox_integrity.go` · `controller/internal/backup/offbox.go` ·
`controller/internal/settings/settings.go` · `controller/internal/config/config.go` ·
`controller/cmd/controller/main.go` · `controller/internal/web/handler_debug.go` ·
`controller/internal/web/templates/debug.html` · `controller/internal/report/types.go` ·
`controller/configs/controller.yaml.example` · `controller/scripts/controller_gates.py` ·
`controller/scripts/test_controller_gates.py` · `controller/internal/backup/r359_integrity_test.go` ·
`CHANGELOG.md` · `CONTEXT.md` · `REUSE.md` · `controller/README.md` · `.claude/rules/gates.md`
`felhom.eu`: `STATUS.md` · `documentation/architecture/00-capability-map.md` ·
`documentation/architecture/07-backup-architecture.md` · `documentation/backlog/OPEN-ITEMS.md` ·
`documentation/backlog/CLOSED-ITEMS.md` · `scripts/wire_contract_gate.py`
**DELETED — half this task, so listed explicitly**
*From `debug.html`:* six `/api/debug/...` references, their buttons and result spans, the entire
„Tárhely teszt" card (`section-storage`), the `dr-status` panel, and the JavaScript functions
`loadWatchdogStatus`, `renderWatchdogStatus`, `simulateDisconnect`, `simulateReconnect`, `loadDRStatus`,
plus their two `loadSectionData` cases. Template shrank 49 081 → 42 646 bytes.
*From the test tree:* `TestR359_StructureCheckPassesNoReadDataFlag` and
`TestR359_MalformedReadDataSubsetIsTreatedAsOff` — both asserted the ruling this release reverses.
They are **replaced, not weakened**, and a paragraph stands where each was naming its successor, so a
later reader does not re-derive the old ruling from an absence. See §4.
*From `controller/README.md`:* the „Tárhely teszt" debug-section row, and the `infra-push` /
`dr/infra-status` route mentions.
---
## 3. Commits pushed to `main`
| Repo | Commit | What |
| Commit | What | CI |
|---|---|---|
| `felhom-controller` | `3c49dc8ea42df6c84a4bc9495d0d8a7662163ae4` | the whole of Parts 1–3 + tests + gate + repo docs |
| `felhom.eu` | `77a5a115` | STATUS, capability map, 07 §10.2, register, wire allowlist |
---
## 4. Tests — per group, and the red-proofs by name
Green gate: `go build ./... && go vet ./... && go test ./...` → **all three exit 0**, full suite.
`python3 scripts/controller_gates.py --fast` → **13/13 OK**.
| Test | Result |
|---|---|
| A1 `TestR399_AbsentConfigRunsFullDepth` | PASS |
| A2 `TestR399_EmptyStringIsNotOff` | PASS |
| A3 `TestR399_OffTokenRunsStructureOnly` | PASS |
| A4 `TestR399_OffTokenIsCaseInsensitive` | PASS |
| A5 `TestR399_ExplicitValueWins` | PASS |
| A6 `TestR399_MalformedFallsBackToTheDefault` | PASS |
| A7 `TestR399_DepthIsStatedInTheOutcome` | PASS |
| A8 `TestR399_DefaultResolvesThroughTheRealConfigPath` (the seam) | PASS |
| B1 `TestR399_SlowCheckWarns` | PASS |
| B2 `TestR399_FastCheckIsSilent` | PASS |
| B3 `TestR399_SkipNeverWarns` | PASS |
| B4 `TestR399_UnreachableNeverWarns` | PASS |
| B5 `TestR399_SlowAndFailedProducesBoth` | PASS |
| B6 `TestR399_SlownessRaisesNoHubEvent` (AST, with a positive control) | PASS |
| C1 gate on the shipped tree | PASS — `18 referenced address(es), all dispatched, none orphaned` |
| C2 `test_fails_on_an_unwired_reference` | PASS |
| C3 `test_fails_on_an_unreached_handler` | PASS |
| C4 `test_gate_is_registered_in_the_runner` (AST over `GATES`) | PASS |
| D `TestR400_DeletedControlsAreGoneFromTheTemplate` · `…LeftNoPanelOrScript` · `…CrossDriveRouteDispatches` · `…CrossDriveReportsWhichAppsItStarted` | PASS |
| D3 `TestBackupReport_DeadFieldsStayZero` | PASS, **unmodified** |
| E1 full existing suite | PASS — see §4a for the two superseded tests |
### Red-proofs — mutate, confirm failure, revert
| # | Mutation | Result |
|---|---|---|
| **A1** | `defaultIntegrityReadDataSubset = ""` | `--- FAIL: TestR399_AbsentConfigRunsFullDepth` |
| **A6** | malformed falls back to `""` instead of the default | `--- FAIL: TestR399_MalformedFallsBackToTheDefault` |
| **B1** | `noticeIfSlow` returns immediately | `--- FAIL: TestR399_SlowCheckWarns` **and** `--- FAIL: TestR399_SlowAndFailedProducesBoth` |
| **C2** | a `/api/debug/storage/simulate-disconnect` reference added to a sandbox copy | gate exit 1, naming `storage/simulate-disconnect` |
| **C3** | a `ghost/handler` case added to a sandbox copy | gate exit 1, naming `ghost/handler` |
Each was reverted from a pre-mutation copy and the suite re-run green. C2/C3 mutate a **throwaway copy
of the two files**, never the repo — a red-proof that leaves a broken tree behind when an assertion
fires mid-test is its own hazard.
### 4a. Two existing tests were superseded — stated rather than absorbed
§9 rule E1 says to stop and report if an existing test needs editing. Two did, and the reason is not
that they were inconvenient: **they asserted the ruling this release reverses.**
- `TestR359_StructureCheckPassesNoReadDataFlag` asserted an unconfigured box passes NO `--read-data`
flag. Correct on 2026-08-30; overturned on 2026-08-31 by measurement and by Viktor's ruling.
Replaced by **A1**, and the off token it left room for by **A3**.
- `TestR359_MalformedReadDataSubsetIsTreatedAsOff` — **its name was the defect.** Treating a typo as
"off" is the quiet downgrade. The half that still holds (nothing malformed reaches restic; it WARNs)
is asserted by **A6**, which also pins the new direction.
Nothing else in the suite was touched.
### Test count
| | Go test/fuzz/bench funcs | gate test files |
|---|---|---|
| before (`300d7e8`) | 1 590 | 1 |
| after | **1 606** (+18 new, −2 superseded) | **2** (+4 tests) |
---
## 5. Deployed version
```
$ ssh hp "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
gitea.dooplex.hu/admin/felhom-controller:0.228.0 Up 16 seconds (healthy)
```
Image digest `sha256:ed28f160fac742dc6d6b22ccee3b7774b49b4f31d67d18099c342f5ce10f0c8d`, 150 MB.
Deploy path: `docker pull` → `/etc/felhom-controller-image` → restart
`felhom-controller-bootstrap.service`. Before: 0.227.1 (up 13 h, healthy).
**DELIVERED the same session, on the operator's instruction.** Golden **0.228.0** baked, published,
round-trip verified and vouched; the fleet floor raised 0.227.1 → 0.228.0. `demo-felhom` then
self-updated in under a minute and re-registered `offsite-integrity` by itself — so both demo boxes
now re-read their whole off-site store weekly, and only `demo-hp` was ever touched by hand. Full
evidence: `felhom.eu/documentation/tests/golden-0.228.0-2026-08-31/`. See §15.
---
## 6. The threshold I chose for the slow-check notice, and why
**`integritySlowNoticeThreshold = 5 * time.Minute`.**
The only full-depth number that exists is **39.2 s**, on a 134 MB store. Five minutes is ≈7.6× that,
so it cannot fire on anything resembling today's fleet — and it is well under `integrityCheckTimeout`
(30 min), so the operator hears *"this is getting slow"* long before a check is killed for running too
long.
**Deliberately imprecise, and that is the argument.** A notice changes no behaviour, so an imprecise
number costs nothing; a precise-looking threshold derived from one measurement on one small store
would be the exact shape of the four production designs this project has already specced against
unvalidated mechanisms. The number that matters is not 5 minutes — it is that *something* says the
setting needs revisiting before a customer's upload does.
It is a WARN in the operator log and nothing else: no hub event, no customer alarm, and it never
changes the depth by itself. An event type would cost the severity contract, the grain table and three
registers to say "this took a while", against 08 §6.2's coarse-by-default rule.
---
## 7. NOT yet live-validated — stated, not implied
1. **A real WEEKLY firing at the new depth.** The job is confirmed REGISTERED on the box
(`Daily job offsite-integrity scheduled for 2026-09-01 06:00 CEST`, `totalJobs=13`). That is not the
same claim as observing it fire. Both live runs below were hand-forced through the debug route,
which is the same code path with due-ness skipped and every other guard intact.
2. **Everything about a LARGE store.** There is one data point, on 134 MB. The slow notice has never
fired on real hardware — nothing on this fleet is slow enough. R-401 owns this.
3. **The slow-notice WARN text as rendered on a box.** Proven in tests through the production path;
not observed live, because no live check exceeds the threshold.
4. **The `crossdrive` route's zero-app branch on a real box.** demo-hp had three eligible apps, so the
non-empty branch was proven live and the empty one only in tests.
---
## 8. The depth evidence — argv verbatim, and the new wall-clock
The box's `controller.yaml` was confirmed to have **no `integrity:` key at all** before run 1 — the
Scenario A condition, live, on a real box.
**Run 1 — the default, no config:**
```
restic -r sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo -o sftp.command=ssh
u629488-sub3@u629488-sub3.your-storagebox.de -p 23 -oBatchMode=yes -oConnectTimeout=10
-oStrictHostKeyChecking=yes -oUserKnownHostsFile=/opt/docker/felhom-controller/data/offbox/known_hosts
-i /opt/docker/felhom-controller/data/offbox/ssh_key -s sftp check --read-data-subset=100%
```
```json
{"data":{"depth":"100%","duration_ms":38745,"ok":true,"read_data_subset":"100%","skipped":false,"unreachable":false},
"message":"Az ellenőrzés rendben lezajlott","ok":true}
```
```
[INFO] [offbox] integrity: check PASSED in 43s (structure, index, and 100% of the pack data re-read)
[INFO] Event pushed: backup_integrity_ok (info) — A távoli mentés ellenőrzése rendben lezajlott.
(43s, a mentett adatok 100%-át újraolvasva)
```
**Run 2 — `read_data_subset: "off"` added to the box's `controller.yaml`, container restarted:**
```
restic -r sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo -o sftp.command=ssh
u629488-sub3@u629488-sub3.your-storagebox.de -p 23 -oBatchMode=yes -oConnectTimeout=10
-oStrictHostKeyChecking=yes -oUserKnownHostsFile=/opt/docker/felhom-controller/data/offbox/known_hosts
-i /opt/docker/felhom-controller/data/offbox/ssh_key -s sftp check
```
```json
{"data":{"depth":"structure","duration_ms":34742,"ok":true,"read_data_subset":"","skipped":false,"unreachable":false},
"message":"Az ellenőrzés rendben lezajlott","ok":true}
```
```
[INFO] [offbox] integrity: check PASSED in 35s (structure and index only — no pack data was downloaded)
```
**The config was then put back** (the `integrity:` block removed, container restarted, `grep -c
integrity` = 0), and the scratch backup file on the guest deleted.
| | 2026-08-30 (0.227.x) | 2026-08-31 (0.228.0) |
|---|---|---|
| structure depth | 35.0 s | **34.7 s** (via `off`) |
| 100% re-read | 39.2 s | **38.7 s** · 39.8 s · 43.2 s over three runs |
The numbers reproduce. The 43.2 s run was the first after a container restart, with a cold SFTP path.
**Method:** endpoint-level, via `POST /api/debug/backup/integrity` — the exact route the „Restic
integritás" button invokes. No browser is available on DooPlex. The argv was read from the **guest's**
process table while each check was in flight (`pct exec 9201 -- ps -eo args | grep '[r]estic'`); the
container has no `ps`. All evidence was captured **before** the config revert (R-320).
---
## 9. The seven controls — disposition, by name
| # | control | invoked by | disposition | why |
|---|---|---|---|---|
| 1 | `backup/crossdrive` | button „Csak cross-drive" | **IMPLEMENTED** | `Manager.RunTier2(stackName)` is live at `tier2.go:289` and already called from the app config page. Only the route was missing. Async (a Tier-2 sweep is bounded by disk, not by a timeout) and it answers with the app LIST, because zero apps and eight apps are different facts |
| 2 | `backup/infra` | button „Infra mentés" | **DELETED** | no backing function exists. `backup.go:22`: disk-tier backup (restic, cross-drive, drive-recovery, **infra-backup**) moved to the host agent in slice 8C |
| 3 | `hub/infra-push` | button „Infra backup küldése" | **DELETED** | `report/pusher.go:170`: *"PushInfraBackup removed 2026-06-16 — the infra-backup mechanism was retired hub-side. It was dead since slice 8C, had no callers, and pushed plaintext secrets to the hub."* |
| 4 | `dr/infra-status` | **fetch on page LOAD** | **DELETED** | it rendered per-drive infra backups and the hub infra push — the two mechanisms above, both retired. The panel had been permanently blank |
| 5 | `storage/watchdog-status` | **fetch on page LOAD, twice** (initial + 5 s poll) | **DELETED** | `web/server.go:254`: the slice-8C watchdog is retired and the drive-gate reconcile replaced it. Nothing publishes a per-path probe status; the panel had been permanently blank |
| 6 | `storage/simulate-disconnect` | button in the watchdog table | **DELETED** | no backing capability at all, **and it writes storage state**. A debug button that fakes a drive disconnect on a customer's machine is a foot-gun — that is where drives get unenrolled and data gets stranded. No live need was shown |
| 7 | `storage/simulate-reconnect` | button in the watchdog table | **DELETED** | same |
No control was left in the third state. Each deletion took its panel and its JavaScript; the
„Tárhely teszt" section had nothing left and went entirely.
**Counts, so the gate has a baseline:**
| | references in `debug.html` | cases in `handler_debug.go` |
|---|---|---|
| before | 24 | 17 |
| after | **18** | **18** |
**Proven live on the served page** (`GET /debug`, 200, 75 430 bytes, ASCII-only fragments per R-364):
`backup/infra`, `hub/infra-push`, `dr/infra-status`, `storage/watchdog-status`,
`storage/simulate-disconnect`, `storage/simulate-reconnect`, `watchdog-status`, `simulateDisconnect`,
`loadDRStatus`, `section-storage` → **0 occurrences each**. Positive controls on the same fetch:
`backup/crossdrive` 1, `backup/integrity` 1, `dr/trigger-setup` 1, `btn-dr-trigger` 5.
**The implemented control, invoked live:**
```json
{"data":{"apps":["calibre-web","paperless-ngx","romm"],"count":3},
"message":"Cross-drive mentés elindítva 3 alkalmazásra","ok":true}
```
and it did the work, which is the positive observable — not an absent error:
```
[INFO] [backup] Tier 2 copied calibre-web → …/backups/secondary/calibre-web (5.6 MB, 1 leg(s))
[INFO] [backup] Tier 2 copied paperless-ngx → …/backups/secondary/paperless-ngx (79.6 MB, 1 leg(s))
[INFO] [backup] Tier 2 copied romm → …/backups/secondary/romm (176.2 MB, 0 leg(s))
[INFO] [web] debug cross-drive run for {calibre-web,paperless-ngx,romm} completed
```
**The gate, and its red-proof:**
```
### gate on the shipped tree
debug route gate OK - 18 referenced address(es), all dispatched, none orphaned
rc=0
### red-proofs
Ran 4 tests in 0.106s — OK
```
---
## 10. Teardown — all three layers
- **DooPlex.** Nothing provisioned. One image built and pushed to the registry
(`felhom-controller:0.228.0`), which is the deliverable, not scratch. Evidence and logs live in the
session scratchpad and are reproduced verbatim in this report; nothing was left in `/tmp` beyond it.
- **`demo-hp` (host).** Nothing provisioned. No storage added, no VM, no guest.
- **Guest 9201.** One scratch file, `/root/controller.yaml.pre-r399` (the config backup taken before
the off-token run) — **deleted**, absence confirmed. `controller.yaml` restored byte-for-byte and the
restored state verified (`grep -c integrity` = 0). The off-site store was **read** at full depth
three times and never written, pruned, unlocked or forgotten.
- **Not touched at all:** `demo-felhom`, `ep0`, DooPlex's own k3s/Longhorn/PBS, Peti's box.
---
## 11. Register
| Row | Action |
|---|---|
| **R-399** | **CLOSED** — controller v0.228.0. Moved to `CLOSED-ITEMS.md` with its reasoning kept |
| **R-400** | **CLOSED** — controller v0.228.0. Moved to `CLOSED-ITEMS.md` with its reasoning kept |
| **R-401** | **FILED** — revisit the depth when a real store is large. **Trigger is the slow-check WARN firing, not a date.** Owner CC |
| **R-402** | **FILED** — the integrity verdict AND its depth are on the wire and no hub surface reads either. See §12 |
| **R-87** | **UNTOUCHED and still OPEN.** Reading the bytes back out of the store is not a restore. Restated in `07 §10.2` where the two rows sit adjacent |
**Register size:** `OPEN-ITEMS.md` 166 → **165** rows (−2 closed, +2 filed). `CLOSED-ITEMS.md`
148 → **150** rows. Each closed entry names `300d7e8` as the commit whose
`git show 300d7e8:documentation/backlog/OPEN-ITEMS.md` returns the original text verbatim.
---
## 12. Observations
1. **FILED: R-402 — a wire field with no receiver, caught by a gate, not by me.**
`scripts/wire_contract_gate.py` convicted the new `offsite.last_integrity_depth`: the literal
string occurs nowhere in the hub, so `encoding/json` discards it on arrival. Its sibling
`offsite.last_integrity_ok` has been in the same state since v0.227.0 and was already allowlisted
**with its reason**. I added the new field to that allowlist beside it and filed the row rather than
modelling it hub-side, because that is a hub release this task does not authorise and R-331 ruled
the *display* a decision for the operator. **This is the opposite order to the one that produced
R-331:** publish the value first, build the screen when someone decides what it should say.
2. **NOT-A-FINDING: `integrityCheckTimeout`'s comment was made false by this change, and I rewrote it
rather than leaving it.** It said read-data "ships OFF (R-399); whoever turns it on must revisit
this number, and this comment is the note that says so." Both halves stopped being true in this
release. It is now the number a large store will meet first, and it says so, pointing at R-401.
Strictly outside the listed scope; leaving it would have been the R-395 family defect this task
itself corrects three instances of.
3. **NOT-A-FINDING: `.claude/rules/gates.md` said "all seven local gates" while nine were registered.**
Found while registering the tenth. Corrected to point at the runner's `GATES` table instead of
listing them again — the duplicate list is what drifted.
4. **NOT-A-FINDING: `demo-hp`'s dashboard password is NOT stale — I made the exact mistake the
project already has a memory about.** `POST /login` returned 200-with-login-page and the box logged
`Failed login`, which matches the documented "the password drifted" symptom exactly. It had not:
values in `~/.config/credentials` are **single-quoted**, my extraction stripped only `"`, and the
two `'` characters were being sent as part of the password. With both quote characters stripped,
`POST /login` → 302 + `felhom_session`. **Nothing on the box was changed.** The memory
`credentials-file-values-are-quoted` states this correctly, gives the right recipe
(`tr -d "\"'"`), and records the identical misdiagnosis from 2026-07-20 — where it was written up
three times as "the stored password is stale" before being caught. The memory is right; I did not
follow it. Its own lesson is the one that applies: **an auth failure is evidence about the bytes
you sent, not proof about the stored secret.**
5. **NOT-A-FINDING: the two `storage/simulate-*` controls are the only deletions that removed a
capability someone might want back.** They wrote state, so §2.1's rule deleted them absent a shown
live need. If drive-absent behaviour ever needs exercising by hand again, that is a new feature
with a gate in front of it, not a restored button — and the drive-gate reconcile it would be
testing did not exist when those buttons were written.
---
## 13. One push bypassed a gate, deliberately — and the gate is now green
The **first** `felhom.eu` docs push (`77a5a115`) used `git push --no-verify`. At that moment
`golden-currency` was **RED and correctly so**: v0.228.0 was released and the newest golden bake was
0.227.1, so a machine installed right then would have received 0.227.1. That was not a defect in the
work — it is the state the gate exists to make visible. It was not a waiver case either: a golden was
genuinely owed, so recording one would have been false. The hook sanctions `--no-verify` on condition
that the session report says so; this is that sentence.
**It is no longer red.** The golden was baked and vouched later in the same session (§15), the gate
went red → green, and the follow-up push (`1623a4d5`) passed the hook normally with **all 12 gates
OK**. Both `felhom-controller` pushes used the hook normally, 13/13.
---
## 14. What Viktor is owed
**Nothing.** No customer action, no data migration, no credential change. The debug page is
operator-only and no customer sees any part of it. The delivery that §13 recorded as owed was carried
out on the operator's instruction and is verified in §15.
---
## 15. Delivery — golden 0.228.0 baked, vouched, and the floor raised
| | |
|---|---|
| `GOLDEN_VERSION` | **0.228.0** |
| `GOLDEN_SHA256` | `76a3a98b9e7cc23bf8ae51b38a6272f576df285cb34cd22235ac3f06a31e53ec` |
| size | 658 079 744 B |
| script | `build-golden.sh v3.0.0`, drill VM reverted to `virgin` and cold-booted |
| template | `debian-13-standard_13.6-1_amd64.tar.zst`, after `pveam update` |
**Acceptance markers, counted:** `docker OK (overlay2` 1 · `including mount point rootfs` 1 ·
`including mount point mp0` 1 · `upload OK (HTTP 201)` 1 · `excluding` 0 · `FATAL` 0 ·
`mount point mp1` 0. `felhom-controller:0.228.0` appears 4× in the bake log.
**The 404 pre-gate was proven before its 404 was believed:** the target URL returned 404 while the
existing 0.227.1 package returned 200 on the same command. A 404 from a check that cannot see
anything is not a measurement.
**The evidence is the round trip.** Downloaded size and sha match the bake exactly, and
`./etc/felhom-controller-image` read **out of the downloaded archive** says
`gitea.dooplex.hu/admin/felhom-controller:0.228.0` — the golden naming its controller from the bytes a
customer's box would actually fetch.
**The vouch — three fields together:** `golden_version` 0.228.0, `agent_version` 0.130.0,
`min_agent` 0.129.0 (read from this release's CHANGELOG header, not assumed), `wrapper_sha256`
carried through explicitly because the handler clears it when omitted. `agent ≥ min_agent`, so **not
the R-216 shape**. **Verified by RE-READING the manifest, never by the flash.** The R-120 gate passed
rather than being bypassed.
**The floor is ACTING, not merely set.** Impact preview `{"below":3,"valid":true,"version":"0.228.0"}`;
re-read after the POST confirms `DB override: v0.228.0`. Then, with nobody touching it:
```
[INFO] [selfupdate] Post-update startup: update successful (0.227.1 → 0.228.0)
[INFO] [scheduler] Daily job offsite-integrity scheduled for 2026-09-01 06:00 CEST
[INFO] [offsite-apply] settle-gate: GO — at/above floor 0.228.0 (we are 0.228.0)
```
That is `demo-felhom`. **The second line is the one worth keeping:** a box nobody deployed to now runs
the deeper off-site check on its own schedule — R-399 reaching the fleet, observed rather than assumed.
**Token hygiene and teardown** are recorded in full at
`felhom.eu/documentation/tests/golden-0.228.0-2026-08-31/README.md`. In short: file → file `scp`, a
runner script inside the VM so the token never reached a command line (`systemctl show … | grep -c`
→ 0), the committed log's leak grep **proven with a planted copy (1) before its 0 was believed**,
build guest `9100` purged, secrets shredded after the log was copied out (standing rule 5), VM
powered off and the disk reverted to `virgin`.
**Still NOT live-validated,** unchanged from §7: a real weekly firing at the new depth (next is
2026-09-01 06:00 CEST, now on both boxes), and everything about a large store (R-401).
| `bb2809d` | R-1251 (P1): a pending put-back blocks every start; H2 a failed put-back holds; H7 Stop still gets the put-back | 2301 / #482 success |
| `062f9b4` | R-1251: the copy's folders synced before the marker names it (found by the Tester 1 proof: `undo.json.tmp` after a cut); the restore record says the put-back waits | 2302 / #483 success |
| `518a135` | R-1252 (P2): reconcile only under a valid classification; a rejected block is said and signalled; kept copies stay kept; Remove drops kept records | 2303 / #484 success |
| `c9b4b82` | R-1253 (P2): a stop-first export refuses while another operation holds the marker | 2304 / #485 success |
| (close-out) | README, CONTEXT, this report; the interrupted-restore log line names a waiting put-back | final report |
**Proofs:** R-1251 on Tester 1 — a local restore of MeTube (1.5 GB volume) cut by powering the VM off, the data disk
taken off and put back: held while away, put back BEFORE the start, the newest data there, a later restart poured
nothing. R-1252 on 9202 — the review's audiobookshelf steps: kept after a bogus path. R-1253 on 9202 — the export refused
during a restore, ran after it. Every rule red-proved (each test names its red-proof).
**Images:** dev images `0.343.0-dev.r1251a/b`, `r1252a`, `r1253a` built locally and loaded into the test guests only;
never pushed. **Machines:** Tester 1 and 9202 put back (both: the controller on 0.342.0; leftover anonymous volumes of
the R-1267 class named in the audit). **Release:** Monday — controller v0.343.0 (with R-1250), MinAgent 0.158.1.
+129 -15
View File
@@ -11,7 +11,8 @@
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `NamespaceRoot` | controller/internal/appbackup/paths.go | `(drivePath string, inGuestDrive bool) string` | Resolve felhom-data root for a drive | `inGuestDrive=true` returns path AS-IS (Model A: guest mount IS the ns root); false appends `felhom-data`. Never double-nest |
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path |
| `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path. Since v0.229.0 the last three are thin wrappers over the unit-directory-relative primitives below and return byte-identical strings (`TestR102_PathWrappersAreByteIdenticalToToday`) |
| `UnitComposeDir` / `UnitManifestFile` / `UnitDBDumpDir` / `UnitVolumeDumpDir` (R-102, v0.229.0) | controller/internal/appbackup/paths.go (re-exported by `backup/appbackup_bridge.go`) | `(unitDir string) string` | naming a leg inside a recovery unit that is NOT under `backups/primary/` | **These take the recovery-unit DIRECTORY itself.** The `(nsRoot, stackName)` form joins a hard-coded `primary`, which was the MECHANISM of R-102: the Tier-2 mirror at `<dest>/backups/secondary/<app>/recovery-unit/` was written nightly and readable by nothing. Use these whenever the unit's location is an argument; do NOT add a second copy of a layout join |
| `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) |
| `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/<name>` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir |
| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`<nsRoot>/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). |
@@ -19,10 +20,27 @@
| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** |
| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). |
| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. |
| `web.fileBrowserOpenLink` · `web.ServeFilesOpen` / `ServeFilesAuth` · `infra.LoadOrCreateFileBrowserSSOHeader` / `FileBrowserSSOFor` (v0.332.0, R-1100) | controller/internal/web/filebrowser_link.go · controller/internal/web/files_sso.go · controller/internal/infra/filebrowser_sso.go | `(domain, sourceName, relPath)` · handlers · `(dataDir[, domain])` | A dashboard link to a FileBrowser folder that opens it signed in (the one sign-in) | **Every dashboard folder link uses `fileBrowserOpenLink`** (through `/__files/open`), never `fileBrowserLink` raw. The header NAME is the box's secret: only FileBrowser's config and traefik's route carry it — never a log, a page or a report. Never widen the dashboard cookie; the files cookie is derived per host from the session fingerprint (the R-976 shape) |
| `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) |
| `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/<n>` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert |
| `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) |
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, legacy felhom-data) | Consult before ANY recursive delete under a drive |
| `ProtectedHDDPaths` | controller/internal/stacks/delete.go | `(hddPath string) map[string]bool` | Never-delete set (root, appdata, backups, media, kept, legacy felhom-data) | Consult before ANY recursive delete under a drive |
| `stacks.OldAppDataPaths` / `Manager.ListKept` / `KeepAside` / `DeleteKept` / `FindKept` | controller/internal/stacks/kept.go | `(composePath, hdd)` / `(drives)` / … | Kept data (`09` §3 decision 36): what counts as an app's old data (ONLY `<hdd>/appdata/…` binds), the list, start-fresh, the household's delete | **An action names a kept item by path only through `FindKept`** — `DeleteKept` refuses anything not listed. `KeepAside` is a rename on one drive; never copy, never `RemoveAll` in a rollback (`removeEmptyDirs`) |
| `stacks.RunAfterInstall` / `expandAfterInstall` / `web.defaultLoginInEffect` (v0.279.0, decision 45) | controller/internal/stacks/after_install.go · controller/internal/web/known_login.go | `(name, wait)` / `(cmd, allowed, env)` / `(meta, cfg, installed)` | A fresh install replaces a known default login; the page says when a default is still in effect | **Only from the deploy-done hook** — never after a restore/kept load (R-694). A `success:` marker is required (exit 0 lies). Never log the expanded command |
| `stacks.OpenSetupGate` / `SetupGateTick` / `SetupGateHost` · `web.ServeGateAuth` / `ServeGateStart` (v0.280.0, decision 46) | controller/internal/stacks/setup_gate.go · controller/internal/web/setup_gate.go | `(name, by)` / `()` / `(host)` · handlers | The setup gate: a `setup_gate: true` install is closed to everyone but the household until its probe or the household's press opens it | **Write the gate BEFORE the first start** (spike F2). Open = record first, then remove the file. Never widen the dashboard cookie — the handshake mints a host-bound one-use token |
| `stacks.OpenSignupWindow` / `SignupBlocked` / `SetupGateProbe` · `web.ServeSignupClosed` (v0.281.0, decision 47) | controller/internal/stacks/signup_block.go · controller/internal/web/setup_gate.go | `(name)` | Sign-up closed at the app's own address once the gate opens; the household's 15-minute window; the press asks the probe | **The block goes up BEFORE the gate comes down** (a failed write keeps the gate closed). Never on an app this box did not gate |
| `family.Store` · `stacks.FamilyGateHost` / `familyGateTick` / `FamilyExceptRegexp` · `web.ServeFamilyGateAuth` / `ServeFamilyStart` / `ServeFamilyLogin` / `ServeFamilyLogout` (v0.287.0, decisions 63/64) | controller/internal/family/family.go · controller/internal/stacks/family_gate.go · controller/internal/web/family_gate.go | store `Add/AddPerson/People/Reset/Remove/Verify/NewSession/HouseholdSession/Valid/EndSession/Find/EndAll` + `family.Fingerprint` + `family.LoginFrom` · `(host)` / `()` / `(prefix)` · handlers | The PERMANENT family gate: family members with their own logins in front of a `family_gate: true` app | **A family cookie never opens the dashboard** (RequireAuth reads only `felhom_session`); the app cookie carries a token DERIVED per app host from a STORE session (`familyAppToken`, R-976 — never the session id itself), so the store is asked on every request (reset/remove/logout end access at once); **every exception goes through `FamilyExceptRegexp`** (anchored — never a hand-written PathPrefix); door written before the first start, like the setup gate. `familyStoreOverride` is the test seam. Fifth atomic-write helper (family.json, fsync) — see §6. **R-1112: a session is stored as its FINGERPRINT (`Session.FP`, the R-35 shape), never the cookie value** — `NewSession` returns the value once; `Valid`/`EndSession` take the value and hash it; `Find` and `familyAppToken` work on the fingerprint. An old file (`"id"` = the value) is converted and rewritten in `Open`; a failed rewrite is an error. **R-1113: the household path uses `HouseholdSession`** (its live session, a new one only past half its life) — never `NewSession("")` per pass. **W12: a typed name goes through `AddPerson` (keeps the display, makes the login with `LoginFrom`, numbers a taken login); `Add` stays the strict login-only primitive. Any place that turns a typed member name into a login calls `LoginFrom` — never a second fold table** |
| `stacks.OpenInstallHold` / `installHoldTick` (v0.284.0, R-741) | controller/internal/stacks/install_hold.go | `(name, by)` / `()` | An `after_install` app held behind the setup gate's door until its known login is replaced | **Written before the first start**, like the gate; opens on `after_install` success or the household's "I changed it"; the door (`SetupGateHost`) reads holds first |
| `stacks.RetainImagesAfterUpdate` / `RetainImagesAfterRemove` / `RunImageRetentionOnce` (→ `(deleted, done)` since v0.294.0) / `RunImageRetentionOnceUntilDone` · seam `imageDocker` (v0.284.0, decision 53) | controller/internal/stacks/image_retention.go | `(name, previous)` / `(name, repos)` / `()` | Deletes an app's images older than its running + previous one | **The keep set is box-wide and read at delete time** (containers, installed composes, installed/previous records); exact id, never forced or pruned; skipped while any update runs AND while any image-pulling compose command runs (R-863, `dockerexec.TryImageCleanup`); the one-time pass writes its marker only after a pass that RAN; tests use the `imageDocker` seam, never Docker |
| `dockerexec.BeginImageWork(args)` / `TryImageCleanup()` / `ImagePulling(args)` (v0.294.0, R-863) | controller/internal/dockerexec/imagework.go | `defer dockerexec.BeginImageWork(args)()` | **Every NEW place that runs `compose up/pull/create/run` must hold it** (the stacks compose helpers, appexport's restore and the FileBrowser sync already do) | An image pulled by compose is named by no container until compose creates one; a clean-up pass in that window deleted it (measured, R-863). The pass takes the lock exclusively without waiting; pulling commands wait for a running pass (seconds) |
| `nightchain.Ledger` / `CatchUp` / `ResumeWatch` / `ComputeBanner` (v0.295.0, R-871) | controller/internal/nightchain/ | `Open(path, now)`, `MarkEnded(leg, t)`, `Missed(now, W, loc)`, `Evaluate(ctx, why)` | "was a night missed?" and making it up; the missed-backup banner's rule | The ledger is an ATTEMPT record (ran to its end) — never evidence a backup exists; a NEW nightly backup leg must be wrapped by `withLeg` in main.go and listed in `nightchain.Order`, or the catch-up never makes it up; never add the update leg to `catchUpLegs` |
| `scheduler.DailyLateLimit` (v0.295.0) | controller/internal/scheduler/scheduler.go | — | a daily timer firing > 60 min late (host suspend) is skipped | do not "fix" a late fire by running it: the app-update leg would run at noon |
| `stacks.RetainControllerImages(ControllerImageRecord)` · `selfupdate.UpdateState.RecordedPrevious` (v0.285.0, decision 56) | controller/internal/stacks/controller_image_retention.go | `({Repo, Running, Previous})` | Deletes controller images older than the running + previous one | The previous comes from the SWAP RECORD (success onto the running version), version order only as the fallback; versions above the running one and non-version tags are kept; skipped while the controller swaps itself; same `imageDocker` seam |
| `stacks.CloseSignupNow` / `CloseSignupOffered` / `applyNativeLock` (v0.282.0, decisions 47/49) | controller/internal/stacks/after_setup.go | `(name)` | The app's own sign-up switch after the setup; "close sign-up now" for an app installed before the rule | **Check the installed compose reads the variable** (an old install carries the old compose until its next update) — never record a lock that is not there. One run per app at a time (`nativeLockBusy`) |
| `backup.judgeCopy` / `HollowCopies` / `SetHollowCopyNotify` (Part D, v0.279.0) | controller/internal/backup/hollow_watch.go | `(app, tier, unitDir)` | A RUNNING app whose newest copy holds no data → operator digest once/day + page sentence | Uses `unitCarriesData` (the manifest, never size); a stopped held app is never flagged |
| `web.nightChain` (R-705, v0.279.0) | controller/internal/web/night_chain.go | `POST /api/debug/backup/night-chain` | The night's four legs now, in order | Refuses while any op/update/chain runs; the leg uses `RunUpdateLegNow` |
| `Router.dropLeftoverHold` + `settings.ClearUpdateHold` (R-704, v0.278.0) | controller/internal/api/router.go · controller/internal/settings/settings.go | `(name, why)` / `(stack) (bool, error)` | A new install (plain or "use my kept data") and a removal clear the update / crash-loop hold of the app's install | **A hold belongs to an INSTALL; the name is all the next install shares with it.** Never clears an R-379 restore hold (operator-only) |
| `backup.KeptBestCopy` / `KeptDBCopy` / `KeptOffsiteCopies` / `KeptCopyAt` / `LoadKeptApp` / `LoadKeptOffsite` / `KeptCopyKey` | controller/internal/backup/kept_load.go | `(ctx, app, drive)` / `(app, drive)` / `(ctx, apps)` / `(unitDir, drive, tier)` / … | Which copy can load kept files (local tiers + since v0.277.0 the off-site copy, R-691 (2)), the load as a restore op, the copy's name for the page | A copy counts only with data (DB dump or volume tar) AND its app.yaml `HDD_PATH` = this drive. Installed apps are never offered. **The off-site copy is judged only after its unit is downloaded** — `LoadKeptOffsite` refuses an unversioned unit (`07` §6.6) BEFORE `prepare` (a dated folder's move-back); ask the repository once per page (`KeptOffsiteCopies`), never per row. Both pages name a copy through `KeptCopyKey` |
### Subprocess + timeout + exit-code discipline
@@ -36,6 +54,19 @@
| `parseWWWAuthenticate` + `fetchAnonymousToken` | controller/internal/selfupdate/updater.go | Bearer-challenge parse + anonymous Docker v2 token | Any credential-free registry API access | realm comes FROM THE HEADER (never hardcode a token URL); denial = errAnonymousDenied, never "credentials missing" |
| `Syncer.runGit` / `runGitInDir` | controller/internal/sync/sync.go | `(args...) error` | git CLI ops | Credentials masked in logs via `maskRepoURL` |
### Error kinds — never branch on a customer-facing sentence (R-553)
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `util.KindErrorf` / `util.KindError` | controller/internal/util/errkind.go | `(kind error, format string, a ...interface{}) error` | ANY refusal a caller must tell apart: build the message exactly as `fmt.Errorf` would AND carry a sentinel for `errors.Is` | The message bytes are unchanged (pinned by tests); never `fmt.Errorf("%w: …")`, which would prepend the sentinel's own text to the customer's sentence |
| `stacks.ErrAlreadyDeployed` / `ErrRequiredField` / `ErrPathMissing` / `ErrNotEnoughMemory` | controller/internal/stacks/deploy_errors.go | sentinels | the API's deploy status code (`api.deployStatusFor`) | 409 / 400 / 400 / 400. Do NOT add a text signature beside them |
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) |
| `AlertManager.GetBannerAlerts(page, lang)` + `bannerShows` (R-906, 2026-10-08) | controller/internal/web/alerts.go | the layout banner's list for ONE page, capped AFTER the page filter | any page that fills `data["Alerts"]` (`baseData` does it for you) | `GetAlerts` keeps Inline and other pages' alerts (debug dump, placement tests) and must not feed a banner: its cap counted alerts the layout then dropped, so a page showed „+ 5 további figyelmeztetés" and nothing above it. `bannerShows` mirrors the layout's own filter (pinned: `TestR906_*`) |
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the page decides on the kind ONLY — the legacy text fallback was removed when R-570 closed (2026-10-10) |
### HTTP/JSON envelopes + flash messages
| Symbol | File | Short signature | Use for | Gotchas |
@@ -49,16 +80,26 @@
| `restoreOpInFlight` + `hasRecentRestoreResult` | controller/internal/web/restore_wizard.go | `(backup.RestoreOpStatus) bool` / `(st, app, now) bool` | THE "is a restore running / did one just finish" display reads | **TRAP (v0.154.0 shipped this bug): `Manager` has TWO running flags.** `IsRunning()` reads the CONCURRENCY flag, acquired inside the goroutine — and `RestoreOffboxScratch` never acquires it, so it is false for the whole verification restore. Display must read `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`). Read the status ONCE per render or the strip and the suppression can disagree. `hasRecentRestoreResult` is app-bound and window-bounded — a process-wide result must not light another app's „Eredmény" |
| `restoreWizardPath` / `deriveWizardStep` / `resolveWizardApp` | controller/internal/web/restore_wizard.go | `(app) string` / `(restoreWizardInput) restoreWizardView` / `([]OffboxAppRow, name) *OffboxAppRow` | R-48 offsite restore wizard: URL builder + the PURE step/unlock derivation + the app-resolution refusals | The step is **never** taken from the request. Precedence is load-bearing: op-running outranks a stale `?full_prep=`, else a commit button reappears mid-restore. Truth table + red-proof: `restore_wizard_test.go`. Adding a form here that posts anywhere new breaks `TestRestoreWizard_NoNewMutationEndpoints` **by design** — R-48 adds no mutation surface |
| `restoreOpBlocked` | controller/internal/web/restore_wizard.go | `() (msg string, blocked bool)` | THE refusal gate before starting ANY restore | **Use this, never a bare `IsRunning()`.** It reads BOTH flags: `RestoreStatus().Running` (set synchronously by `BeginRestoreOp`, true for the whole off-box restore) and `IsRunning()` (the concurrency flag, the only one the nightly backup holds). **R-351: all seven handlers read only `IsRunning()`, which the goroutine acquires AFTER the handler returns — a second press started a second run and was told „…elindult".** Returns the Hungarian refusal, which names the running app and a route | **R-360 (v0.226.0): `offboxVerifyCopyDeleteHandler` was the LAST holdout and its doc comment claimed it already did this — the sentence is why nobody looked. `IsRunning()` is FALSE for the whole of a verification restore, so the copy a restore was writing into could be deleted from the UI (observed live 2026-08-21 22:35). No app-name comparison: refusing during ANY restore is stronger and uniform.**
| `Manager.RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353, v0.226.0) | controller/internal/backup/restore_unit.go | `(stack string) (UnitRestoreResult, error)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation |
| `Manager.RestoreFromRecoveryUnitAt` / `RestoreFromRecoveryUnit` + `UnitRestoreResult` (R-353 v0.226.0; R-102 v0.229.0) | controller/internal/backup/restore_unit.go | `(stack, unitDir string) (UnitRestoreResult, error)` / `(stack string) (…)` | THE local recovery-unit restore, and the facts its surface must state | **Returns a RESULT, not just an error** — volumes replayed, DBs replayed, and what the manifest LISTED. The Manifest* counts are load-bearing: zero-replayed has two causes (the backup held no data / the backup listed data that did not come back) and they are opposite news. Pair it with `unitRestoreOutcomeMsg`; do NOT write a new sentence. **A claim about the APP is forbidden on this path** — it has no `SafetyDump` discriminator, unlike the off-site twin (CONTEXT.md ruling, 07-backup-architecture §6.3). `restoreDockerVolumes` now returns `(int, error)`; `restoreDockerVolumesFrom` is unchanged and still the shared implementation. **R-102: `…At` holds the whole body and takes the unit DIRECTORY; the one-argument form is the thin caller naming the primary unit.** THE SOURCE MOVES; THE DESTINATION DOES NOT — `GetAppDrivePath` still resolves where the data lands. The R-47 mutation order, the unit-over-guest secret precedence, the fail-closed data-key gate and the no-unit `RestoreApp` fallback are all pinned and unchanged. The volume leg goes through the R-354 `volumeReplayFrom` seam so the source directory is assertable without Docker |
| `Manager.putBackAfterFailedRestore` + `liveVolumeCopy` (R-893 option A, v0.336.0) | controller/internal/backup/offbox_restore_undo.go | `(stack, cause, live, copy, volumesMoved, filesPlaced, restart, holdKey) error` | THE restore undo — off-site (v0.336.0) and, since D22 (unreleased), the LOCAL unit restore (`RestoreFromRecoveryUnitAtWith`, so also the second drive's and a kept load): the live volumes it will replace, measured before the stop (`planLiveVolumeCopy`), copied right after it (`copyLiveVolumes`), poured back on any failure | One copy per restore under `<ns>/backups/restore-undo/<app>/` (no capture set reads `backups/`); the put-back uses the restore's own `volumeReplayFrom`/`restoreDockerVolumesFrom`, so there is one volume-replay implementation. A put-back that fails sets `keep` and HOLDS (option C) — never start on volumes that are not known to be back. Seams for tests without Docker: `undoVolumeProbeFn`, `undoVolumeTarFn`, `undoVolumeRemoveFn`; operator notice `SetRestorePutBackNotify` (leg `restore-put-back`). Pinned by `r893_putback_test.go` and `r1177_local_putback_test.go` |
| `CrashPutBack` + `Manager.persistUndoForCrash` + `AppStopGuard.SetUndo` (D22 / R-1195, v0.338.0) | controller/internal/backup/restore_crash_putback.go | `NewCrashPutBack(stop, writeDef, logger)`; `PutBackInterruptedRestore(stack, undoDir) error` | A restore cut by a power loss is put back at start: the copy (volumes + `definition/` + `undo.json`) is recorded in the app-stop marker once whole; `Recover` pours it back BEFORE the restart | Call `persistUndoForCrash` right after `copyLiveVolumes` in any new restore path. `Recover` runs before the backup manager exists, so the put-back is wired in main.go with `stackMgr.StopStack` and the stack adapter's definition writer. A failed put-back = a FAILED recovery (app not started, marker kept). The household reads `note.restore.interrupted_put_back` (`LoadRestoreRecordAfterRecovery`). Pinned by `TestR1195_*` |
| `tier2CaptureSetFull(...) (legs, missing, warns, classified)` + `keptCopyRels` + `dropKeptCopiesUnder` (R-1252, unreleased) | controller/internal/backup/tier2_capture.go, tier2_kept.go | `classified` = the legs come from a VALID backup block | **The second-drive reconcile deletes only under a valid classification**; a rejected or absent block keeps every dest dir; kept copies (D25) are always in the keep set; a mirror deleted by Remove drops its kept records | A new reconcile caller must take `classified` and the kept rels too — never reconcile against the legacy legs. A rejected block is told by `Manager.BackupBlockRejected` (stacks; via the adapter's optional `BackupBlockRejected` method) and signalled once a day (`SetTier2SettingsUnreadableNotify`). Pinned by `r1252_rejected_block_keeps_copies_test.go` |
| `AppStopGuard.ResumePutBack` / `ResumeWaitingPutBacks` + `HoldReasonPutBackPending` (R-1251, unreleased) | controller/internal/backup/appstop_putback_wait.go | `ResumePutBack(stack) bool` (true = nothing owed, the caller may start); `ResumeWaitingPutBacks() []string` (puts back, then starts through the gated starter) | A cut restore's copy on a drive that is not bound is WAITING: Recover writes the restore hold `putback_pending` (copy in `SafetyDump`) and starts nothing; the put-back runs when the drive binds, before any start | **Any new path that starts an app when its drive arrives calls `ResumePutBack` first** (the boot recreate and `restartStacks` do, in web via `Server.resumePutBack`). Every other start path is covered by the hold store it already honours — do not add a second check. A failed put-back (at start or on resume) writes the D20 hold (`restore_mixed`) and drops the app from the marker. Needs `SetHolds` + `SetUndoReachable` before `Recover` (main.go). Pinned by `r1251_putback_waits_test.go`, `TestR1251_DriveStartPathsPutBackBeforeTheyStart`, `TestR1251_MainWiresThePutBackWait` |
| `Manager.RestoreTier2Unit` + `Tier2Coverage.CanRestoreUnit` / `Tier2CopyDate` (R-102/R-103, v0.229.0) | controller/internal/backup/tier2_restore.go | `(stack string) (UnitRestoreResult, error)` / `() bool` / `() (string, bool)` | THE full restore from the SECOND DRIVE's mirror, and the predicate that gates it | **Do NOT widen `CanRestore()`** — it answers only "can the additive file restore run?" and one predicate answering two questions is R-356. Fail-closed: `tier2UnitIsOpenable` requires a parseable manifest, because a directory is not a package. The single-writer flag is taken INSIDE `RestoreFromRecoveryUnitAt` — a second `acquireRunning()` here would refuse the restore it guards. `Tier2CopyDate` prefers the SUCCESS anchor over the attempt clock (R-101) and reports which it returned |
| `unitRestoreOutcomeMsg` + its three message constants (R-353, v0.226.0) | controller/internal/web/handlers.go | `(app string, res backup.UnitRestoreResult) string` | THE customer sentence for a completed LOCAL restore | Twin of `reconstituteOutcomeMsg`; copy its SHAPE (clauses earned by having done the thing, no filesystem path, base names only), never its text. The constants are named because `r353_unit_outcome_test.go` asserts them verbatim — a silent edit is how an honest message drifts back into a comforting one, which is the documented history of the sentence it replaces |
| `offsiteNoSpaceMsgFmt` + `offsiteSizeUnknownMsg` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | two consts | EVERY headroom refusal on the off-site restore surface | **All four gates share these** (prepare, scratch, place, and the destructive reconstitute). A customer meeting one wording on one path and a different one on another has to work out whether it is the same problem. **The reconstitute gate uses NO ×1.1 margin** — it copies a measured tree; `OffboxRestorePrepareFull`'s ×1.1 predicts a download. **Fail closed when either probe reads ≤ 0**: `free < need` with `need == 0` is FALSE, so an unmeasurable input sails through — a gate present and inert |
| `Manager.OffboxFullScratchReady` + the scratch marker (R-358, v0.226.0) | controller/internal/backup/offbox_restore.go | `(stack) bool`; `.felhom-restore-complete.json` | THE gate for place-to-live and reconstitute | **It answers "did the run FINISH and was it FULL", not "are there files".** The old non-empty check passed a part-copy from a failed restic run, and the old doc comment ("PlaceOffsiteRestore re-validates per-path completeness") is what made it look adequate — that call stats top-level placements, not files. Marker written 0600 atomically AFTER restic returns nil; stale one cleared BEFORE it starts; both orders pinned by an AST test because `resticStep` is not a seam. Anything else — absent, unreadable, wrong schema, `full:false` — is NOT ready, with a WARN naming which. **Unit-only and full restores write the SAME directory**, so `full` is the only separator (R-396) |
| `JudgeRestoredUnit` + `UnitProofResult` (R-87, v0.231.0) | controller/internal/backup/r403_hollow.go | `(unitDir string) UnitProofResult` | THE question "does this app's backup contain what THIS APP should have" | **The rule has TWO parts and part 1 alone is the trap:** "everything declared is present" passes a HOLLOW unit, because a hollow unit declares nothing — the exact shape it exists to catch. Part 2 is the expectation, and it comes from the unit's **own** captured compose file (`UnitComposeDir(unitDir)` + the compose filename), NEVER from live Docker (`GetDockerVolumes` describes the running app; the snapshot may predate it). Database half is `DBServiceNames`, the same discriminator `RestoreFromRecoveryUnit` uses, so this cannot disagree with the restore path about what an app is. **The volume half is an EXISTENCE check, not a name match** — `ResolveDockerVolumeNames` derives the project from the compose file's parent dir, which inside a unit is the literal string `compose`. **THREE outcomes:** pass / fail / **cannot judge**, and the third is never collapsed. Size is never consulted (`TestR87_SizeIsNeverConsulted`) |
| `Manager.ProveOffboxUnit` + `ProofResult` (R-87, v0.231.0) | controller/internal/backup/offbox_proof.go | `(ctx) ProofResult` | THE nightly off-site content proof — one app, its newest snapshot, restored read-only and judged | **It NEVER writes to the repository and that is asserted on the ARGV:** `--no-lock`, no `unlockStale`, and `m.runner()` rather than `resticStep` so the `unlock --remove-all` escalation is unreachable. **It takes `acquireRunning` ITSELF** because `RestoreOffboxScratch` does not (R-408) — do not remove that. **Due-ness is per SNAPSHOT** (`ProvedSnapshots[stack]`), never a timestamp: a timestamp re-proves the same snapshot forever AND breaks the rotation. **Its scratch is a SEPARATE root** (`offsiteProofRootFor`, `backups/offsite-proof`) — sharing the customer's `offsite-restore` root would let a nightly job delete a copy the customer is looking at. A skip, a missing snapshot and a restore error reach NO verdict and do not advance due-ness |
| `unitOnlyHeadroom` + `offboxScratchDirIn` (R-87, v0.231.0) | controller/internal/backup/offbox_restore.go | `(free int64) error`; `(stack, rootFor)` | The unit-only free-space gate and the drive-preference resolver, **shared** by the customer restore and the nightly proof | Extracted rather than forked so the two paths cannot drift on the parts that must not differ — the floor, the Hungarian refusal (`offsiteNoSpaceMsgFmt`), the network-storage refusal and the R-252 wording. `rootFor` is the ONLY difference between the two scratch paths. **Fail-closed on an unmeasurable probe:** `offboxFree` returns 0 when it cannot read, and `0 < floor` refuses — the inverse of the R-357 shape where a gate went inert |
| `Manager.CheckOffboxIntegrity` + `IntegrityResult` + `IntegrityDue` (R-359, v0.227.0) | controller/internal/backup/offbox_integrity.go | `(ctx) IntegrityResult`; `(now) (due bool, last time.Time)` | THE off-site integrity check, and the only place `restic check` is run | **It TAKES `acquireRunning` and SKIPS rather than waits — never remove that guard.** `resticStep` escalates to `unlock --remove-all` on a lock error and is only safe because every caller holds the single-flight mutex; a check without it can strip a LIVE prune's lock. **THREE outcomes, not two:** `Skipped`, `Unreachable` and failed are different facts — a timeout is unreachable, NEVER damage, and only a failure notifies. A skip and an unreachable repo do **not** advance due-ness; a failure does. **DUE-NESS, NOT A WEEKDAY** (R-341). **`looksLikeRepositoryDamage` matches PHRASES, not words** — bare `pack `/`tree `/`snapshot ` appear in restic's ordinary progress output and made a healthy run look corrupt |
| `runOffsiteIntegrityCheck` + `integrityFailedMsg` / `integrityOKMsg` (R-397, v0.227.0) | controller/cmd/controller/main.go | `(ctx, mgr, notifier, logger, force) backup.IntegrityResult` | THE one caller of the check — the scheduled job AND the debug button both go through it | ONE function so the hand-run cannot drift from the scheduled one; `force` skips due-ness and **nothing else**. Wired to `NotifyIntegrityOK` / `NotifyIntegrityFailed`, which existed with no caller since the notifier did (sixth built-but-never-wired instance). **`ok` is severity `info` and therefore mails NOBODY by design** — a weekly success e-mail is how alerts stop being read. The customer gets a sentence; restic's output goes to the log truncated (R-379) |
| **`SetOffboxRunner` / `m.runner()` — the restic exec seam, and it has ALWAYS existed** | controller/internal/backup/offbox.go (~L52, ~L386) | `offboxRunner func(ctx, env, args...) ([]byte, error)` | Driving ANY restic-backed path under test | **R-398 claimed there was no such seam and was WRONG — do not re-file it.** `resticStep` is not itself overridable, but the layer it calls is, and tests have driven restic paths through it since the off-site tier shipped. **Use this rather than adding a `resticStepFn`:** replacing `resticStep` would hide its `unlock --remove-all` escalation from exactly the assertions that must see it (R-359's lock-safety tests assert `unlock` never appears in any argv) |
| `Manager.SetOffboxLatestSnapshotFn` (R-357, v0.226.0) | controller/internal/backup/offbox_restore.go | `(fn func(ctx, stack) (id string, paths []string, err error))` INIT/TEST-ONLY | Overriding the restic snapshot lookup in tests | Exists because R-357's gate could not otherwise be tested at the level that matters: reaching it requires getting past `offboxLatestSnapshot`, which shells to restic. **The assertion the seam enables is `StopStack` call count == 0** — a gate placed after the stop returns the right sentence and still takes the outage. Nil in production |
| `CheckPlacement` + `PlacementMismatchMessage` | controller/internal/backup/offbox_placement.go | `(*RecoveryManifest, liveDrive, liveNS) PlacementCheck` / `(stack, PlacementCheck) string` | Comparing where a backup SAYS the data lived against where a restore is about to write | Pure and total — nil/empty/blank manifest all give the same honest "not known, no mismatch". **An UNKNOWN is never a mismatch** (refusing on an absence strands every pre-field unit). Compares the DRIVE only (the namespace root is derived from it), Cleaned, so a trailing slash is not a difference. The message names BOTH values on purpose |
| `RecordedUnitForStack` + `RecordedAddress` | controller/internal/backup/offbox_placement.go | `(stack) (RecordedPlacement, RecordedAddress, bool)` | Reading back the address + data folder a backup recorded, for a reinstall prefill | Local file reads over every readable namespace root — **no network, no restic, no restore**; it exists for the NOT-INSTALLED case where `GetStackHDDPath` is `""`. **`RecordedAddress.Known()` requires BOTH halves:** an absent `SUBDOMAIN` makes the live deploy path fall back to the CATALOG default (`stacks/deploy.go:88-90`), and offering that back as "what your backup says" is a fabricated fact |
| **`Metadata.For(lang)` + `LocalizeStacks` / `LocalizeStackPtr` / `Stack.MetaFor` (R-560, v0.257.0)** | controller/internal/stacks/metadata_i18n.go | `(lang string) Metadata`; `([]Stack, lang) []Stack`; `(*Stack, lang) *Stack` | THE only way a page may reach catalog COPY — the app description, tagline, use cases, first steps, prerequisites, deploy-field labels and descriptions, optional-config text, integration labels, data-path labels and the initial-credentials note | Reading `stack.Meta.<copy>` off the manager renders HUNGARIAN to an English household, with no error and nothing wrong-looking on the page — `TestNoDirectMetaCopyReadOnPages` keeps the named, reasoned allow-list of every direct read in `internal/web`. **`For("hu")` is the parsed struct with `I18n` cleared** and nothing else (pinned over all 53 real catalog files), so a translation cannot change the Hungarian product. **Fallback is FIELD BY FIELD** — absent or blank English shows Hungarian, which is what makes a half-translated app shippable. **Lists replace WHOLE; every other list is matched by its own key** (`env_var`, option `value`, `match_group`, `target`, `path`) — position matching mistranslates silently the first time a field is inserted. **It never writes through:** the metadata is shared by concurrent requests, so an in-place merge leaks one household's language into another's page |
| `AppInfo.WithAddress` (R-498) | controller/internal/stacks/appinfo_address.go | `(subdomain, domain string) AppInfo` | Filling the catalog copy's `<label>.DOMAIN` / bare `DOMAIN` placeholder with the household's real address (effective subdomain + customer domain) | Returns a COPY (the metadata is shared); `\bDOMAIN\b` leaves `SUBDOMAIN`/`MAIL_DOMAIN` alone; no domain → unchanged. Apply AFTER `LocalizeStackPtr`. The deploy page still fills its post-deploy steps in JS (`deploy.html`), `knownLoginLine` its own line |
| `Metadata.HasDeployField` | controller/internal/stacks/metadata.go | `(envVar string) bool` | "Does this app have somewhere to PUT a recorded value?" | **13 of 53 templates declare `HDD_PATH`; 40 do not** (measured 2026-08-21). For the 40 a recorded placement is a FACT TO STATE, never a value to write into a field that does not exist |
| `redirectTier2` | controller/internal/web/tier2_config_handler.go | `(w, r, name, flash, flashErr)` | Tier2 page flash redirects | Same convention |
| `validStackName` | controller/internal/web/validate.go | `(name string) bool` | Any stack name from a request | Single-segment, no `/ \ ..` — blocks path traversal into stacks/userdata |
@@ -74,10 +115,11 @@
| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate |
| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone |
| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only |
| `Manager.keepCopy` / `releaseKeptCopy` / `RemoveKeptCopy` / `KeptCopies` + `settings.KeptCopy` (D25, R-1175/R-1181, 2026-10-21) | controller/internal/backup/tier2_kept.go, controller/internal/settings/kept_copies.go | see files | **A second-drive copy whose SOURCE is gone** (drive away/decommissioned, declared folder missing) — recorded, listed on the restore page, removed only by the household's „Eltávolítás" | **Never remove a kept copy by size, name or pattern** — `RemoveKeptCopy` takes the RECORD and checks path-inside-tree + device/inode; a swapped directory refuses. A share/leg reconcile walks ONLY its own key/legs (R-1175: walking every key with one group's legs deleted the other groups' copies). `emptiedSourceRefused` stops `rsync --delete` from emptying a full copy |
| `stacks.SaveImportedAppConfig` + `PrepareImportedApp` (R-1191, 2026-10-21) | controller/internal/stacks/import_record.go | `(stackDir, env) (fresh bool, error)`; `(stackDir, env, fresh) error` | The `.fab` import's app record + doors (family gate, sign-up lock) BEFORE the start | Reuses `carryLifeRecords` / `prepareFamilyGate` / `restoreSignupLock` — do not copy their logic. `PrepareImportedApp` must also update the IN-MEMORY record, or the gate reconcile removes the file on its next tick |
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
| `atomicWriteFile` | controller/internal/setup/handlers.go | `(path, data, perm) error` | setup-wizard writes | Same bind-mount fallback caveat |
| `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand |
| `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-<ts>` + safe defaults; never crash-loops |
| `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup |
@@ -85,11 +127,14 @@
| `Manager.buildSharesPayload` / `classifiedShares` | controller/internal/backup/shares_payload.go | `() (dir, passdbOK, error)` / `() []classifiedShare` | the definitions+credential payload and the availability-filtered share set both tiers read | payload is SECRET-BEARING (0600 passdb.tar) — never log its bytes/name at INFO. `classifiedShares` is the single place a dead mount is dropped, so both jobs agree |
| `Manager.selectTier2TargetFrom` | controller/internal/backup/tier2.go | `(stack, sourceDrive, fullSize, stateOnlySize) (*Tier2Target, error)` | tier-2 target choice with the source drive supplied EXPLICITLY | the seam the shares job reuses — NEVER fork the headroom math; `selectTier2Target` is now a thin wrapper over it |
| `Manager.tier2ReconcileRoots` | controller/internal/backup/tier2.go | `(destBase, roots, legRels)` | staleness pruning with explicit dest roots | pure extraction from `tier2Reconcile` (which now calls it with `hdd`/`userdata`); reuse it rather than writing a second pruner |
| `backup.Tier2CopyBlocksRestore` / `ClearLeftoverTier2Copies` / `MarkTier2CopyForTest` + the `tier2Docker` seam (the stray-copy fix, v0.340.0) | controller/internal/backup/tier2_copy_lifecycle.go | `(m) Tier2CopyBlocksRestore() (app, blocked)`; `ClearLeftoverTier2Copies(ctx) error`; `MarkTier2CopyForTest(app) func()` | a restore's refusal while a capped second-drive copy runs or a stray may run; the start-up removal of labelled copy containers; the test marker; `tier2Docker` is the docker seam (tests never reach real docker) | Row added 2026-10-24 (review round 2). The capped copy runs `rsync --delete` in its own container — never call `docker` directly for it; go through `tier2Docker`. |
| `Manager.liveShareRootOK` / `scratchJoin` | controller/internal/backup/shares_restore.go | `(dst) bool` / `(scratch, abs) string` | THE place guard for shares restore + scratch path reconstruction | a snapshot is UNTRUSTED layout input: require a STRICT descendant of a live registered root, refuse `..` and the drive root itself. `scratchJoin` strips the volume name — plain `filepath.Join` splices a drive letter mid-path |
| `infra.SambaContainerName` / `SambaPassdbVolume` / `SambaPassdbMount` | controller/internal/infra/samba.go | consts | single source of truth for the samba container identity | the compose renderer interpolates them; stacks/backup/monitor read them. The CONTAINER name (`felhom-samba`) is NOT the stack name (`samba`) — `EffectiveProtected` needs the container one |
| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 |
| `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot |
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE R-82 multi-tier backup schedule — several whole-guest tiers (local daily + PBS weekly) reconciled into ONE quiesce window | **Both tiers due ⇒ ONE stop/start pair**, never two (two = two app outages for one night). Tiers run SEQUENTIALLY (vzdump holds a guest lock) and the app stays down until the LAST tier snapshots — resuming earlier loses app-consistency on the DR tier. Order is fast-first (agent advertises primary first) or downtime blows up. `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
| `quiesce.TieredBackend` + `Loop.resolveDueTiers` / `quiesceAndPollTiers` | controller/internal/quiesce/tiers.go, quiesce.go | `Tiers/DueFor/StartBackupFor/BackupStatusFor`; `resolveDueTiers(ctx) ([]dueTier,bool,error)` | THE multi-tier whole-guest backup schedule (local daily + PBS weekly) | **One stop per tier** (`09` §3 decision 156, v0.301.0 — REVERSES R-82's one window for both tiers): a window backs up only the FIRST due tier and resumes the apps at its `snapshotted`; another due tier waits for a later cycle. A tier that refuses to start (BUSY or an error) lets the next tier try in the same window. Order is agent order (primary first). `ErrTiersUnsupported` (route 404) ⇒ pre-R-82 agent ⇒ degrade to the untargeted path and **STILL BACK UP** — never read it as "nothing due". |
| `quiesce.InFlightProber` + `Loop.anotherTierInFlight` · `agentapi.Client.BackupJobsInFlight` (R-921) | controller/internal/quiesce/precheck.go · controller/internal/agentapi/backup_tiers.go | `BackupJobsInFlight(ctx) ([]string, error)` | Check first, stop second: no app is stopped for a tier the agent will refuse because ANOTHER tier's job is in flight | OPTIONAL interface (fakes without it keep the old path). Fails toward backing up on any read error. **It cannot see the agent's host-wide heavy-op gate** (OS step, restore-test, fstrim — no endpoint serves it): that refusal is met by the BUSY path's immediate resume (`TestR921_BusyRefusalResumesAtOnce`). The window's OWN tier in flight is not skipped (the agent answers with the running job) |
| `quiesce` whole-guest ledger — `Loop.pressOwesNight` / `recordWholeGuestSuccess`, `WithManualTrigger` / `IsManualTrigger` (R-899) | controller/internal/quiesce/nightowed.go | `pressOwesNight(led, target) bool`; `WithManualTrigger(ctx) ctx` | **A household press never cancels the night's whole-guest backup** (operator ruling 2026-10-08) | The ledger (`whole-guest-ledger.json` beside the marker) records SUCCESSES only, per tier, press vs scheduled — it decides due-ness and is never evidence that a copy exists (the agent's storage answers that). An „owed" tier never fires the window gate's safety valve (`withoutOwed`/`gateAge`). The press mark rides the context to the agent adapter (`agentapi.StartBackupForTrigger` → `trigger=manual`), so the agent runs no OS leg after a press. |
| `quiesce.failureBreaker` + `Loop.dropBackedOffTiers` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `blocked/recordFailure/recordSuccess(target, now)`; `backoffFor(n) time.Duration` | **R-88** — a tier whose backups keep failing stops re-quiescing. Backoff 15m→30m→1h→2h→4h (cap), reset on success | **It gates the QUIESCE, not the backup** — the harm was never the failing backup, it was the app outage taken to attempt it, so backed-off tiers are dropped from the due set BEFORE any stack is stopped. **Per TARGET** — a broken offsite tier must never suppress a healthy local one (`TestBreaker_OneFailingTierDoesNotSuppressAHealthyOne`). **Never permanent** — the cap bounds the retry INTERVAL, it never stops retrying; a latched breaker is a silent backup outage, worse than the loop it replaces. **`TriggerNow` is never gated** (it already bypasses due-ness and the window gate), though a manual run still RECORDS its outcome. **`stillRunning` is NOT a failure** — a first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose**: a restart forgets the backoff and re-attempts, which is the cheap direction to fail. Log the deferral ONCE when armed, never per tick. |
| `quiesce.TierNotifier` + `Loop.SetTierNotifier` / `noteTierFailure` / `noteTierSuccess` | controller/internal/quiesce/breaker.go, quiesce.go | `BackupFailed(tier,msg,err)` / `BackupRecovered(tier,msg)`; `SetTierNotifier(n)` INIT-ONLY | **R-97a** — the whole-guest backup tier reports its outcome to the hub | A **seam, not an import** — quiesce keeps no dependency on `internal/notify` (same reason `windowStartFn` is injected). Wired by a setter because main.go builds the notifier AFTER the loop; `nil` = unprovisioned guest, not an error. **Edge-triggered:** failure fires only when the breaker ARMS (`n == 1`), never per retry — the cadence is 15m/30m/1h/2h/4h and an event per attempt is an inbox nobody reads. Recovery rides `recordSuccess`'s existing bool. **Event types are OPERATOR-ONLY** (`whole_guest_backup_failed`/`_recovered`, hub >= v0.78.0) — NOT `backup_failed`, which has a customerMessages entry AND sits in live `enabled_events`, so it would email the CUSTOMER about a backup they cannot act on. `WholeGuestBackupDetails.Tier` is load-bearing: the hub keys its per-tier cooldown on it. |
| `quiesce.Loop.SuppressedStacks` + `markQuiesced` / `markUnquiesced` | controller/internal/quiesce/suppress.go | `() map[string]bool` (nil-safe on a nil *Loop) | **R-97b** — an app THIS controller stopped for a backup is not a fault | Consumed at the SINGLE derivation point `classifyRunStates` (which computes both the banner dead-list and the notifier Down-set — keep it one place). **Cycle-keyed, not state-based:** v0.164.0's `!= StateStopped` filter cannot see an app caught MID-RESTART (`starting`/`unhealthy`), which is how BookStack alarmed on 2026-07-27. The window (`quiesceAlarmGrace` = 180 s, derived from the deploy flow's 120 s health timeout and Mealie's 60 s start_period) **EXPIRES** — permanent suppression turns a loud false alarm into a silent real one. Open-ended while the cycle runs (a first offsite snapshot legitimately takes hours). **This set alone is NOT the whole answer** — see `AppStopGuard.SuppressedStacks` (R-330) for the per-app operations; `classifyRunStates` consumes the union of both. |
@@ -99,12 +144,17 @@
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `stacks.Manager.admitCompose` / `privpolicy.Admit` (v0.328.0, R-971) | controller/internal/stacks/allowlist.go, controller/internal/privpolicy/privpolicy.go | `admitCompose(name, data, env) error` | THE question before ANY write of an app compose (and before a settings change or restore commits an env): the box's catalog allow-list on the RENDERED compose | A new compose writer must call it first — `TestAllowList_IsWiredIntoEveryComposeWriter` fails otherwise; a refusal records the page line + one hub event per definition; the allow-list is `privpolicy/allowlist.json` (exact app+service+setting; EMPTY since v0.329.0) plus `appdata_folders` (an app's `${HDD_PATH}/appdata/<folder>` when the folder is not its name — `privpolicy.AppdataFolder`, R-1142), and the catalog's `scripts/privilege-allowlist.json` must stay byte-identical |
| `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure |
| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `<project>_<key>`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only |
| `renderDigests` / `RenderWithLadderDigests` / `CarryDigests` / `StripDigest` (v0.269.0/.1) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update; the sync for an app NOT yet installed) — an INSTALLED app's sync uses `CarryDigests` (v0.269.1), which keeps the digest the app runs | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin |
| `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside |
| `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump |
| `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) |
| `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail |
| `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d <svc>...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise |
| `appbackup.DBServiceNames` / `dbTypeForImage` (R-47, v0.153.0) | controller/internal/appbackup/dbservices.go | `(composePath string) ([]string, error)` | naming the compose SERVICE(s) holding a database, sorted | yaml.v3 `services:` MAP parse — **never a line scan** (immich's top-level `immich_ml_cache:` / `immich_postgres_data:` volume keys look exactly like services). `dbTypeForImage` is shared with `DiscoverDatabases`, which is what makes "a dump exists ⇒ a service can be named" hold. An error means CANNOT-TELL, never "no database" — callers refuse when a dump exists |
| `Manager.StartStack/StopStack/RestartStack/UpdateStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
| `Manager.StartStack/StopStack/RestartStack` | controller/internal/stacks/manager.go | `(name string) error` | Lifecycle | **`UpdateStack` was DELETED in v0.237.0 — use `StartGuardedUpdate` (update.go).** Protected stacks refuse stop; all funnel through composeExec. **NOT writers of desired state (R-166)** — 14 call sites, only 2 are the customer; recording intent here would make a nightly backup indistinguishable from the customer pressing Stop. Use `SetDesiredState` at the intent point instead |
| `Manager.SetDesiredState` / `DesiredStateOf` / `BackfillDesiredState` (R-166, v0.189.0) | controller/internal/stacks/desiredstate.go | `(name, desired string) error` / `(Stack) string` / `() int` | THE customer-intent record — `app.yaml` `desired_state`, tri-state `""`/`running`/`stopped` | **ONE OWNER: the customer's action.** Writers are the API action switch, `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` restore adapter — nothing else, ever. **`""` (absent) means UNKNOWN, never "running"**: every pre-v0.189.0 app.yaml reads absent, so treating it as running would start every deliberately-stopped app on upgrade. Write intent BEFORE the act and REFUSE the act if it fails (§8.2). Backfill is **running-only** — never infer `stopped` from zero containers, that inference IS the defect |
| `Manager.DriveLive` (R-171, v0.190.0) | controller/internal/stacks/deploy.go | `(hddPath string) bool` | is an app's data drive a live mountpoint RIGHT NOW | Wraps the **same** `isMountPoint` seam the userdata belt uses (`manager.go`) — never write a second liveness check, the two would drift invisibly. The system/local path is legitimately not a mountpoint and returns true |
| `bootrecon.StartGate` (R-171, v0.190.0) | controller/internal/bootrecon/bootrecon.go | `MayStart(stack) (bool, reason)` | THE one question the boot sweep asks before starting anything | **Fail-safe: cannot determine ⇒ return FALSE.** One seam for all three holders (absent drive · quiesce · an in-flight app-data operation) because they differ only in the reason string. Implemented in `main.go` (`bootDriveGate`) reusing `quiesce.SuppressedStacks()`, `AppStopGuard.HeldStacks()` and `Manager.DriveLive` — never re-derive any of them. Held apps go to `Result.HeldByDrive`, **never** `StillDown` (that is the dead-app alarm's bucket) |
@@ -112,8 +162,25 @@
| `backup.AppStopGuard` (`Begin`/`End`/`Recover`) (R-166, v0.189.0) | controller/internal/backup/appstop_marker.go | `(opID, reason, stacks) error` / `()` / `() *AppStopRecovery` | THE crash marker for stop→work→start windows (volume dump, offbox reconstitute, `.fab` export) | Its **own** file (`appstop-state.json`), never quiesce's — one file, one writer. **A `defer` is NOT the mechanism** (Campaign 8 fault 10: SIGKILL runs no defer); the marker is. Written BEFORE the stop, cleared ONLY after a restart that succeeded; a FAILED restart deliberately KEEPS it. `Recover` RETURNS its outcome rather than notifying, because it must complete before the boot reconciler while the notifier does not exist yet |
| `backup.AppStopGuard.SuppressedStacks` + `markStopped` / `releaseStarted` / `ReleaseFailed` (R-330, v0.224.0) | controller/internal/backup/appstop_suppress.go | `() map[string]bool` (nil-safe on a nil *AppStopGuard); `ReleaseFailed(stacks ...string)` | **R-330** — an app a PER-APP operation is holding stopped (nightly volume dump, offbox reconstitute, `.fab` export) is not a fault | The **twin** of `quiesce.Loop.SuppressedStacks` above, and the two are unioned by `unionSuppressed` in main.go before `classifyRunStates` — **consult BOTH or the bug comes back**: R-330 shipped because the alarm read only the quiesce set while the per-app legs stopped apps through a different path. Rides `Begin`/`End`, so all three call sites got it with no call-site change. Grace is `appStopAlarmGrace` = 180 s, deliberately the SAME constant and derivation as quiesce's — two windows over one alarm that disagreed would be a bug on whichever path used the shorter one. **It must never latch**, and unlike quiesce's loop `End()` runs ONLY on a restart that succeeded: (1) every failure path calls `ReleaseFailed`, which drops the entry IMMEDIATELY so the app alarms on the next scan; (2) `Begin` REPLACES the set (one marker file = one operation); (3) `appStopMaxHold` (6 h) caps an open-ended hold and logs at WARN. **Deliberately NOT persisted** — after a crash the guard holds nothing and a down app must alarm. `ReleaseFailed` drops the suppression and KEEPS the durable marker; the two are independent and a test pins that |
| `backup.ErrStartRefused` + `AppStopRecovery.Refused`/`Alarming()` (R-174, v0.191.0) | controller/internal/backup/appstop_marker.go | `errors.Is(err, ErrStartRefused)` / `() bool` | THE refusal-vs-failure split in the app-stop crash recovery | **A gated starter's refusal is NOT a restart failure.** `Recover`'s starter MUST be the gated `gatedAppStopStarter` (cmd/controller/main.go), never the raw `stacks.Manager` — that was the v0.189.0 defect, which started apps onto ABSENT drives at boot (R-171 one path over). A refusal goes to `Refused` (marker KEPT, silent), a real error to `Failed` (marker kept, ALARMS). Collapsing them routes a deliberate hold into `NotifyBackupFailed`, a customer-enabled type — the R-171 false alarm again. `main.go` must guard the notify with `Alarming()`, not `!= nil` |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll |
| `Manager.DeleteStack` / `RemoveStack` | controller/internal/stacks/delete.go | `(name, removeHDDData[, backupPaths])` | THE guarded removal paths | Orphan/protected/deploying/running checks + ProtectedHDDPaths filter before any RemoveAll. **R-442 (v0.236.0): the drive is the app's OWN `app.yaml` `HDD_PATH` (`appHDDPath`), never a global (`cfg.Paths.HDDPath` was deleted in R-492); a data removal that cannot be resolved returns a typed `*RemoveRefusedError` BEFORE `compose down` — handlers `errors.As` it to 409 + `Message`** |
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
| `Manager.recordInstalledImages` (v0.233.0) | controller/internal/stacks/installed.go | `(name, stackDir string, env []string)` | writing down what each compose SERVICE is ACTUALLY running, into `app.yaml.installed_images` | Called after a successful compose up from `StartStack`/`RestartStack`/the deploy path/the end of the guarded update and of the undo (`UpdateStack` was deleted in v0.237.0). **Reads the CONTAINER, never `docker-compose.yml`** — that file is the value the syncer has already moved (spike §3: 25 minutes of disagreement). **A failed write NEVER refuses the action** — the deliberate OPPOSITE of `SetDesiredState`: intent refused, observation logged at ERROR. **NOT from `StartStackServices`** (the R-47 DB-only window would overwrite a complete record with a partial one). Skips the write when ref+digest are unchanged, and carries `at` forward so it means "running since". Its OWN seam (`installedExecFn`) with a **context + 30 s timeout** — the two existing exec helpers have neither |
| `Manager.SetPin` / `AdoptPins` / `RenderPlanFor` / `AppliedComposePath` (v0.235.0) | controller/internal/stacks/pin.go | `SetPin(name, stackDir, pin, composeSrc) error` | THE version freeze — `app.yaml.pinned_images` + the stored `applied-compose.yml` | **`PinnedImages` is INTENT, `InstalledImages` is an OBSERVATION — never feed one from the other** (the R-166 category error, one field over). Four writers only: deploy, the guarded update (via `advancePinToCatalog`, which advances the pin and re-renders BEFORE the pull, and REFUSES the update if the pin cannot be written; a failed pull puts the pin BACK via `SetPin`), the restore adapter (this is what closes R-441), and `AdoptPins`. `AdoptPins` reuses `observationCoversTemplate` — do NOT write a second completeness rule — and skips loudly rather than inventing a pin. Absent pin = pre-v0.235.0 behaviour |
| `Manager.UpdatePreflight` / `StartGuardedUpdate` / `RecoverUpdates` / `ResumeInterruptedUpdates` (v0.237.0) | controller/internal/stacks/update.go | `UpdatePreflight(name) *UpdateRefusal`; `StartGuardedUpdate(name) error` | THE update — refusals, then a 202 job with phases on `Stack.Updating/UpdatePhase/UpdateError` | **Never report an update complete before health is known (R-443).** Every cheap refusal runs BEFORE the intent write. Safety dump BEFORE the pin moves; pin BEFORE pull; pull failure → pin back; health failure → stop + HOLD, pin stays. Journal-before-mutate (`update-journal.json`); `RecoverUpdates` MUST run before the boot sweep and `ResumeInterruptedUpdates` AFTER `SetUpdateGuards`. Seams: `updateComposeFn`, `updateHealthFn`, `updateMemoryFn`, `updateDiskFreeFn`, `updateNowFn` (R-457: the age check and the test read ONE clock). Unwired guards ⇒ every update refused |
| `stacks.UpdateGuards` + `updateGuardsAdapter` (v0.237.0; tiers v0.239.0) | controller/internal/stacks/update.go, controller/cmd/controller/main.go | `HoldFor`, `Busy`, `RestorePoints`, `CanBackUp`, `BackupNow`, `SafetyDump`, `HoldAfterFailedUpdate` | the ONLY bridge from the update job to the backup side (stacks cannot import backup) | Wired by `stackMgr.SetUpdateGuards` — pinned by `TestSlice4_UpdateGuardsAreWiredAtStartup`. Add a guard HERE, never by importing backup into stacks |
| `backup.Manager.Tier2UnitRestorePoint` + `Tier2RestorePoint.ProvenCopyTime` (v0.237.0) | controller/internal/backup/update_guard.go | `(stack) (Tier2RestorePoint, error)` | "can this app be restored from Tier 2, and from when" — the predicate that gates BOTH the „Teljes visszaállítás" action and an update | **ONE predicate, two callers** (extracted from `buildAppBackupRows`, not copied). `CopyDate` is what the page NAMES (the package date, R-403); `ProvenCopyTime` is how OLD the data is — the last successful copy, because the manifest's `created_at` moves only when the DEFINITION changes (measured: a fresh dump under a 22-h-older manifest). Do not age a copy by `CopyDate`. **Since v0.239.0 the UPDATE no longer calls it directly** — it goes through `UpdateRestorePoints` (next row); the page still does |
| `backup.Manager.UpdateRestorePoints` + `CanBackUpApp` (v0.239.0, R-475) | controller/internal/backup/update_guard.go | `(ctx, stack, accept func(UpdateTierPoint) bool) (UpdateTierPoint, bool, []UpdateTierPoint)` | "which backup can this update lean on" — walks Tier 2, 1, 3 and returns the first copy `accept` admits | **The age rule lives in the caller's `accept`** (stacks' `freshRestorePoint`), so it is ONE rule for every tier. **The ORDER is `UpdateTierOrderFor` (v0.241.0, R-479): 2 → 3 → 1 for an app with classified binds (`DataOutsideUnit`), 2 → 1 → 3 otherwise; `UpdateCopyHolds` is the matching phrase for the hold.** Stops at the first accepted copy, so Tier 3 (restic, 15 s bound, unreachable = absent + WARN) is reached only when needed. Seams: `updateTier2PointFn`, `updateTier1PointsFn`, `updateOffsiteTimesFn` (v0.240.0: Tier 3 reads `OffsiteSnapshotTimes` — snapshots only, never the inventory's per-app `stats`). stacks adds R-478's rule: a copy older than `deployed_at` does not count (`usableRestorePoint`). Tier numbers are pinned equal across stacks/backup by `TestR475_TierConstantsAgree` |
| `backup.Manager.Tier2MirrorDirsForApp` / `RemoveTier2Mirrors` + `settings.DeleteAppBackupPrefs` (v0.240.0, R-474 / R-486) | controller/internal/backup/r474_remove_mirrors.go | `(stack) []string`; `(stack, dirs) []string` | deleting an app's backups on removal — the Tier-2 mirror lives on ANOTHER drive, outside RemoveStack's per-app base; the same dirs size the backup card (R-485) | Read the mirror dirs BEFORE the prefs are forgotten. Deletes only `<root>/backups/secondary/<stack>` for a known root; never `_shares`, never a path that merely cleans to it. **The Tier-2 RECORD goes only with `remove_backups`** (R-486) — a removal that keeps the backups must keep the record, or the mirror is unrestorable. Pinned by `TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs` + `TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo` |
| `appbackup.dbTypeForImage` (R-484, v0.240.0) | controller/internal/appbackup/dbservices.go | `(image) (DBType, bool)` | the ONE place an image is judged a database — nightly dumps, pre-update safety dump, DB-only replay | Derived Postgres images (`postgis`, `pgvector`, `timescaledb`) are Postgres. A new engine image goes HERE and in `dbservices_test.go`'s table, never in a second matcher |
| `backup.Manager.HoldAfterFailedUpdate` / `RunAppBackupNow` / `WriteUpdateSafetyDump` / `UpdateBusy` (v0.237.0) | controller/internal/backup/update_guard.go | see file | the update's hold, per-app backup-now, safety dump, busy check | The hold is `settings.RestoreHold` with `Reason: update_failed` — SAME store and gate as R-379, never a second map. `RunAppBackupNow` composes the nightly legs for ONE app (admission, DB dump, volume dump, capture, Tier-2) — do not write a second backup orchestration. A successful unit restore lifts an UPDATE hold only. **`isHeld` is ALSO true while a guarded update is moving the app (`SetUpdatingCheck`, v0.238.1)** — found live: the periodic capture overwrote a primary unit during a health wait. **R-645: every night leg (DB dump, volume dump, capture, Tier-2) also skips an app whose pin is not what it runs (`versionSkip`, wired by `SetVersionCheck` → `stacks.Manager.PinNotRunning`); unknown never skips. The page line is `VersionSkipFor`** |
| `stacks.Manager.memoryVerdict` (v0.237.0) | controller/internal/stacks/deploy.go | `(newReq, newLimit, releasedReq, releasedLimit int) (refusal, warning string)` | the deploy's memory check, shared with the update | An update RELEASES the app's current request first. Deploy passes `0, 0` and is byte-identical in wording and log line |
| `Syncer.SetRenderPlanFn` + `renderSource` (v0.235.0) | controller/internal/sync/sync.go | `func(appName string) stacks.RenderPlan` | the catalog render table | **NIL-SAFE: no seam = copy verbatim = the old product.** Catalog images == pin → verbatim (fixes flow + self-healing, both deliberately kept); differ → the WHOLE stored definition, **never a ref substitution into a newer template** (`wger 2.6`). `.felhom.yml` always verbatim (R-458). The syncer must NEVER read app.yaml. Re-reads the applied file before writing it — a test caught it writing an empty compose over a live app |
| `Stack.CatalogImages` vs `Stack.TemplateImages` (v0.235.0) | controller/internal/stacks/manager.go | both `map[string]string` | badge input vs "what the next `up -d` gives this app" | **THE TRAP: same type, same shape, opposite meaning after the freeze.** `TemplateImages` reads the LIVE (possibly frozen) compose file; `CatalogImages` reads the syncer's clone. `web.compareInstalledToTemplate` MUST use `CatalogImages` or it answers „Naprakész" on exactly the apps that are behind, with every test green. Red-proved |
| `Manager.BackfillInstalledImages` (v0.234.0) | controller/internal/stacks/installed.go | `() int` | seeding `installed_images` for apps that have NO record — call ONCE at startup | Beside `BackfillDesiredState` in `cmd/controller/main.go`, after it and BEFORE the boot reconciler (pinned by an AST-walking test that asserts the ORDER). **READS only** — starts nothing, writes no compose file. **Never overwrites an existing record** (an app that has one is not even observed). **REFUSES a partial observation** (`observationCoversTemplate`): `web.compareInstalledToTemplate` reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible renders „Frissítés elérhető" over an app that is current. The bring-up paths may write a partial because they follow a SUCCESSFUL `up -d` where a gap is real news; a backfill meets any state and must be stricter |
| `stacks.ParseComposeImages` (v0.233.0) | controller/internal/stacks/installed.go | `(composePath string) (map[string]string, error)` | compose SERVICE name -> the image the FILE pins; feeds `Stack.TemplateImages` and the update badge | yaml.v3 `services:` MAP parse, never a line scan (same reason as `DBServiceNames`). An error means CANNOT-TELL — `ScanStacks` leaves `TemplateImages` nil and the badge renders NOTHING, never "current" |
| `stacks.Manager.AnyUpdating` / `SetSelfUpdatingCheck` + `selfupdate.Updater.SetAppUpdatingCheck` (v0.261.0, R-608) | controller/internal/stacks/update.go, internal/selfupdate/updater.go, wired in cmd/controller/main.go | `() bool` callbacks, both directions | THE two-way lock between the CONTROLLER's own swap and a guarded APP update | **Wire BOTH halves or neither** — they are wired together in `main.go`, the only place holding both objects; **`stacks` must never import `selfupdate`**. The app-update side is a sibling of `SetBackupRunningCheck` and is consulted in the SAME three places (dry run, `TriggerUpdate`, `maybeAutoUpdate`) — do not add a fourth pattern. **`AnyUpdating` MUST answer false for a HELD app** (`Stack.Updating` is cleared on done/failed/held): a latching gate would block the controller's own updates for ever, including the one that fixes the hold. Nil callbacks are SAFE and mean pre-v0.261.0 behaviour — never fail closed on an unwired gate |
| `stacks.CatalogOrder` / `CompareImageRefs` (v0.260.0, R-524) | controller/internal/stacks/updateorder.go | `(Stack) UpdateOrder` — Unknown/Current/Behind/**Ahead** | THE one "how does this app stand against the catalog?" verdict | **Both the badge AND `Manager.UpdatePreflight`'s `downgrade` refusal read it — never re-implement the comparison.** `web.compareInstalledToTemplate` is a thin wrapper. Ahead is NARROW: every differing service must be orderable AND newer, else Behind. Ordering is `util.Version.Compare` behind a tag normaliser (`X.Y`/`X.Y.Z`, optional `v`, suffix must be IDENTICAL on both sides) — **never add a second comparator**. Queries NO registry; absent record = Unknown, never „Naprakész" |
| `web.updateBadge` / `updateBadgeAt` / `Metadata.CatalogSince` + `CatalogSinceAge` (v0.233.0) | controller/internal/web/updatebadge.go, controller/internal/stacks/metadata.go | `(stacks.Stack) *MetaBadge` | THE "is this app current?" label — „Naprakész" / „Frissítés elérhető — N napja" | The SECOND `*MetaBadge` user the type was built for: existing `meta_badge` partial, **no new markup or CSS**. **NO RECORD RENDERS NOTHING — absent means UNKNOWN, never current** (R-166 applied to an observation; red-proved). **No version number reaches the customer** and **no registry is queried**. `catalog_since` is tolerant in the `lifecycle` style — absent/empty/malformed/**future** all degrade to a badge with no age + one WARN. LIMITATION: for the **10** floating pins (recounted 2026-09-21; the old "23" matched no definition the catalog supports) the ref can match while the image has moved — 6 of the 7 measurable ones HAVE moved — so those read „Naprakész" when they may not be |
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
| `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet |
@@ -122,9 +189,13 @@
| `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) |
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
| `backup.Manager.stampDataFile` / `foldUnitData` / `unitNewestArtifact` / `RecoveryManifest.Data` (v0.275.0, R-696) | controller/internal/backup/data_versions.go, removed_units.go | `(stack, unitDir, rel)` / … / `(unitDir) (time.Time, bool)` | A backup's data and its version travel together: the leg stamps each file it writes, the capture folds the stamps into `data` and keeps the definition the data belongs to; `unitNewestArtifact` is THE unit data time for every tier and list | **A new writer of a unit's data file MUST call `stampDataFile` right after the file reaches its final name** — an unstamped file makes the whole unit's versions unknown (restores as before, WARN). Never date a unit by its manifest; never count `pre-restore-*` as data. `definitionPins` is the ONE source of "current pins" (the stack's compose parse) |
| `backup.unitVersionCheck` / `ErrUnitVersionMismatch` (v0.275.0) | controller/internal/backup/restore_unit.go | `(stack, *RecoveryManifest, composeDir) ([]string, error)` | A restore never starts data with a definition it does not belong to — the check every unit restore and the off-site restore make before the first mutation | Branch with `errors.Is`; the sentence is the bundle's (`err.backup.unit_version_mismatch` / `_versions_mixed`). Unknown (`nil, nil`) restores as before |
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected. **C9-F1 (v0.183.0): reads `hdd/` + `userdata/` ONLY — never `recovery-unit/`.** For 43 of 53 catalog apps that is a guaranteed no-op, so it now refuses with `ErrTier2NoRestorableData` BEFORE stopping the app. Ask `Tier2RestoreCoverage` first |
| `backup.Manager.Tier2RestoreCoverage` | controller/internal/backup/tier2_restore.go | `(stackName) (Tier2Coverage{Legs, HasUnit}, error)` | Answers what a Tier-2 restore CAN and CANNOT return for an app, from the RECORDED copy on disk | **C9-F1.** `Legs` = subtrees the restore reads; `HasUnit` = the copy also holds DB dumps + volume tarballs it will NEVER read. Use it to refuse up front and to decide whether the success message must disclose uncovered data. Judged from the copy, not the catalog, so a retemplated app is judged by what it actually has |
| `Manager.acquireRunning`/`releaseRunning`, `acquireMigrating` | controller/internal/backup/backup.go, controller/internal/stacks/migrate.go | `() error` | Single-flight for long ops | Copy this mutex-flag pattern for any new long-running manager op |
| `stacks.Manager.WantsStopped` (v0.283.0, R-721) | controller/internal/stacks/desiredstate.go | `(name) bool` | The household's recorded intent is „stopped" | ASK IT before any machine restarts an app it stopped for its own purpose (quiesce, volume dump, update leg) — a Stop pressed meanwhile must hold. Never gate the household's own Start on it |
| `settings.DefaultOffboxOnForNewApp` / `AppsWithoutOffbox` / `EnableOffboxFor` (v0.283.0, decision 50) | controller/internal/settings/offbox_default.go | `(stack) (bool, why)` | A fresh install joins the off-site copy when the customer has off-site | Call only from the deploy-done hook; it never overrides an existing per-app choice |
### Secrets hygiene
@@ -142,6 +213,12 @@
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way** — `POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
| `storedValuePresent` / `appLoginFields` | controller/internal/web/app_login.go | the settings tab's login panel and „Cím és kulcsok" rows (v0.319.0) | **A field the box holds no value for is a sentence, never an eye.** `storedValuePresent` is the ONE definition of „stored", read by the page AND by `appAutoFieldRevealHandler`'s 404 branch — so the page cannot offer a reveal the endpoint refuses. The page gets a boolean per login field, never the value (R-254/R-709). Pinned by `TestAppPage_NoStoredValueShowsSentence`. |
| `loginAnswerFor` | controller/internal/web/app_login.go | the settings tab's ONE login answer (v0.320.0) | `fields` · `initial` · the catalog's `app_info.login_kind` via `AppInfo.EffectiveLoginKind` (`first_visit` default; an unknown value reads as it). An infra stack with no login gets no panel. Pinned by `TestAppPage_EveryInstalledAppHasOneLoginAnswer`. |
| `filesButtonLink` / `mediaFolderCards` / `drivePresent` | controller/internal/web/datapath_card.go | the „Fájlok ↗" target and the media folder rows (v0.320.0) | **A folder whose drive is not bound into FileBrowser now is TEXT, never a link** — `drivePresent` reads `stacks.PresentMediaDrives` (the same gates `buildFileBrowserPaths` applies); tests seam it with `Server.presentDrivesFn`. Links only through `fileBrowserOpenLink` (R-1100; the folder path built by `fileBrowserLink`). Pinned by `TestAppPage_FilesButtonTarget`, `_UnknownSourceIsTextNotLink`. |
| `infraMetaIn` | controller/internal/web/inframeta.go | an infra stack's curated name/description in a language | The ONE place for both the `infraMeta` template func and the app page. |
| `Manager.FileBrowserStoredPasswordRefused` | controller/internal/stacks/filebrowser_password.go | is the stored FileBrowser password still valid (v0.321.0, R-1101) | TRUE only on FileBrowser's own 401/403 in the `generated` state; FileBrowser not answering is FALSE (an unproven refusal must not tell the household its password changed). 3 s bound. Web seam: `Server.fbStoredRefused`. |
| `Manager.ResetFileBrowserAdminPassword` | controller/internal/stacks/filebrowser_password.go | FileBrowser's admin password without the old one (v0.320.0) | Only in `operator`; holds `infraMu` (the base tick must not start the stopped server), and the web handler holds `fileBrowserMu` (the mount sync must not recreate it). Stop → `docker run --rm --volumes-from filebrowser … set -u admin,<pw>` → start (detached from the request's cancel) → verify → store encrypted. TRAP: the CLI opens the DB with no lock timeout — never run it against a running server. Seam: `fbSetPassword`. |
### Storage registry + mount detection
@@ -152,6 +229,10 @@
| `registerStoragePath` | controller/internal/web/storage_handlers.go | `(where, label, setDefault) error` | Post-enroll registration | The single funnel used by init/attach/manual-add |
| `system.IsMountPoint` / `IsWritable` / `PathsOverlap` | controller/internal/system/mounts_linux.go | `(path) bool` | Mount checks | `_other.go` stubs return permissive values — Linux behavior is the real one |
| `system.CheckBackupDestination` | controller/internal/system/mounts_linux.go | `(path) DestinationHealth` | Tier2/offbox target vetting | Detects same-physical-device (`SamePhysicalDevice`) |
| `backup.Manager.diskRelation` | controller/internal/backup/backup.go | `(a, b) (DiskRelation, reason)` — DiskDifferent / DiskSame / DiskUnknown | EVERY Tier-2 or one-copy "same disk?" decision (v0.317.0, felhom.eu `16-storage-physical-identity.md` §4) | Asks the host agent (`SetDiskJudge`, FeatureSameDisk ≥ agent 0.157.0); an older agent or an `unmapped` path → the old `sameDevice`/st_dev rule. Never call `sameDevice` directly for a Tier-2 decision — st_dev calls two partitions of one disk "different". Pinned by diskrelation_test.go |
| `web.Server.ensureInternalPlace` / `refuseInternalLifecycle` | controller/internal/web/internal_place.go | drive-gate tick hook; `(w, r, where) bool` | the internal data place (v0.318.0, `16` §5): bind on one tick, register (kind internal, default when none) on the next; refuse eject/decommission/wipe | Only an agent row of kind `internal` is enrolled without a press — a household drive never is. Pinned by internal_place_test.go + stacks d4_needs_drive_test.go (a media app lands on it) |
| `web.Server.offerRelationFor` | controller/internal/web/backup_target_offer.go | `(ctx, drive) offerRelation` (sameDiskFn seam) | the whole-box backup offer's disk question (R-1084) | Older agent → today's offer; an error → no promise. Pinned by r1084_offer_identity_test.go |
| `web.withoutSystemVolume` / `deviceBacksRegistered` / `freePartitions` | controller/internal/web/attach_sources.go, agent_disk_handlers.go | pure filters | R-1082 (sys_drive never offered, by device) and partitions (`16` §6: free parts of an in-use disk stay, the whole disk never; server-side refusal) | Pinned by r1082_sysdrive_hidden_test.go + partitions_test.go |
| `system.ProbeStoragePath` | controller/internal/system/mounts_linux.go | `(path) ProbeResult` | Disconnect detection | — |
| `appexport.DiskFree` | controller/internal/appexport/estimate.go | `(path) int64` | Free bytes for space gates (df-based, 0 on any error) | Exported v0.128.0 for the browser-upload gate; test seam = `web.uploadDiskFree` package var |
| `stacks.ExportDataMounts` | controller/internal/stacks/delete.go | `(composePath, hddPath) []string` | THE .fab-export mount discovery (v0.130.0 C6B-F1) | Unions `${HDD_PATH}` binds + the `${USERDATA_PATH}` ROOT (single `userdata` entry — basename must round-trip the import's `<HDD_PATH>/<subdir>` mapping; NEVER return per-bind userdata subpaths). Containment-deduped. Backup-side `stackAdapter` deliberately does NOT use it |
@@ -168,7 +249,7 @@
| `agentapi.New` | controller/internal/agentapi/client.go | `(endpoint, token, fingerprintHex) (*Client, error)` | Pinned-TLS client to felhom-agent | Leaf-DER SHA-256 pin replaces chain verify; fails closed. Bounded idle pool (leak fix) |
| `Server.agentClient` | controller/internal/web/agent_disk_handlers.go | `() (*agentapi.Client, error)` | THE memoized client accessor | Always use this, never a fresh `agentapi.New` per request (§3) |
| `Server.ProbeAgentChannel` | controller/internal/web/agent_disk_handlers.go | `(ctx) (constructionErr bool, err error)` | Channel health probe | Probes via the PRODUCTION client on purpose (self-heals, mirrors UI) |
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only). Agent re-checks role server-side |
| `Client.FormatDisk` | controller/internal/agentapi/client.go | `(ctx, device, fstype, confirmed, durableID)` | ONLY format/wipe entry | Sentinels: `ErrNeedsConfirmation` (user-data, resubmit confirmed+durableID) / `ErrFormatRefused` (system/backup — operator opsign only) / `ErrNoDurableID` (v0.329.0, R-1069: a blank disk with no durable id, e.g. virtio without a serial — show `disk.err.no_durable_id`). Agent re-checks role server-side. `Client.Decommission` returns `*DecommissionRefusedError{Status 403|409}` for the agent's two policy refusals (R-1063) — ask the agent BEFORE marking anything |
| `Client.EjectDisk` / `Decommission` / `AssignDisk` / `GuestAttach` / `ListCandidates` | controller/internal/agentapi/client.go | disk lifecycle | Delegate ALL disk ops to agent | Controller holds no Proxmox creds — never shell out to disk tools in-guest |
| `Client.AddNetStorage/ListNetStorage/RemoveNetStorage` | controller/internal/agentapi/client.go | NAS mounts (A1) | Network storage | Password passes through to agent's 0600 cred file; controller NEVER persists it |
| `agentapi.StatusError` | controller/internal/agentapi/client.go | `{Path, Code}` typed non-2xx GET error | Distinguishing HTTP statuses from transport errors (`errors.As`) | NEVER string-match agent error text — the capability probe keys on `Code==404` |
@@ -185,34 +266,48 @@
|---|---|---|---|---|
| `Notifier.PushEvent` | controller/internal/notify/notifier.go | `(eventType, severity, message, details)` | Hub events | Async goroutine, 3 attempts/3s backoff. NEW event types MUST be added to hub `allowedEventTypes` or POST /event 400s. **SEVERITY IS AN EXACT WIRE CONTRACT: `{"info","warning","error","critical"}` and nothing else.** The hub silently COERCES any other string to `"info"` (`hub/internal/api/handler.go`, the ingest severity switch) and `severityNotifies` (`hub/internal/notify/dispatcher.go`) emails only warning/error/critical — so a typo'd severity is stored and delivered to NOBODY, with no error anywhere. **`"warn"` is not a severity.** It shipped on `disk_health_degraded` (fixed v0.215.0, R-328) and is STILL live on `app_start_failed` (R-329) |
| `notify.DiskAlert` / `DiskAlertKind` / `DiskAlertKind.Severity()` | controller/internal/notify/notifier.go | `NotifyDiskHealthDegraded(DiskAlert)` | The disk-health alert payload + its five Hungarian message shapes | The notifier owns customer copy — pass a `DiskAlert`, never a pre-formatted string, or Hungarian scatters across packages. `Severity()` is the ONE mapping kind→hub severity and is exported so any package can assert the contract instead of duplicating the literal |
| `Notifier.PushEventHousehold` (v0.309.0, R-521 F7) | controller/internal/notify/notifier.go | `(eventType, severity, message, household, details)` | A producer that renders BOTH languages itself because its sentence carries figures it computed (fillwatch) | Prefer `pushEventMsg` (one key, both renderings) inside notify; this is for a sentence built outside. `household` "" for a Hungarian household. Registered in the R-329 severity contract (`severityArg`) |
| `Notifier.Notify*` convenience methods | controller/internal/notify/notifier.go | typed wrappers (backup/DB/storage/channel/DR…) | Standard events | Add a typed wrapper rather than raw PushEvent calls |
| `report.BuildReport` / `Pusher.Push` | controller/internal/report/builder.go + pusher.go | periodic hub report | Box→hub reporting | ACK carries `config_version` → `ConfigRefresher.Reconcile` |
| `report.Trigger` (`NewTrigger`/`Fire`/`Run`) | controller/internal/report/trigger.go | `Fire()` after a hub-relevant user action | THE out-of-cycle report push (v0.139.0) — fire via `api.Router.reportPushNow` / `web.Server.reportTriggerNow`, both nil-safe | Coalesce-and-eventually-fire (trailing edge; quiet 2s, min spacing 15s). NEVER add retries (Pusher owns them); NEVER reuse the `internal/sync` REFUSE-debounce for hub pushes (a refused fire loses the update until the next cycle). Fire only AFTER a successful local commit |
| `report.SetPendingLogTails` + `buildLogTailsSection` | controller/internal/report/logtail.go | ACK `log_tail_requests` → next report `log_tails` | THE pull-based ACK-flag pattern (hub asks, controller pushes next cycle) — copy for any new hub→box request | Consume-once drain at BuildReport; failed push re-arms from the hub's still-pending request; NEVER add a hub→controller push channel |
| `metrics.FetchContainerLogTail` | controller/internal/metrics/logscanner.go | `(name, tailLines) (string, error)` | Raw per-container `docker logs --tail=N` | 15s timeout; caller caps/redacts (capTailLines) |
| `ConfigRefresher.Reconcile` | controller/internal/report/config_refresh.go | `(ackVersion int)` | Pull-based config refresh | Re-pulls controller.yaml (re-merging local_api), then graceful self-restart; first-run = baseline, no restart |
| `offsiteapply.HubRegistrar` / `HubWindowClient` / `PinnedProber` (v0.289.0, decisions 68–69) | controller/internal/offsiteapply/seams.go | `Register(ctx,pub)(fp,err)` · `Confirm` · `MoveAside` · `Open/Close` window · `Probe(ctx,host,user,port,kh,privPEM) bool` | EVERY off-site key install, the hub move-aside, the clean-up window | **The box never handles the sub-account password** — there is no consume path any more. `PinnedProber` is a POSITIVE observable (exit 0 + rclone output); "authenticates" is NOT enough — an unpinned key authenticates and can delete. |
| `Manager.offsiteWindowRetention` + `offsiteGuard` (v0.289.0, R-822) | controller/internal/backup/offbox_window.go | `(ctx, base, env, why)` · pure `(all, plan, now, newestAllowed, max) (ids, refuse)` | THE retention step for both callers (after a run, over quota) | Pinned tier: no window → nothing deleted; the guard runs BEFORE any forget; "young" = fewer than `keepDaily` CALENDAR days old (v0.294.0, R-867 — the line is built from the same constants as `retentionPolicy`; never an hour count); a young snapshot superseded the same day is EXCLUDED, any other young removal / future date / plan above `max_remove` REFUSES; tests run restic 0.14.0's policy itself (`restic0140Plan`, proven identical to the binary); forget is by explicit ids, oldest first. A due abandonment goes to `OffsiteAbandonClient` (hub, 7-day wait). NAS tier: the old SP-2 policy, unchanged. |
| `offsiteapply.SettleProvider` / `SettleFunc` / `Bridge.AwaitSettle` / `ReconcileWhenSettled` (R-71a, v0.162.0) | controller/internal/offsiteapply/offsiteapply.go + seams.go | `SettleState() (version, floor string, updateRunning, floorKnown bool)` | THE settle-gate: defers the offsite one-time-password consume past a managed day-0 floor-update (the F10 race). Wire the `SettleFunc` adapter over `updater.GetFloor()`/`IsUpdateRunning()` — **the updater's knowledge is the ONE floor source; never fetch the floor a second way**. Gate ONLY the bridge goroutine, and only when an updater exists (nil `Settle` = reconcile immediately). Bounds `settlePoll`/`settleFloorSubBound`/`settleOverallBound`; the floor is in-memory (report-ACK-derived, ~5–10 s), NOT persisted → unknown until the first ACK on any restart. Inject `Now`/`Sleep` in tests (no real sleeps). B′: at/above-floor GOes on the first poll, zero wait. Do NOT touch the consume/persist order or the 404 contract — ordering only |
| `bootstrap.MaybeIngest` / `RefreshConfig` | controller/internal/bootstrap/bootstrap.go | bootstrap.json → controller.yaml | Day-0 + refresh | Overwrites controller.yaml, NEVER settings.json |
| `api.GracefulSelfRestart` | controller/internal/api/selfrestart.go | `(logger)` | Controller self-restart | Detached exit; bootstrap unit re-runs the image |
| `settings.NotificationGroups` + `NotificationGroupState` / `NotificationPrefs.GroupState` (R-388 slice 1, v0.309.0) | controller/internal/settings/notif_groups.go | `(enabled []string, key) "on"\|"off"\|"custom"` | THE `08` §8.1 group membership of every customer-switchable detector — the notification page's three switches | A group is a VIEW over `enabled_events`, never a stored field. A new detector with a page switch MUST join exactly one group (`TestR388_EveryPageSwitchIsInExactlyOneGroup` fails otherwise). The save rule lives in `Server.applyNotificationGroupSwitches`: only a MOVED group switch overrides the per-detector switches — an untouched „egyedi" group is saved back unchanged |
| `Settings.AddPendingEvent/DrainPendingEvents` | controller/internal/settings/settings.go | offline event queue | Events while hub unreachable | — |
| `Manager.SetUnitNotify` + `UnitSpace` (R-158/R-167, v0.191.0) | controller/internal/backup/recovery_unit.go | `(func(stack string, err error, *UnitSpace))` | THE per-app Tier-1 recovery-unit capture failure alert — fires PER APP from `captureAllRecoveryUnits`, loop continues | **OPERATOR-TIER** (`recovery_unit_capture_failed`, in the hub's `operatorOnlyEvents`). **NEVER route it to `backup_failed`** — that type is in `DefaultEnabledEvents` and carries Hungarian copy, so it emails the CUSTOMER about a failure they cannot act on (D-c; R-158's own proposal said `backup_failed` and D-c overrides it). `UnitSpace` is **nil when the target filesystem is unreadable** and renders as *"unavailable"*, never as zeros — "0 GB free" and "we could not look" are opposite diagnoses. No controller-side cooldown: the hub owns it |
| `Manager.beginRunSummary` / `noteFailure` / `noteAttempted` / `emitRunSummary` / `SetRunSummaryNotify` (R-182, v0.194.0) | controller/internal/backup/runsummary.go | `(kind, runID) func()` / `(app, leg, reason)` / `(RunSummary)` | **THE per-run operator digest.** One `backup_run_failures` event at the end of a run listing every failed app, its leg and its reason — emitted ONLY when something failed | **The RECORD and the NOTIFICATION are different things and must stay so.** The per-app `recovery_unit_capture_failed` event is the record (hub routes it *record-only*, stored + logged every time); this digest is the notification. Before R-182 one event was both, and did neither: nine arrived, two were mailed, seven vanished before `LogNotification`. **Lifetime is `admissionSet`'s exactly** — absent collector means "no run in flight", never a stale answer. **A refusal is noted ONCE, inside `admitApp` where the verdict is taken**, not at the three legs that consult it: R-181's one-verdict-covers-all-three contract makes per-leg noting produce "2 of 1 apps failed". **Deliberate skips (disconnected / decommissioned) must NEVER be noted** — they have their own alert and a nightly digest about an unplugged drive is an ignored digest. **A clean run emits NOTHING**; silence is safe only because the hub's deadline check (`monitor/deadline.go:396,417`) raises a missed backup from report freshness independently — if that is ever weakened this design loses its footing. **`run_id` is unique per real run** (so the hub's 1-h cooldown cannot collapse a manual run into the nightly one) and **deliberately EMPTY on the periodic refresh sweep**, which must stay under that cooldown or a polled status page becomes a mail flood |
| `Manager.admitApp` / `beginAdmissionRun` / `decideAdmission` / `estimatedWriteBytes` (R-181, v0.193.0) | controller/internal/backup/admission.go | `(stackName) bool` / `() func()` | **THE reserve gate. Call it before ANY per-app backup write** — one verdict per app per run, covering the DB dump, the volume dump and the unit capture (all three write under one per-app root) | **Decided LAZILY at the app's first write, never once at run start** — app A's dump can put app B under the reserve, so a run-start verdict reads a disk that no longer exists. **Never re-decided between an app's own legs**: that is exactly the split R-181 closed (bulk written, capture refused). **Reset per run** via the closer `beginAdmissionRun` returns. **Must sit ahead of `DumpAppVolumesSafe`**, which stops the stack as its first act — a refusal decided inside it has already bounced the app. Fires **exactly one** `unitNotify` per refused app per run. Nil admission set (periodic status refresh) → decides fresh, which is still once per app per sweep. Wiring pinned by an **AST walk** in `TestAdmission_IsWiredIntoEveryProductionWriteLeg`, not `strings.Contains` |
| `Manager.floorVerdict` + `FloorUsedPercent`/`FloorFreeGiB` / `ErrCaptureFloor` / `floorReason` (R-165 B2 v0.192.0, size term R-181 v0.193.0) | controller/internal/backup/recovery_unit.go | `(*UnitSpace, estGiB float64) (*UnitSpace, floorReason)` | The pure two-question predicate behind `admitApp`: is the filesystem already below the reserve (`floorHeadroom`), and would THIS app's write take it below (`floorSize`)? | **Headroom is about the FILESYSTEM, never a per-unit cap** — a size cap is R-163 rebuilt inside one volume; the size term bounds the *delta*, not the unit. **REFUSES, never deletes:** nothing here is generational (a unit is one fixed path per app, a DB dump one fixed name), so pruning could only destroy a DIFFERENT app's only local copy — **never repurpose `pruneStalePrimaryDirs`**, which removes ORPHANED dirs from an app that moved drives and has no notion of age. Two terms (97% / 1 GiB) in `fillwatch`'s shape, deliberately BEYOND its critical band (95% / 2 GiB) so the customer is always warned first — pinned by `TestFloorSitsBelowTheCriticalWarningBand`. **`estGiB == 0` degrades to headroom-only on purpose** — refusing an app with no history makes the FIRST backup the one that can never happen. A nil reading neither refuses nor warns (§8.4). Inject `unitSpaceFn` in tests rather than manufacturing occupancy on a real disk |
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space |
| `fillwatch.Watcher` (`New`/`SetNotify`/`Check`) (R-167, v0.191.0) | controller/internal/fillwatch/fillwatch.go | `(statePath, logger, targetsFn, usageFn)` → `Check() error` | THE customer fill warning — warns BEFORE a filesystem fills, per FILESYSTEM (never per app: one full disk holding ten apps would fire ten times) | Emits the **pre-existing** `disk_warning`/`disk_critical` pair, which was allowlisted + copy'd + default-enabled with **no producer in any repo** until now — do NOT mint a new type beside it. **Two threshold terms, whichever trips first** (85% / 5 GiB; critical 95% / 2 GiB) because a percentage alone lies at both ends of this fleet's size range. **Edge-triggered on ESCALATION ONLY**, state persisted; de-escalation is silent and re-arms. Hysteresis dead zone between clear (75% / 7 GiB) and warn — pinned by `TestThresholdsKeepTheirHysteresisGap`. **A nil usage read is NEVER a warning** (§8.4). The hub has **no `customerMessages` entry** for either type on purpose — an entry would override the dynamic message and discard the drive label + free space. **Both languages (v0.309.0, R-521 F7):** the sentence is the bundle's `event.fill.*`, the household's rendering rides as `message_customer` via `Notifier.PushEventHousehold` (`SetLang`; `SetExtra` takes the language); a built-in area carries a `LabelKey`, a household's own drive label is used as written. Hungarian pinned byte-for-byte by `TestMessageHungarianWireText` (v0.309.0 dropped the formal „Kérjük," — R-516) |
### Scheduler / time / UI
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `Scheduler.RunNow` / `scheduler.OnDemand` | controller/internal/scheduler/scheduler.go | `RunNow(name) error`; `OnDemand(ctx) bool` | Run a registered job once now (operator action `run_job`, decision 185) | Refuses unknown and already-running jobs; a job that checks due-ness reads `OnDemand(ctx)` to skip that check on demand |
| `report.OperatorActions` | controller/internal/report/opactions.go | `Reconcile(reply)` → results in the next report | Hub-requested actions in the report reply — the `selftail.go` pattern plus results | The action table is CLOSED and pinned by `TestOpActions_ClosedList`; once per id in memory; every entry must be safe to repeat |
| `web.sessionFingerprint` / `loadSessions` / `saveSessionsLocked` / `saveSessionsRevokingLocked` | controller/internal/web/session_store.go | dashboard sessions on disk (R-35) | Any session lookup | The map is keyed by sha256(cookie), NEVER the cookie — hash with `sessionFingerprint` first; a path that ENDS sessions uses the revoking save |
| `web.endEverySession` / `rotateGateKey` (R-976, v0.324.0) | controller/internal/web/auth.go · controller/internal/web/setup_gate.go | `(why string)` / `()` | A dashboard password change or claim/reset — ANY act that must end every way in | Ends the dashboard sessions, every family session (`family.Store.EndAll`) AND every setup-gate pass / family app cookie (the gate key is replaced; a failed save REMOVES the old key file so a restart cannot bring old passes back). Call this, never `invalidateAllSessions` alone — that ends only the dashboard's. Pinned by `TestR976_PasswordChangeEndsFamilyAndGateSessions` / `TestR976_ClaimResetEndsFamilyAndGateSessions` |
| `web.reserveLoginAttempt` / `reserveAttempt` / `shareReserve` / `familyReserve` / `allowResetRequest` (R-983, v0.324.0; R-1111) | controller/internal/web/auth.go · share.go · family_gate.go · claim.go | `(key) → may go ahead` | Any limiter in front of a slow check (bcrypt, a hub call) | **Check and count in ONE locked step, BEFORE the slow work** — a count taken after the compare lets N parallel tries all pass the check (the shape R-983 removed from the dashboard and family sign-ins, and R-1111 from the share password and the escrow re-auth). A success clears the count afterwards. `reserveAttempt(mu, &map, key)` is the one body of the 5-per-minute password limiters — a new one passes its OWN map to it, never a third shape; the escrow re-auth counts on the login map (`reserveLoginAttempt`), the share password on its own (`shareReserve`). The compare goes through `comparePassword`, so `passwordCompare` (the slow-compare seam) reaches all three |
| `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) |
| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop |
| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" |
| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6); quiesce has its own `budapestLocation` (window gate) — 3rd copy, see §6 |
| `Server.templateFuncMap` | controller/internal/web/funcmap.go | template.FuncMap | ALL template functions | `stateColor` outputs v2 suffixes `run/progress/warn/neutral/off`; stopped = NEUTRAL not red (operator-approved); `stateLabel` copy is frozen byte-identical (unit-tested) |
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
| `bootstrap.Unconfigured` / `WaitForConfig` / `WaitingHandler` (v0.308.0, R-554) | controller/internal/bootstrap/waiting.go | `WaitForConfig(ctx, ln, configPath, cfg, logger, pull, every, version) *config.Config` | The ONLY unconfigured-box path: the bilingual waiting page + a retry of `MaybeIngest` every minute; returns the configured config (nil on stop) | Replaces the deleted setup wizard. `/api/health` stays 200 while waiting; a nil listener still retries |
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input |
| `LogBuffer` + `Lines(maxBytes)` | controller/internal/web/logbuffer.go | ring buffer io.Writer | In-memory log capture for the debug UI + the report `controller_log_tail` source | v0.116.0: ALWAYS constructed (any logging.level) — the logger is `MultiWriter(LevelFilterWriter(stdout, level), ring)`; `Lines` drops OLDEST to honor the byte budget |
| `logx.Debugf/Infof/Warnf/Errorf` | controller/internal/logx/logx.go | `(l *log.Logger, format, args…)` | ALL NEW leveled log lines (the v0.116.0 sweep standard) | routing is the WRITER's job — Debugf always reaches the ring, stdout filters; nil logger = no-op; caller-attributed (Output calldepth 3) |
| `web.LevelFilterWriter` | controller/internal/web/levelfilter.go | `NewLevelFilterWriter(w, minLevel)` | stdout leveling under the always-on ring | untagged lines parse INFO; always reports full length written |
@@ -236,6 +331,7 @@
| Streaming subprocess progress | controller/internal/backup/offbox_progress.go | `offboxStreamRunner` seam (stdout scanned line-by-line, stderr buffered, output tail-bounded) + a PURE line parser + a mutex-guarded published snapshot. Traps it encodes: a source reporting nothing is **normal** (restic sends 0 bytes for a whole incremental run) and the progress source may only update on unit completion — degrade bytes → files → current item + elapsed, never fake a percentage |
| Post-start async verification | controller/internal/stacks/manager.go `logPostStartStatus` | goroutine + sleep, INFO log, never blocks/fails the operation |
| Startup wiring order | controller/cmd/controller/main.go | init-only setters (`SetStackProvider` M2 contract: exactly once, before scheduler/HTTP), scheduler registration block |
| Tabbed page (one subject, several URLs) | controller/internal/web/app_page.go + templates/app_shell.html | ONE data builder for what every tab shows (`appCommonData`), one shell partial (header + page-level banners + tab bar), one page template per tab calling `{{template "app_shell_start" .}}`. Tabs are `<a>` links with `aria-current`, never hidden panels — a link in an alert must land ON a tab and a poller must run on its tab only. The default tab is RENDERED at the bare URL, never redirected to. A key the tab owns (`Page`, `Title`, `TitleKey`, `TitleArgs`, `ActiveTab`, `AppTabs`) is listed in `appPageDataKeysOwnedByTab` so a merged data builder cannot overwrite it |
## 3. Dangerous lookalikes — do NOT reuse
@@ -247,9 +343,11 @@
| `timeAgo` on an RFC3339 string field | Template 500 (OffboxTarget.LastRun bug, fixed v0.96.0) | `timeAgoStr` |
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
| `config.LoadPermissive` | Skips validation — first boot only (customer.id/domain may be unset until `bootstrap.WaitForConfig` gets the configuration, R-554) | `config.Load` everywhere else |
| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
| any settings getter/setter or helper that ends in one (`m.note`, `boxLang`) INSIDE a `settings.UpdateOffboxStatus`/`UpdateCrossDriveStatus` callback (R-578) | The callback runs under the settings WRITE lock and `sync.RWMutex` is not reentrant — it DEADLOCKS holding the lock and wedges settings.json for the box | Resolve the value BEFORE the callback (`lang := m.boxLang()`); `TestR578NothingTakesTheSettingsLockInsideASettingsCallback` (controller/internal/settings/r578_callback_lock_gate_test.go) convicts it in every package |
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back |
## 4. Seams & interfaces (testing + cross-repo)
@@ -257,10 +355,14 @@
|---|---|---|---|
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
| `healthSettle` / `healthInterval` / `healthTimeout` (package vars, R-488) | controller/internal/backup/restore.go | production 3 s / 5 s / 90 s — `waitForHealthy`'s clock, used by every restore path | shortened ONCE in controller/internal/backup/main_test.go `TestMain` (0 / 2 ms / 50 ms; the package went 444 s → ~2 s); `TestR488_HealthWaitDefaultsAreProduction` pins the production values. A new real-clock wait in this package goes behind the same kind of var, not a per-test sleep. Also: `newOffboxManager` installs a failing `SetOffboxSSH` fake, so no backup test spawns a real `ssh` |
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
| `volumeCopier` + `Manager.undoCopier` / `updateUndoHealthFn` (v0.263.0) | controller/internal/stacks/undo.go | nil → `dockerVolumeCopier` (alpine helper: `cp -a` named volume → `<vol>.pre-update-<stamp>`, finished-marker LAST; restore re-checks the marker in the same shell) / nil → `waitUpdateHealthyMeta` with the OLD `.felhom.yml` | `fakeCopier` in controller/internal/stacks/undo_test.go — volume CONTENT as strings, so "the data came back" is a compare; a cut-off copy is a copy without its marker. **Judge a copy by the helper's own exit + the marker, never by the client** (killing `docker run` leaves the container copying — measured) |
| `pgConverter` + `Manager.pgConv` / `convertFreeFn` (v0.273.0) | controller/internal/stacks/pgconvert.go | nil → `dockerPGConverter` (docker exec psql/pg_dumpall over 127.0.0.1 — the entrypoint's temporary init server listens on the socket only; `Empty` re-checks the undo copy's marker in the same helper) / nil → statfs of the stack dir | `fakePG` in controller/internal/stacks/pgconvert_test.go — works on `fakeCopier`'s volume strings. **Never convert without the ladder's `engine_conversion` mark** (`planEngineConversion` refuses a PostgreSQL major move without it); `isPostgresImage` must match `appbackup.dbTypeForImage` (pinned by a source-reading test) |
| `Manager.composeExecFn` / `stacks.setupGateProbeGet` / `Server.gateClock` (v0.280.0) | controller/internal/stacks/manager.go + setup_gate.go · controller/internal/web/server.go | nil → the real compose call / an HTTP GET (5 s) / `time.Now` | controller/internal/stacks/setup_gate_test.go (with a docker STUB on PATH — R-650) · controller/internal/web/setup_gate_test.go |
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
| `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba <args>` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned |
@@ -269,6 +371,7 @@
| `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) |
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
| `Manager.installedExecFn` (func seam, v0.233.0) | controller/internal/stacks/manager.go (field) + installed.go | nil → `defaultExecRunner` (`exec.CommandContext`, 30 s) | the installed-images recorder's ONLY process boundary — `docker compose ps` + `docker inspect` + `docker image inspect` | Deliberately NOT `execFn`/`composeExecCustomEnv`: those are already load-bearing elsewhere and **neither carries a context or a timeout**, and a bookkeeping read must never wedge a lifecycle action. Injected in controller/internal/stacks/installed_test.go, which FAILS the test on an argv it does not recognise, so a change to the commands issued cannot pass silently. The wiring test stubs the compose binary on PATH instead, so `RestartStack` is reached for real |
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
| `Manager.offboxStreamRunner` + `SetOffboxStreamRunner` | controller/internal/backup/offbox_progress.go | nil → `defaultOffboxStreamRunner` (real `restic`, stdout scanned live) | streaming sibling of `offboxRunner`; fakes emit canned `--json` status lines in offbox_progress_test.go, so the whole progress path runs with no restic, network or repo |
| `Manager.offsitePreDumpFn` + `SetOffsitePreDumpFn` (R-44, v0.148.0) | controller/internal/backup/offbox_reconstitute.go (seam) + offbox.go (call site) | nil → `runDBDumpsInternal` under the SAME running flag | THE dumps-before-capture ordering seam. Extracted so the order is observable without Docker/restic — an ordering guarantee no test can see is one refactor from silently reverting to the DIAG-immich-restore-2026-07-19 behaviour. Red-proof: moving the capture first yields `[capture dump]` |
@@ -281,9 +384,16 @@
| `Manager.execFn` (func seam) + `restartPolicyLookup` / `inspectRestartPolicyFn` (R-51, v0.156.0) | controller/internal/stacks/manager.go | nil → real `exec.Command` / `docker inspect -f {{.HostConfig.RestartPolicy.Name}}` | `scriptedDocker` in controller/internal/stacks/degraded_test.go drives the WHOLE production path (docker ps → aggregateState → docker inspect) — an aggregateState-only test proves the function, not the caller. Policy answers are cached per container+state and pruned to the live `docker ps` set; a FAILED inspect is deliberately never cached (a hiccup must not pin a container to "unknown") and reads as SUPERVISED, i.e. fail-closed — the opposite of `IsDownState`'s fail-open, because there the state is ambiguous while here a member is known dead |
| `bootrecon.StackProvider` (R-52, v0.156.0) | controller/internal/bootrecon/bootrecon.go | `*stacks.Manager` (GetStacks/StartStack/RefreshStatus) | `fakeStacks` counts StartStack per app; the load-bearing assertion is the NEGATIVE — a zero-container stack (a UI Stop = `compose down` = containers removed) must record **0** starts, while a boot orphan (containers present, Exited) records exactly 1. `Reconciler.sleep` is injected so the 30 s gap costs nothing |
| `bootReconcileFn` + `runBootReconcile` (package-main seam, v0.156.0) | controller/cmd/controller/main.go | `bootrecon.New(mgr, logger).Run` | controller/cmd/controller/bootrecon_wiring_test.go. **The wiring itself is asserted by an AST walk** over `func main()`, not a `strings.Contains` — the substring version passed its own red-proof because a commented-out call still contains the string. Comments are not callers |
| `crashboot.Probe` / `crashboot.Grace` (R-856) | controller/internal/crashboot/crashboot.go | `crashboot.AgentProbe(agentClient.CrashGuard)` (GET /host/crash-guard) | `fixed(Fact, err)` in crashboot_test.go. The dead-app check gates on `bootGrace.Within(now)` — 90 s normally, `CrashGrace` (15 min) when the HOST's last boot was unclean and within `BootWindow` of the controller start. **Unknown (no agent, 404, no state) is a normal boot** — never a longer silence on a guess. Asserted by AST walk in cmd/controller/r856_wiring_test.go |
| `classifyRunStates` (pure fix-3 derivation, v0.164.0) | controller/cmd/controller/main.go | `([]stacks.Stack, quiesced, failedRestart map[string]bool, now time.Time)` → `(dead []web.DeadApp, states []notify.AppRunState)` | classify_runstates_test.go. **THE single fix-3 rule: down = `(IsDownState(st.State) || st.CrashLooping(now)) && !userStopped && !quiesced`.** **`quiesced` is a UNION of TWO suppression sets** (R-330, v0.224.0): `quiesce.Loop.SuppressedStacks()` (whole-guest vzdump/PBS) and `backup.AppStopGuard.SuppressedStacks()` (per-app volume dump / offbox reconstitute / `.fab` export), merged by `unionSuppressed` in `scanDeployedAppRunStates`. **Adding a third way to stop an app means adding its set here** — R-330 was 61 false customer e-mails caused by exactly that omission, with a working suppressor sitting three lines away. C9-F2 (v0.183.0) added the crash-loop term: `restarting` is NOT in `IsDownState` and must not be — adding it alarms on every deploy and update fleet-wide — so a SUSTAINED restarting run (`stacks.crashLoopAfter` = 5 m, above the 120 s deploy timeout, Mealie's 60 s start_period AND R-97b's 180 s grace) becomes down instead. `now` is injected so the threshold is a testable contract. A deliberate UI stop (`compose down` → zero containers → StateStopped, I1) must not alarm — banner OR email — while faults (Exited/Degraded) alarm byte-identically; I2 (P2 census: all catalog services `unless-stopped`) is why a crash never rests at stopped. **Do NOT touch `IsDownState`** (other callers rely on stopped=down) and do NOT filter in `buildDeadAppAlerts`/`NotifyAppStartFailures` — one derivation point. If I1 or I2 changes, revisit the suppression |
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
| `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build |
| `upwatch.Source` / `upwatch.Explainer` / `Watcher.Now` (v0.342.0, the upload watch) | controller/internal/upwatch/ | `DockerAPI` (Engine API on the socket, exact counters) / the qBittorrent, Transmission and Plex clients | `fakeSource`, `fakeApp` and a minute clock in watch_test.go drive the WHOLE judge path; the app clients are tested against httptest servers that behave as measured on 9202. **Exact counters come from the Engine API, never from `docker stats` text** (three significant digits) |
| `upwatch.QBittorrent.SetUploadLimit` + `ApplyQBLimit` | controller/internal/upwatch/qblimit.go | the one writer of qBittorrent's global upload limit | compare against the value qBittorrent STORED (KiB/s rounding), never the requested one; a limit the household touched is theirs for good |
| `upwatch.Service.Measured` / `RefreshMeasured` / `MeasuredLine` + `agentapi.Client.LineTest` (R-1247) | controller/internal/upwatch/service.go · controller/internal/agentapi/linetest.go · cmd/controller/upwatch_wiring.go `agentLineTest` | GET /host/line-test (agent ≥ 0.164.0): the host's weekly iperf3 line test | **qBittorrent's limit reads `MeasuredLine` only — never `Line()`** (the push floor is a far lower bound; D41). `Line()` = the higher of the two, for the watch. Read at start + every 15 min; a failed read KEEPS the last value; 404 / present:false = not measured. Pinned by `TestService_QBLimitFromTheTestedLineOnly` |
| `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address |
| `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same |
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
| `netProbeReadBack` (package var) | controller/internal/web/netprobe.go | `os.ReadFile` | overridden in TestNetProbeChild (nonce-tamper + cleanup-fail rows); package var because the child is a RE-EXEC'd process in production |
| `system.ClassifyPathFS(Timeout)` + `netProbeFSClass` / `Server.classifyFSPath` / `Router.classifyFSPath` | controller/internal/system/fsclass*.go (+ web/netprobe.go, web/server.go, api/router.go seams) | statfs f_type → network/autofs/stub/unknown in THIS namespace (RCA fix 2) | idle autofs = HEALTHY, never force-mount; unknown = fail OPEN; seams injected in netprobe_stub_test.go / networkstub_test.go / deploygate_test.go |
@@ -310,6 +420,7 @@ Cross-repo edges:
## 5. Extension points (where new features plug in)
- **New storage web endpoint**: switch in `ServeStorageAPI` (controller/internal/web/storage_handlers.go); disk ops in `ServeDiskAPI` (controller/internal/web/agent_disk_handlers.go); backup in `ServeBackupAPI`; export in `ServeExportAPI`; debug in `handleDebugAPI` (debug-mode gated).
- **A gate that must REPORT and never REFUSE (R-404, 2026-09-01)**: register it in `controller/scripts/controller_gates.py` with the fifth field `blocking=False` — its exit code then never reaches the runner's verdict and it prints as `ADVISORY`. `golden_notice.py` is the only one, and `test_golden_notice.py` asserts it stays the only one. **Use this instead of giving a notice the power to refuse a push**: the golden notice must fire at the moment a release is committed, when the golden legitimately cannot exist yet. A cross-repo gate IMPORTS the sibling's script (see `golden_notice.py` loading `felhom.eu/scripts/golden_currency_gate.py`) and never copies it — a copy recreates the drift these gates exist to detect.
- **New debug-page control (R-400, v0.228.0)**: the control in `controller/internal/web/templates/debug.html` AND the `subpath == "…"` case in `controller/internal/web/handler_debug.go` are ONE change — `controller/scripts/debug_route_gate.py` fails on either half alone, in both directions (a reference with no case, and a case with no reference). Keep the dispatcher's EXACT-match switch with its `http.NotFound` default; a prefix match is what would have hidden the original defect. Handler shape: `debugTriggerDBDump` for a fire-and-forget trigger, `debugRunIntegrityCheck` for one the operator pressed to learn an ANSWER (synchronous). **Before this gate the page referenced 24 addresses and 17 were answered, and three of the seven dead ones fetched on page LOAD** — those panels were permanently blank on the page an operator opens when something is already wrong.
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
- **New background job**: `sched.Every`/`sched.Daily` registration block in controller/cmd/controller/main.go.
@@ -323,21 +434,24 @@ Cross-repo edges:
- **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
- **New startup self-check**: append check fn in `selftest.Run` (controller/internal/selftest/selftest.go).
- **Converting a template to the bundle (i18n, v0.247.0)**: `python3 controller/scripts/i18n_extract.py <template>` moves every Hungarian run into `hu.json` and leaves `{{T "key"}}`; then **review what it LEFT** (it matches Hungarian by accented letter plus an ASCII word list — eight ASCII-only strings were missed on the first three pages), add the English to `en.json`, add fixture states for the page to `i18n_parity_test.go` **captured from the unconverted template first**, and run `controller/scripts/i18n_missing_gate.py`. Copy gates judge the page through `controller/scripts/i18n_bundle.py` (`read_template(path, lang)`), never the raw template.
- **New settings field**: struct + accessor pair in controller/internal/settings/settings.go following the Lock→mutate→save pattern.
- **A value asked AFTER the app starts (v0.315.0)**: add a `DeployField` with `after_start: true` to the catalog template AND an entry to `afterStartRegistry` in `controller/internal/web/after_start.go`, keyed `slug + "/" + ENV_VAR`, with `Needed(stack) bool` and `Apply(s, r, stack, value) error`. **The catalog may never carry the action** — not a command, not a URL, not a container to exec: the household's text is an argument to a function in this repository, which is the whole point of the design (D2 = A). Both halves are required and each is checked: a declared field with no entry renders no card and 404s its POST; an entry whose field the catalog does not declare also 404s (`TestAfterStart_RegistryAloneIsNotAnInvitation` — that second half was green and unpinned until a red-proof showed a POST for an undeclared field reaching Plex). `Apply` returns `*afterStartError{Status, MsgKey}` to show a sentence of its own inline, and **never logs the value** (it is a credential).
- **A tab on the app page (v0.315.0)**: a `case` in `appPageHandler`'s switch, a `render<Tab>Tab` that adds only its own keys to `appCommonData`'s map, a template starting with `layout_start` + `app_shell_start`, and the tab's key in `appTabOrder` with a literal `app_page.tab_<key>` locale key resolved in `appTabLinksLang`. **Resolve the label in Go, not with a composed `{{T (printf …)}}`** — a composed key is invisible to `TestI18nParityCoversEveryMarker` and a missing translation would render the key to the household.
## 6. Known duplication (observed — NOT fixed)
| Duplication | Locations |
|---|---|
| Atomic write ×4 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/setup/handlers.go `atomicWriteFile`; controller/internal/api/router.go `writeConfig0600` |
| String truncation ×3 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based, widely used); controller/internal/backup/offbox.go `truncate` |
| Atomic write ×3 (+inline in settings.save) | controller/internal/backup/recovery_unit.go `atomicWrite`; controller/internal/bootstrap/bootstrap.go `writeFileAtomic`; controller/internal/api/router.go `writeConfig0600` (the setup wizard's `atomicWriteFile` went with it, R-554) |
| String truncation ×4 | controller/internal/util/strings.go `TruncateStr` (rune-safe, 1 caller); controller/internal/stacks/manager.go `truncateStr` (byte-based HEAD, widely used) and `tailStr` (rune-safe TAIL, v0.294.0 R-864 — use it for a command's stderr: the error is printed last); controller/internal/backup/offbox.go `truncate` |
| humanizeBytes ×3 | controller/internal/appbackup/appdata.go `HumanizeBytes` (canonical) + private twin; controller/internal/appexport/estimate.go `humanizeBytes`; controller/internal/backup/appbackup_bridge.go wrapper (deliberate bridge) |
| copyFile ×2 | controller/internal/stacks/migrate.go (returns bytes) vs controller/internal/appexport/export.go |
| dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` |
| timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) |
| CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled |
| Budapest timezone loader ×3 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
| Budapest timezone loader ×4 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` vs controller/internal/api/kept_install.go `budapest` (v0.277.0, the kept choice's dates) (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) |
| JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` |
| Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) |
| DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` |
| qBittorrent Web API login ×2 | controller/internal/mediawire/mediawire.go `qbLogin` (writes the drive wiring) vs controller/internal/upwatch/explain.go `QBittorrent.login` (reads every minute; keeps its session cookie, which mediawire's per-run login does not need) |
| compose exec ×2 | controller/internal/stacks/manager.go `composeExecCustomEnv` vs controller/internal/appexport/restore.go `composeExecEnv` (the latter has ctx+timeout; the former has the userdata belt) |
+5 -1
View File
@@ -41,4 +41,8 @@ restic-password
# App assets (synced from felhom.eu website at build time)
assets/*.svg
assets/*.png
assets/*.webp
assets/*.webp
# build.sh's scratch directory. It is created beside the script, so a build run from a CLONE of
# this repo (which is what the clean-tree gate pushes you to when the shared tree is busy) would
# otherwise dirty the very tree the gate is checking. Generated output, never committed.
workspace/
+65 -33
View File
@@ -5,12 +5,12 @@
# =============================================================================
# --- Build stage ---
FROM golang:1.24-bookworm AS builder
# R-972 (v0.322.0): a supported, patched Go, pinned by version AND digest (no moving tag). Go 1.24 had no security fixes
# left (go.dev/doc/devel/release#policy). The builder's Debian does not reach the runtime (a static CGO-free binary).
FROM golang:1.27.2-trixie@sha256:47667934874913a449b96faa7ef4777fa285d9ec29126ee80ed61c45b7cccf0e AS builder
ARG TARGETOS=linux
ARG TARGETARCH
ARG VERSION=dev
ARG GIT_COMMIT=unknown
WORKDIR /build
@@ -24,6 +24,13 @@ COPY . .
# Generate go.sum and ensure all deps are fetched
RUN go mod tidy
# R-208: the per-build values are declared HERE, just above the only step that reads them. A RUN's
# cache key includes the stage's ARG environment, so declared above `go mod download` a fresh VERSION
# re-ran the module download on every build (208 cache records, each used once, ~440 MB apiece).
# Pinned by TestR208_DockerfileVersionArgsSitBelowModuleDownload.
ARG VERSION=dev
ARG GIT_COMMIT=unknown
# Build static binary
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-ldflags="-s -w \
@@ -33,48 +40,73 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
-o /build/felhom-controller \
./cmd/controller/
# --- Runtime stage ---
FROM debian:bookworm-slim
# --- restic: the official release binary, pinned by version AND checksum (R-1105, v0.323.0) ---
# Never again „whatever the OS ships": Debian 12 → 13 would have swapped restic 0.14.0 → 0.18.0 under the off-site code
# without anyone choosing it. The version below is a decision; moving it is a re-proof (07-backup-architecture.md §6.8).
# Three checks, each fails the build: restic's SHA256SUMS carries a good signature from restic's release key (vendored
# in third_party/restic/, fingerprint CF8F 18F2 8445 7597 3F79 D4E1 91A6 868B D3F7 A907 — no keyserver at build
# time); the download matches the published sum; and it matches the sum pinned HERE, so even a re-signed release
# cannot swap the binary. Pinned by TestR1105_ResticIsPinnedOutsideTheOS.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f AS restic
ARG TARGETARCH
ARG RESTIC_VERSION=0.19.1
ARG RESTIC_SHA256_AMD64=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c
ARG RESTIC_SHA256_ARM64=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465
COPY third_party/restic/restic-release-key.asc /tmp/restic-release-key.asc
RUN set -eu; \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg gpgv bzip2; \
case "${TARGETARCH}" in \
amd64) want="${RESTIC_SHA256_AMD64}" ;; \
arm64) want="${RESTIC_SHA256_ARM64}" ;; \
*) echo "no pinned restic checksum for architecture '${TARGETARCH}'" >&2; exit 1 ;; \
esac; \
f="restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \
url="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}"; \
cd /tmp; \
curl -fsSL -o "$f" "$url/$f"; curl -fsSL -o SHA256SUMS "$url/SHA256SUMS"; curl -fsSL -o SHA256SUMS.asc "$url/SHA256SUMS.asc"; \
gpg --dearmor < /tmp/restic-release-key.asc > /tmp/restic-release-key.gpg; \
gpgv --keyring /tmp/restic-release-key.gpg SHA256SUMS.asc SHA256SUMS; \
grep " ${f}\$" SHA256SUMS | sha256sum -c -; \
echo "${want} ${f}" | sha256sum -c -; \
bunzip2 -c "$f" > /usr/local/bin/restic; chmod 0755 /usr/local/bin/restic; \
/usr/local/bin/restic version | grep -q "^restic ${RESTIC_VERSION} "
# Install runtime dependencies:
# - docker-cli: for "docker compose" commands
# - ca-certificates: for HTTPS (healthchecks pings, git)
# - postgresql-client: for pg_dump
# - default-mysql-client: for mysqldump
# - sqlite3: for SQLite backup
# - git: for stack sync from Gitea
# - curl: for health pings and debugging
# - util-linux: lsblk, blkid, mount (storage init)
# - fdisk: sfdisk (partition table writing — separate from util-linux on Debian)
# - e2fsprogs: mkfs.ext4 (filesystem formatting)
# - rsync: for data migration between storage paths
# - parted: partprobe (partition table re-read after sfdisk)
# - restic: encrypted off-box (NAS) backup over SFTP (Part B; version pinned by the Debian release)
# - openssh-client: restic's sftp backend shells out to `ssh` (the SFTP transport); also ssh-copy-id/sftp
# for the SLICE-2 offsite apply-bridge key install
# - sshpass: feeds the one-time storage-box password to ssh-copy-id -s -f (offsite apply-bridge)
RUN apt-get update && apt-get install -y --no-install-recommends \
# --- Runtime stage ---
# Debian 13 (R-1105, v0.323.0), pinned by point release AND digest. It waited on Debian 12 until the off-site code's
# restic assumptions were re-proven on 0.18 (07-backup-architecture.md §6.8); restic itself no longer comes from here.
FROM debian:13.7-slim@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
# Install runtime dependencies — each one is a program the controller binary runs (R-1105 re-checked every exec):
# - docker-cli + compose plugin: every stack operation (below)
# - ca-certificates: for HTTPS (healthchecks pings, git, the hub)
# - curl: the image's HEALTHCHECK, and the docker repo key below
# - gnupg: dearmors docker's repo key below
# - git: catalog sync
# - util-linux: findmnt (mount reads)
# - rsync: data migration between storage paths
# - openssh-client: restic's sftp/rclone transports shell out to `ssh`; the off-site key probe
# v0.323.0 (R-1105) REMOVED, because the binary never runs them: postgresql-client and default-mysql-client (every
# dump and import runs INSIDE the app's own database container — `docker exec`, appbackup/dbdump.go), sqlite3,
# fdisk, e2fsprogs, parted (disk work moved to the host agent in v0.37.0), sshpass (the ssh-copy-id key install it
# fed was replaced by the hub's key registrar in v0.289.0). Fewer packages, fewer known holes.
RUN apt-get update && apt-get upgrade -y --no-install-recommends && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
git \
postgresql-client \
default-mysql-client \
sqlite3 \
util-linux \
fdisk \
e2fsprogs \
rsync \
parted \
restic \
openssh-client \
sshpass \
&& rm -rf /var/lib/apt/lists/*
# Install docker-cli (without daemon)
COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic
# Install docker-cli (without daemon). The repo's codename comes from the image's own /etc/os-release, never a
# hard-coded name (a hard-coded `bookworm` would have kept Debian 12's packages on a Debian 13 base).
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \
&& . /etc/os-release \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \
https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
+1342 -198
View File
File diff suppressed because it is too large Load Diff
+106 -13
View File
@@ -12,11 +12,18 @@
# ./build.sh 0.1.0 # Build with version tag
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
# ./build.sh 0.1.0 --print-tags # Print the tags a build would carry, and stop (the R-1143 test)
#
# :latest is carried ONLY by a release tag (x.y.z). An -rc, dev or any other tag never moves it (R-1143).
# =============================================================================
set -euo pipefail
# --- Configuration (edit these if your paths differ) ---
REPO_DIR="/mnt/5_hdd/felhom.eu/git/felhom-controller"
# The default is the shared DooPlex clone, and the release runbook names it. It is overridable
# because that clone is shared: when another session has work in progress in it, the clean-tree gate
# rightly forbids building over them, and the way to ship is a clean clone at the pushed commit —
# not stashing somebody else's work. Set FELHOM_REPO_DIR to that clone.
REPO_DIR="${FELHOM_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom-controller}"
CONTROLLER_SRC="${REPO_DIR}/controller"
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
REGISTRY="gitea.dooplex.hu/admin"
@@ -37,6 +44,21 @@ warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
# --- The tags this build carries (R-1143, 2026-10-18) ---
# Until v0.328.0 every build moved :latest, release candidates included (rc.1–rc.5 each did on 2026-10-17). Nothing
# current pulls :latest — the update button takes the highest x.y.z tag — but a legacy-shaped box following it would
# have taken an rc. So :latest moves with a RELEASE only. Pinned by scripts/test_build_push_once.sh.
TAGS=("${IMAGE}:${VERSION}")
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TAGS+=("${IMAGE}:latest")
fi
TAG_ARGS=()
for t in "${TAGS[@]}"; do TAG_ARGS+=(-t "$t"); done
if [[ "${ACTION}" == "--print-tags" ]]; then
printf '%s\n' "${TAGS[@]}"
exit 0
fi
# --- Pre-flight checks ---
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
error "Controller source not found: ${CONTROLLER_SRC}"
@@ -61,11 +83,90 @@ info "║ felhom-controller image builder ║"
info "╚══════════════════════════════════════╝"
info "Version: ${VERSION}"
info "Commit: ${GIT_COMMIT}"
info "Repo: ${REPO_DIR}"
info "Source: ${CONTROLLER_SRC}"
info "Build dir: ${BUILD_DIR}"
info "Image: ${IMAGE}:${VERSION}"
echo ""
# =========================================================================
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
# =========================================================================
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
# the first and the last runs different code under the same number — and will never fetch the fix,
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
# the build stopped it. This does.
#
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
#
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
# (scripts/test_build_push_once.sh).
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
step "0/4 — Checking the registry: is ${VERSION} already published?"
# `set -e` is on, and a failing command substitution would abort the script before the rc is
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
# keeps the failure for us to classify; that classification is the whole check.
probe_rc=0
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
if [[ ${probe_rc} -eq 0 ]]; then
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
error "A version number is pushed once. Two images under one number mean a box can run"
error "either of them and never learn the difference. If this is a fix for something already"
error "pushed, release it as the next patch version instead."
exit 1
fi
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
# unanswered question is a refusal.
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
error "The registry answered: ${probe_out}"
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
exit 1
fi
info "${VERSION} is not present in the registry — proceeding."
# AND THE TREE IT BUILDS FROM MUST BE CLEAN AND PUSHED.
#
# Until v0.316.3 this was a sentence in a runbook, and a rule without a mechanism is a wish. It
# cost a bad push the same evening it was written down: the shared clone had 31 files of another
# session's work in progress, and a build from it put their unreleased code into a published
# image under my version number. Nobody deployed it; that was luck, not a control.
#
# Two questions, both of which must be YES:
# is every tracked file committed? — otherwise the image holds something git does not
# is HEAD what origin/main has? — otherwise the image holds something nobody can fetch
# „An unpushed change does not exist" (workspace CLAUDE.md), and an image built from one is a
# thing that exists and cannot be reproduced.
if [[ -d "${REPO_DIR}/.git" ]]; then
dirty=$(cd "${REPO_DIR}" && git status --porcelain 2>/dev/null | wc -l)
if [[ "${dirty}" -ne 0 ]]; then
error "${REPO_DIR} has ${dirty} uncommitted change(s) — refusing to push a build from it."
error "An image built from a dirty tree cannot be reproduced, and on a SHARED clone it"
error "may carry work that is not yours. Commit and push, or build from a clean clone"
error "with FELHOM_REPO_DIR pointing at it."
(cd "${REPO_DIR}" && git status --porcelain | head -10 | sed 's/^/ /')
exit 1
fi
head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown)
main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown)
if [[ "${head_sha}" != "${main_sha}" ]]; then
error "${REPO_DIR} is not at origin/main — refusing to push."
error " HEAD ${head_sha}"
error " origin/main ${main_sha}"
error "An unpushed change does not exist; an image built from one cannot be fetched again."
exit 1
fi
info "Source tree is clean and at origin/main."
else
error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified."
exit 1
fi
fi
# =========================================================================
# Step 1: Prepare clean build workspace
# =========================================================================
@@ -160,14 +261,10 @@ BUILD_ARGS=(
case "${ACTION}" in
--push)
info "Building for current platform + pushing..."
docker build "${BUILD_ARGS[@]}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
info "Pushing..."
docker push "${IMAGE}:${VERSION}"
docker push "${IMAGE}:latest"
for t in "${TAGS[@]}"; do docker push "$t"; done
;;
--multiarch)
@@ -183,18 +280,14 @@ case "${ACTION}" in
docker buildx build "${BUILD_ARGS[@]}" \
--platform linux/amd64,linux/arm64 \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
"${TAG_ARGS[@]}" \
--push \
.
;;
*)
info "Building for current platform (local only)..."
docker build "${BUILD_ARGS[@]}" \
-t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:latest" \
.
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
;;
esac
@@ -0,0 +1,61 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.279.0 (decision 45): the deploy-done hook — the ONE place a fresh install ends — runs after_install, and
// the hook is set on every box, not only inside `if notifier != nil` (a box with no hub still installs apps).
// COMPANION RED-PROOF: move SetDeployDoneHook back inside `if notifier != nil` → the second assertion fails;
// drop the RunAfterInstall call → the first fails.
func TestAfterInstall_IsWiredToEveryFreshInstall(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var hook *ast.CallExpr
var insideNotifierIf bool
var walk func(n ast.Node, underNotifier bool)
walk = func(n ast.Node, underNotifier bool) {
ast.Inspect(n, func(x ast.Node) bool {
if ifs, ok := x.(*ast.IfStmt); ok {
if be, ok := ifs.Cond.(*ast.BinaryExpr); ok {
if id, ok := be.X.(*ast.Ident); ok && id.Name == "notifier" && be.Op == token.NEQ {
walk(ifs.Body, true)
if ifs.Else != nil {
walk(ifs.Else, underNotifier)
}
return false
}
}
}
if c, ok := x.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && hook == nil {
hook, insideNotifierIf = c, underNotifier
}
}
return true
})
}
walk(f, false)
if hook == nil {
t.Fatal("SetDeployDoneHook is never called")
}
calls := false
ast.Inspect(hook, func(x ast.Node) bool {
if s, ok := x.(*ast.SelectorExpr); ok && s.Sel.Name == "RunAfterInstall" {
calls = true
}
return true
})
if !calls {
t.Fatal("the deploy-done hook does not run RunAfterInstall")
}
if insideNotifierIf {
t.Fatal("SetDeployDoneHook is set only when a notifier exists — a box with no hub would never run after_install")
}
}
@@ -135,7 +135,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
body := mainBody(t)
names := callsInMain(t, body)
if indexOfCall(names, "NotifyBackupFailed") < 0 {
if indexOfCall(names, "NotifyInterruptedOperation") < 0 {
t.Fatal("func main() no longer reports an interrupted app-data operation to the operator — the " +
"controller died mid-backup and nobody is told (§2.4)")
}
@@ -144,7 +144,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
//
// R-174 STRENGTHENED THIS. `!= nil` alone is no longer sufficient, because Recover now returns a
// non-nil result for a recovery that merely REFUSED starts (an absent data drive) — the drive
// gate working as designed. `NotifyBackupFailed` sends `backup_failed`, which is customer-enabled
// gate working as designed. `NotifyInterruptedOperation` (R-585; was `NotifyBackupFailed`) sends `backup_failed`, which is customer-enabled
// by default (settings.DefaultEnabledEvents), so a nil-only guard would email the customer
// "A biztonsági mentés sikertelen!" about an app nothing is wrong with. The guard must consult
// Alarming().
@@ -156,7 +156,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
}
carries := false
for _, name := range callsInMain(t, ifst.Body) {
if name == "NotifyBackupFailed" {
if name == "NotifyInterruptedOperation" {
carries = true
}
}
@@ -188,7 +188,7 @@ func TestMainReportsTheInterruptedOperation(t *testing.T) {
if !guardedByAlarming {
t.Fatal("the interrupted-operation alert is not guarded by appStopRecovery.Alarming() — a " +
"recovery that only REFUSED starts (drive absent) would be reported through " +
"NotifyBackupFailed, a customer-enabled event type, telling the customer their backup " +
"NotifyInterruptedOperation (backup_failed), a customer-enabled event type, telling the customer their backup " +
"failed when the drive gate was simply doing its job (R-174)")
}
}
@@ -493,3 +493,30 @@ func assignedIdentsIn(body *ast.BlockStmt) []string {
})
return names
}
// R-1251: the guard's hold store and drive question are wired BEFORE Recover (a waiting put-back would otherwise be
// read as „gone" and nothing would hold the app), and the waiting put-backs are resumed after it. COMPANION
// RED-PROOF (2026-10-24): delete SetHolds or SetUndoReachable, or move either below Recover → this fails.
func TestR1251_MainWiresThePutBackWait(t *testing.T) {
names := callsInMain(t, mainBody(t))
rec := indexOfCall(names, "Recover")
for _, n := range []string{"SetHolds", "SetUndoReachable"} {
if i := indexOfCall(names, n); i < 0 || i > rec {
t.Errorf("func main() must call appStopGuard.%s before Recover (at %d, Recover at %d)", n, i, rec)
}
}
if i := indexOfCall(names, "ResumeWaitingPutBacks"); i < rec {
t.Errorf("func main() must resume the waiting put-backs after Recover (at %d, Recover at %d)", i, rec)
}
}
// R-1252: the second-drive copy asks the stack adapter (by an optional interface) whether a block was REJECTED. An
// adapter that lost the method would make every rejected block read as absent — quiet, with no alarm. Pinned at
// compile time here; the decision is tested in internal/backup.
var _ interface{ BackupBlockRejected(string) string } = (*stackAdapter)(nil)
func TestR1252_MainWiresTheUnreadableSettingsSignal(t *testing.T) {
if indexOfCall(callsInMain(t, mainBody(t)), "SetTier2SettingsUnreadableNotify") < 0 {
t.Fatal("func main() no longer wires SetTier2SettingsUnreadableNotify — a rejected block would keep the copies silently")
}
}
@@ -0,0 +1,13 @@
package main
import "testing"
// TestConvert_ReleaseIsWiredAtStartup — v0.273.0 (`09` §6.4 part 10, B5): the kept pre-conversion datadir
// copy is released only by ReleaseConversionCopies, so main.go must CALL it (the seam-built-but-never-wired
// class). COMPANION RED-PROOF (REPORT.md): delete the `conversion-copy-release` job — this fails.
func TestConvert_ReleaseIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["ReleaseConversionCopies"]) == 0 {
t.Fatal("ReleaseConversionCopies is never called — a converted app's old datadir copy would stay on disk for ever")
}
}
@@ -0,0 +1,90 @@
package main
import (
"go/ast"
"io"
"log"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's,
// a quiesce's or an update's own stop, never twice, never an app already held.
func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) {
var calls []string
d := unhealthyDeps{
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
return []stacks.UnhealthyVerdict{
{Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute},
{Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7},
{Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7},
{Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20},
}
},
suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} },
busy: func(n string) (bool, string) { return n == "busy", "a restore is running" },
holdKind: func(n string) string {
if n == "held" {
return "update_failed"
}
return ""
},
stop: func(n string) error { calls = append(calls, "stop:"+n); return nil },
hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil },
notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) },
}
stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" {
t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls)
}
if len(stopped) != 1 || stopped[0] != "gokapi" {
t.Fatalf("stopped = %v", stopped)
}
}
// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app).
func TestD28_AFailedStopIsNotAnnounced(t *testing.T) {
var calls []string
d := unhealthyDeps{
verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict {
return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}}
},
suppressed: func() map[string]bool { return nil },
busy: func(string) (bool, string) { return false, "" },
holdKind: func(string) string { return "" },
stop: func(string) error { return io.ErrUnexpectedEOF },
hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil },
notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") },
}
stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now())
if len(calls) != 0 {
t.Fatalf("calls = %v after a failed stop", calls)
}
}
// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector.
func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) {
found, withDetector := false, false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" {
found = true
ast.Inspect(call, func(m ast.Node) bool {
if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" {
withDetector = true
}
return true
})
}
return true
})
if !found || !withDetector {
t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector)
}
}
@@ -0,0 +1,41 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.317.0 (`16` §4): the agent's same-disk verdict reaches the backup side. A seam built and never wired
// has shipped in this repo seven times; this walks main.go for the call AND for the agent call inside it.
// COMPANION RED-PROOF: delete the SetDiskJudge block → "not wired"; replace ac.SameDisk with a constant →
// "never asks the agent".
func TestDiskJudge_IsWiredAtStartup(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var judge *ast.CallExpr
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDiskJudge" {
judge = c
}
}
return true
})
if judge == nil {
t.Fatal("backupMgr.SetDiskJudge is not wired in main.go — Tier-2 would keep the st_dev rule for ever")
}
asks := false
ast.Inspect(judge, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SameDisk" {
asks = true
}
return true
})
if !asks {
t.Fatal("SetDiskJudge is wired but never asks the agent (no SameDisk call inside it)")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// v0.283.0 (decision 50, R-720): the deploy-done hook's SUCCESS branch — the one place a fresh install ends —
// calls sett.DefaultOffboxOnForNewApp. The rule itself is pinned in internal/settings/offbox_default_test.go;
// this pins that it is reached.
// COMPANION RED-PROOF: delete the call from the hook → this test fails.
func TestDecision50_DefaultIsWiredIntoTheDeployDoneHook(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "SetDeployDoneHook" && len(c.Args) == 1 {
ast.Inspect(c.Args[0], func(m ast.Node) bool {
if cc, ok := m.(*ast.CallExpr); ok {
if ss, ok := cc.Fun.(*ast.SelectorExpr); ok && ss.Sel.Name == "DefaultOffboxOnForNewApp" {
found = true
}
}
return true
})
}
return true
})
if !found {
t.Fatal("the deploy-done hook does not call DefaultOffboxOnForNewApp — a new app would start with off-site OFF (R-720)")
}
}
@@ -0,0 +1,60 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"sync/atomic"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
)
// opActionReportFire holds the out-of-cycle report trigger once main has built it (func()). The
// executor is built earlier than the trigger, so it reads it through this holder.
var opActionReportFire atomic.Value
// operatorActionHandlers wires the four operator actions (R-314/R-279/R-177, `09` §3 decision 185)
// to the running process. ONE wiring site, called from main and from the wiring test, so the test
// exercises the doors production uses (the seam-built-but-never-wired trap).
//
// offsite_backup_now makes the SAME four checks as the household's „back up now"
// (offboxRunHandler) and then runs the same call, so an operator press can never do what the
// household's own press cannot.
func operatorActionHandlers(backupMgr *backup.Manager, sched *scheduler.Scheduler, resultReady func(), logger *log.Logger) report.OperatorActionHandlers {
h := report.OperatorActionHandlers{ResultReady: resultReady}
if sched != nil {
h.RunJob = sched.RunNow
}
if backupMgr == nil {
return h
}
h.OffsiteBackupNow = func() (string, func(ctx context.Context) error) {
switch {
case !backupMgr.OffboxConfigured():
return "no off-site target is set on this box", nil
case !backupMgr.OffboxRunnable():
return "the off-site key step (escrow) is not done yet — the run is refused until it is", nil
case backupMgr.OffboxOrphaned():
return "the off-site store is orphaned (written with an older key) — a run cannot write to it", nil
case backupMgr.IsRunning():
return "a backup run is already in flight", nil
}
return "", func(ctx context.Context) error {
if logger != nil {
logger.Printf("[INFO] [opaction] off-site backup started at the operator's request")
}
err := backupMgr.RunOffboxBackupWithProgress(ctx)
if errors.Is(err, backup.ErrOffboxRunInFlight) {
return fmt.Errorf("%w: %v", report.ErrActionRefused, err)
}
return err
}
}
h.AbandonStop = backupMgr.StopAbandon
h.AbandonExtend = func(days int) (time.Time, error) { return backupMgr.ExtendAbandon(days) }
return h
}
@@ -0,0 +1,169 @@
package main
import (
"context"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// `09` §3 decision 185 (D1), the design's red test (1): a reply carrying abandon_stop during a
// countdown stops it IN THE RUNNING MANAGER — and settings.json on disk agrees. This is the reason
// the action rides the report reply instead of the CLI lever: the CLI runs a SECOND process, whose
// write the running controller can overwrite (the lost update, main.go's R-241 block).
func TestD1_AbandonStopActsOnTheRunningManagerAndDisk(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.json")
sett, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
mgr := backup.NewManager(cfg, sett, logger)
// No test reaches a real ssh or restic (R-488 / R-650).
mgr.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
t.Fatal("abandon_stop must issue no remote command")
return nil, nil
})
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("abandon_stop must run no restic")
return nil, nil
})
due := time.Now().Add(10 * 24 * time.Hour).UTC()
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
EscrowState: "escrowed",
AbandonRepoPath: "/srv/repo.orphaned-20261001",
AbandonStartedAt: time.Now().Add(-4 * 24 * time.Hour).UTC().Format(time.RFC3339),
AbandonAt: due.Format(time.RFC3339),
}); err != nil {
t.Fatal(err)
}
if !mgr.AbandonStatus().Active {
t.Fatal("fixture: a countdown must be running")
}
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpAbandonStop}})
if mgr.AbandonStatus().Active {
t.Fatal("the running manager still counts down after abandon_stop")
}
onDisk, err := settings.Load(settingsPath, logger)
if err != nil {
t.Fatal(err)
}
if tg := onDisk.GetOffboxTarget(); tg == nil || tg.AbandonAt != "" || tg.AbandonRepoPath == "" {
t.Fatalf("settings.json disagrees: %+v (want no due date, the set-aside path kept)", tg)
}
rs := o.Results()
if len(rs) != 1 || rs[0].Outcome != report.OutcomeDone {
t.Fatalf("result = %+v", rs)
}
// Control: the same countdown, a second press → nothing to stop, reported as such (not „done").
o.Reconcile([]report.OperatorAction{{ID: 2, Action: report.OpAbandonStop}})
if r := o.Results(); len(r) != 1 || r[0].ID != 2 || r[0].Outcome != report.OutcomeFailed {
t.Fatalf("second stop: %+v", r)
}
}
// offsite_backup_now refuses exactly where the household's „back up now" refuses: no target here.
func TestD1_OffsiteBackupNowRefusesWithoutATarget(t *testing.T) {
logger := log.New(io.Discard, "", 0)
dir := t.TempDir()
sett, err := settings.Load(filepath.Join(dir, "settings.json"), logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dir
mgr := backup.NewManager(cfg, sett, logger)
mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
t.Fatal("no restic may run")
return nil, nil
})
o := report.NewOperatorActions(context.Background(), operatorActionHandlers(mgr, nil, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 9, Action: report.OpOffsiteBackupNow}})
if r := o.Results(); len(r) != 1 || r[0].Outcome != report.OutcomeRefused {
t.Fatalf("result = %+v", r)
}
}
// run_job reaches the scheduler's RunNow: a registered job runs; a job not registered on this box is
// refused.
func TestD1_RunJobUsesTheScheduler(t *testing.T) {
logger := log.New(io.Discard, "", 0)
sched := scheduler.New(logger)
ran := make(chan bool, 1)
sched.Daily("fill-watch", "03:30", func(ctx context.Context) error { ran <- scheduler.OnDemand(ctx); return nil })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
defer sched.Stop()
o := report.NewOperatorActions(ctx, operatorActionHandlers(nil, sched, nil, logger), logger)
o.Reconcile([]report.OperatorAction{{ID: 1, Action: report.OpRunJob, Arg: "fill-watch"}, {ID: 2, Action: report.OpRunJob, Arg: "offsite-proof"}})
select {
case od := <-ran:
if !od {
t.Fatal("the job did not see itself as on-demand")
}
case <-time.After(5 * time.Second):
t.Fatal("fill-watch never ran")
}
deadline := time.Now().Add(5 * time.Second)
for len(o.Results()) < 2 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
got := map[int64]string{}
for _, r := range o.Results() {
got[r.ID] = r.Outcome
}
if got[1] != report.OutcomeDone || got[2] != report.OutcomeRefused {
t.Fatalf("outcomes = %v, want 1:done 2:refused (offsite-proof not registered here)", got)
}
}
// main.go hands the reply's list to the executor and the trigger to its holder — one call each
// (the seam-built-but-never-wired trap: a green executor that main never calls proves nothing).
func TestD1_MainWiresTheExecutor(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := map[string]int{}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
if x, ok := fn.X.(*ast.Ident); ok {
found[x.Name+"."+fn.Sel.Name]++
}
case *ast.Ident:
found[fn.Name]++
}
return true
})
for _, want := range []string{"opActions.Reconcile", "report.SetOperatorActions", "operatorActionHandlers", "opActionReportFire.Store"} {
if found[want] != 1 {
t.Errorf("main.go calls %s %d time(s), want exactly 1", want, found[want])
}
}
}
@@ -0,0 +1,88 @@
package main
import (
"log"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1199 (rehearsal 2026-10-20) and R-1199 REOPENED (2026-10-22). A fresh box shows the household its DEFAULT
// notification list, but the hub mails only by its OWN stored list. v0.338.0 pushed that default at every start when
// nothing was saved on the box — and on a REBUILT box that overwrote the list the household had saved on its old box
// (demo-felhom, 07:54:55 UTC 2026-10-22: its own 13 events became the 17 defaults), again at every start, because the
// push created no local save. `08` „the household decides".
//
// Now, at start:
// - a list saved on this box: as before (R-922 — re-pushed when it carries an address or a clear), as the household's;
// - nothing saved here: READ the hub's list first. One the household saved (on any box) is ADOPTED as this box's
// list — nothing is pushed; otherwise the box's default is pushed once, as a default, and saved here, so the next
// start sends nothing. A hub that cannot be read (an older hub, no network) gets nothing; the next start asks again.
//
// The hub refuses a default over a household's list on its own side too (felhom.eu hub household_prefs.go), which also
// stops a box still on v0.338.0. Pinned by TestR1199Reopened_* (prefs_sync_wiring_test.go).
type prefsHub interface {
FetchPreferences() (*notify.HubPrefs, error)
SyncPreferencesAs(source, email string, enabledEvents []string, cooldownHours int, emailCleared bool) (string, error)
}
type prefsStore interface {
GetNotificationPrefs() *settings.NotificationPrefs
NotificationPrefsSaved() bool
SetNotificationPrefs(*settings.NotificationPrefs) error
}
// startupPrefsSync runs the start-up step above and returns what it did (for the log and the tests): "pushed",
// "adopted", "default-pushed", "default-kept", "nothing", "no-hub" or "error".
func startupPrefsSync(hub prefsHub, st prefsStore, logger *log.Logger) string {
logf := func(f string, a ...any) {
if logger != nil {
logger.Printf(f, a...)
}
}
prefs := st.GetNotificationPrefs()
if st.NotificationPrefsSaved() {
if !prefs.SyncOnStartup() {
return "nothing"
}
if _, err := hub.SyncPreferencesAs(notify.PrefsSourceHousehold, prefs.Email, prefs.EnabledEvents, prefs.CooldownHours, prefs.EmailCleared); err != nil {
logf("[WARN] Failed to sync notification preferences on startup: %v", err)
return "error"
}
return "pushed"
}
hp, err := hub.FetchPreferences()
if err != nil {
logf("[WARN] notification prefs: no list saved on this box and the hub's could not be read (%v) — nothing pushed; asked again at the next start (R-1199)", err)
return "no-hub"
}
if hp.Exists && hp.HouseholdSaved {
adopted := &settings.NotificationPrefs{Email: hp.Email, EnabledEvents: hp.EnabledEvents, CooldownHours: hp.CooldownHours}
if adopted.EnabledEvents == nil {
adopted.EnabledEvents = []string{}
}
if err := st.SetNotificationPrefs(adopted); err != nil {
logf("[ERROR] notification prefs: adopting the household's list from the hub failed: %v", err)
return "error"
}
logf("[INFO] notification prefs: adopted the household's saved list from the hub (%d events, address set=%t) — nothing pushed (R-1199)", len(adopted.EnabledEvents), adopted.Email != "")
return "adopted"
}
status, err := hub.SyncPreferencesAs(notify.PrefsSourceDefault, "", prefs.EnabledEvents, prefs.CooldownHours, false)
if err != nil {
logf("[WARN] notification prefs: pushing the default list failed: %v", err)
return "error"
}
if status == "kept" {
logf("[INFO] notification prefs: the hub kept the household's own list over this box's default — adopted at the next start (R-1199)")
return "default-kept"
}
local := &settings.NotificationPrefs{EnabledEvents: prefs.EnabledEvents, CooldownHours: prefs.CooldownHours}
if err := st.SetNotificationPrefs(local); err != nil {
logf("[ERROR] notification prefs: saving the pushed default on this box failed: %v", err)
return "error"
}
logf("[INFO] notification prefs: no household list anywhere — pushed this box's default once and saved it here (R-1199)")
return "default-pushed"
}
@@ -0,0 +1,147 @@
package main
import (
"errors"
"io"
"log"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-1199 REOPENED (2026-10-22) — the brief's four cases, against the real settings file and a hub that behaves like
// hub ≥ 0.165.0 (it keeps a household's list over a default push).
// RED-PROOF: with v0.338.0's rule restored (no hub read: a box with nothing saved pushes its default at every start)
// case A fails (the default reaches the hub) and case D fails (a second push) — seen failing 2026-10-22.
type fakeHub struct {
stored *notify.HubPrefs
readErr error
pushes []string // the source of every push
lastEvents []string
}
func (f *fakeHub) FetchPreferences() (*notify.HubPrefs, error) {
if f.readErr != nil {
return nil, f.readErr
}
if f.stored == nil {
return &notify.HubPrefs{Exists: false}, nil
}
cp := *f.stored
return &cp, nil
}
func (f *fakeHub) SyncPreferencesAs(source, email string, ev []string, cd int, cleared bool) (string, error) {
f.pushes = append(f.pushes, source)
if source != notify.PrefsSourceHousehold && f.stored != nil && f.stored.HouseholdSaved {
return "kept", nil
}
if f.stored == nil {
f.stored = &notify.HubPrefs{Exists: true}
}
f.stored.EnabledEvents, f.lastEvents = ev, ev
if email != "" {
f.stored.Email = email
}
if source == notify.PrefsSourceHousehold {
f.stored.HouseholdSaved = true
}
return "ok", nil
}
func newSettings(t *testing.T) *settings.Settings {
t.Helper()
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
var familyList = []string{"backup_failed", "disk_warning", "node_down"}
// A: the household saved on an earlier box; this box has no local save → nothing reaches the hub, the box adopts the
// list and its page (GetNotificationPrefs) shows it.
func TestR1199Reopened_A_ARebuiltBoxAdoptsTheHouseholdsList(t *testing.T) {
hub := &fakeHub{stored: &notify.HubPrefs{Exists: true, HouseholdSaved: true, Email: "family@example.hu", EnabledEvents: familyList, CooldownHours: 12}}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "adopted" {
t.Fatalf("startup = %q, want adopted", got)
}
if len(hub.pushes) != 0 {
t.Fatalf("a box with no list of its own pushed %v over the household's list", hub.pushes)
}
p := st.GetNotificationPrefs()
if !st.NotificationPrefsSaved() || len(p.EnabledEvents) != 3 || p.Email != "family@example.hu" || p.CooldownHours != 12 {
t.Fatalf("the page must show the household's list: %+v", p)
}
// D after A: the next start re-pushes the adopted list as the household's (it carries an address, R-922) — the
// same list, never the default.
startupPrefsSync(hub, st, nil)
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceHousehold || len(hub.lastEvents) != 3 {
t.Fatalf("after adoption: pushes %v events %v", hub.pushes, hub.lastEvents)
}
}
// B: no household ever saved (a seeded row, or none) → the default goes once, is saved here, and the hub mails by it.
func TestR1199Reopened_B_NoHouseholdListAnywhereTakesTheDefaultOnce(t *testing.T) {
for _, stored := range []*notify.HubPrefs{nil, {Exists: true, Email: "seed@example.hu", EnabledEvents: []string{"node_down"}}} {
hub := &fakeHub{stored: stored}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "default-pushed" {
t.Fatalf("startup = %q, want default-pushed", got)
}
if len(hub.pushes) != 1 || hub.pushes[0] != notify.PrefsSourceDefault || len(hub.stored.EnabledEvents) != len(settings.DefaultEnabledEvents) {
t.Fatalf("the default must reach the hub once, as a default: %v %v", hub.pushes, hub.stored.EnabledEvents)
}
if !st.NotificationPrefsSaved() {
t.Fatal("the pushed default must be saved on the box")
}
// D: a restart sends nothing (a local save exists, with no address).
if got := startupPrefsSync(hub, st, nil); got != "nothing" || len(hub.pushes) != 1 {
t.Fatalf("restart after B: %q, pushes %v — want nothing", got, hub.pushes)
}
}
}
// C: the household saves on the box → the page save pushes as the household's (web handler, unchanged path); at start
// a saved list with an address is re-pushed as the household's (R-922).
func TestR1199Reopened_C_ASavedListIsTheHouseholds(t *testing.T) {
hub := &fakeHub{stored: &notify.HubPrefs{Exists: true, HouseholdSaved: true, EnabledEvents: familyList}}
st := newSettings(t)
st.SetNotificationPrefs(&settings.NotificationPrefs{Email: "new@example.hu", EnabledEvents: []string{"node_down"}})
if got := startupPrefsSync(hub, st, nil); got != "pushed" || hub.pushes[0] != notify.PrefsSourceHousehold {
t.Fatalf("a saved list: %q %v", got, hub.pushes)
}
if len(hub.stored.EnabledEvents) != 1 {
t.Fatalf("the household's new choice must reach the hub: %v", hub.stored.EnabledEvents)
}
}
// A hub that cannot be read gets nothing (never a blind default); a hub that keeps its list over a race is not
// overwritten and the box saves nothing (it adopts at the next start).
func TestR1199Reopened_AnUnreadableHubGetsNothing(t *testing.T) {
hub := &fakeHub{readErr: errors.New("hub answered HTTP 404")}
st := newSettings(t)
if got := startupPrefsSync(hub, st, nil); got != "no-hub" || len(hub.pushes) != 0 || st.NotificationPrefsSaved() {
t.Fatalf("unreadable hub: %q pushes %v saved %t", got, hub.pushes, st.NotificationPrefsSaved())
}
}
// The default list the page shows carries the three events the rehearsal household did not get (kept from R-1199).
func TestR1199_TheDefaultListHoldsTheAlarmsTheHouseholdMissed(t *testing.T) {
want := map[string]bool{"disk_warning": false, "storage_disconnected": false, "crossdrive_failed": false}
for _, e := range settings.DefaultEnabledEvents {
if _, ok := want[e]; ok {
want[e] = true
}
}
for e, ok := range want {
if !ok {
t.Fatalf("%s is not in the default list", e)
}
}
}
@@ -0,0 +1,86 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// R-1105: restic is pinned OUTSIDE the OS. Until v0.323.0 it came from the runtime's apt list, so the
// version was „whatever Debian ships" — a Debian 12 → 13 base move would have swapped restic 0.14.0 for
// 0.18.0 under the off-site code with nobody choosing it. The consequences pinned here:
// - the version is one explicit ARG, and each architecture's checksum is pinned beside it;
// - the fetch checks restic's signature (vendored key, no keyserver) AND both checksums;
// - the runtime takes restic ONLY from that stage — no apt package can bring a second one;
// - every base image is pinned by digest, and the Docker repo's codename is read from the image.
func TestR1105_ResticIsPinnedOutsideTheOS(t *testing.T) {
raw, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
src := string(raw)
// Stages, split on FROM, with `\`-continued lines joined so an apt list reads as one command.
joined := strings.ReplaceAll(src, "\\\n", " ")
var stages []string
for _, part := range regexp.MustCompile(`(?m)^FROM `).Split(joined, -1)[1:] {
stages = append(stages, "FROM "+part)
}
if len(stages) < 3 {
t.Fatalf("expected a builder, a restic and a runtime stage, found %d stages", len(stages))
}
var fetch string
for _, s := range stages {
if regexp.MustCompile(`^FROM \S+ AS restic\b`).MatchString(s) {
fetch = s
}
}
if fetch == "" {
t.Fatal("no `AS restic` stage: restic is no longer fetched as a pinned release binary")
}
runtime := stages[len(stages)-1]
if m := regexp.MustCompile(`ARG RESTIC_VERSION=(\d+\.\d+\.\d+)\s`).FindStringSubmatch(fetch); m == nil {
t.Error("ARG RESTIC_VERSION=X.Y.Z is missing — the version must be an explicit decision")
}
for _, arch := range []string{"AMD64", "ARM64"} {
if !regexp.MustCompile(`ARG RESTIC_SHA256_` + arch + `=[0-9a-f]{64}\s`).MatchString(fetch) {
t.Errorf("no pinned sha256 for %s", arch)
}
}
for _, want := range []string{
"COPY third_party/restic/restic-release-key.asc",
"gpgv --keyring", // SHA256SUMS is restic's, signed
`SHA256SUMS | sha256sum -c -`, // the download matches the published sum
`echo "${want} ${f}" | sha256sum -c -`, // ...and the sum pinned in this file
} {
if !strings.Contains(fetch, want) {
t.Errorf("the restic stage no longer does %q", want)
}
}
if _, err := os.Stat("../../third_party/restic/restic-release-key.asc"); err != nil {
t.Errorf("the vendored release key is missing: %v", err)
}
if !strings.Contains(runtime, "COPY --from=restic /usr/local/bin/restic /usr/local/bin/restic") {
t.Error("the runtime does not take restic from the pinned stage")
}
aptRe := regexp.MustCompile(`apt-get install[^&]*`)
for _, inst := range aptRe.FindAllString(runtime, -1) {
if regexp.MustCompile(`\srestic(\s|$)`).MatchString(inst) {
t.Errorf("the runtime's apt list installs restic — a second, OS-chosen restic: %q", strings.Join(strings.Fields(inst), " "))
}
}
for _, from := range regexp.MustCompile(`(?m)^FROM (\S+)`).FindAllStringSubmatch(src, -1) {
if !strings.Contains(from[1], "@sha256:") {
t.Errorf("base image %s is not pinned by digest", from[1])
}
}
if regexp.MustCompile(`download\.docker\.com/linux/debian (bookworm|trixie|forky)`).MatchString(src) {
t.Error("the Docker apt repo codename is hard-coded — it must come from /etc/os-release (VERSION_CODENAME)")
}
if !strings.Contains(runtime, "${VERSION_CODENAME}") {
t.Error("the Docker apt repo line does not read VERSION_CODENAME")
}
}
@@ -0,0 +1,59 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// R-1176 (2026-10-21): the off-site apply-bridge waits only until the FLOOR is known (offsiteapply.AwaitSettle), and
// then may mint a repository password unless the box has recorded that the hub holds the household's sealed package
// (R-241's mint guard reads that record). Both facts arrive in the same report ACK, so inside OnPushResponse the
// escrow must be recorded BEFORE updater.SetFloor makes the floor known — otherwise a bridge poll landing between the
// two mints a fresh key on a rebuilt box and the household's history is set aside on its first night.
// Walks main.go's AST with comments dropped, so a commented-out call cannot satisfy it.
// COMPANION RED-PROOF: move escrowConfirmer.Reconcile below updater.SetFloor → this fails.
func TestR1176_EscrowIsRecordedBeforeTheFloorIsKnown(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var escrowPos, floorPos token.Pos
ast.Inspect(f, func(n ast.Node) bool {
as, ok := n.(*ast.AssignStmt)
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
return true
}
sel, ok := as.Lhs[0].(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "OnPushResponse" {
return true
}
ast.Inspect(as.Rhs[0], func(m ast.Node) bool {
call, ok := m.(*ast.CallExpr)
if !ok {
return true
}
if s, ok := call.Fun.(*ast.SelectorExpr); ok {
if x, ok := s.X.(*ast.Ident); ok {
switch {
case x.Name == "escrowConfirmer" && s.Sel.Name == "Reconcile" && escrowPos == 0:
escrowPos = call.Pos()
case x.Name == "updater" && s.Sel.Name == "SetFloor" && floorPos == 0:
floorPos = call.Pos()
}
}
}
return true
})
return true
})
if escrowPos == 0 || floorPos == 0 {
t.Fatalf("OnPushResponse no longer calls both escrowConfirmer.Reconcile (%v) and updater.SetFloor (%v) — the order is unasserted", escrowPos, floorPos)
}
if escrowPos > floorPos {
t.Fatalf("escrowConfirmer.Reconcile (%s) runs AFTER updater.SetFloor (%s): a rebuilt box can mint a fresh key before it learns the hub holds the household's package",
fset.Position(escrowPos), fset.Position(floorPos))
}
}
@@ -0,0 +1,28 @@
package main
import (
"os"
"strings"
"testing"
)
// R-1189 (d): the catch-up in main.go carries the After hook that runs the off-site proof and the due-gated integrity
// check (internal/nightchain TestR1189d_* pins that After runs; this pins that production wires it).
// RED-PROOF: the After block removed from main.go → this fails.
func TestR1189d_TheCatchUpIsWiredToTheChecks(t *testing.T) {
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "catchUp := &nightchain.CatchUp{")
if i < 0 {
t.Fatal("the catch-up literal is gone from main.go")
}
lit := s[i:]
lit = lit[:strings.Index(lit, "\n\t\t\t}\n")]
a := strings.Index(lit, "After: func(ctx context.Context) {")
if a < 0 || !strings.Contains(lit[a:], "runOffsiteProof(") || !strings.Contains(lit[a:], "runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false)") {
t.Fatal("the catch-up must run the off-site proof and the due-gated (force=false) integrity check after its legs")
}
}
@@ -0,0 +1,57 @@
package main
import (
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-1253 (review round 2, HUNT-1 H3): an export with „stop the app first" during another app's restore replaced the
// ONE app-stop marker — the restore's crash record and its Undo were lost, and a power cut then left the restored app
// on mixed data with no put-back. The test is the review's mechanism with two Begins on one guard, through the
// exporter's real adapter. RED-PROOF (2026-10-24): with exportStopGuard.Begin calling Begin instead of BeginExclusive,
// this fails on „the restore's Undo is gone from the marker".
func TestR1253_AnExportNeverReplacesARestoresMarker(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "appstop-state.json")
g := backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))
if err := g.Begin("unit-restore:bookstack", backup.ReasonUnitRestore, []string{"bookstack"}); err != nil {
t.Fatal(err)
}
undo := filepath.Join(dir, "backups", "restore-undo", "bookstack", "20261024T200000Z")
if err := g.SetUndo("bookstack", undo); err != nil {
t.Fatal(err)
}
ex := exportStopGuard{g: g}
if busy := ex.BusyWith(); busy != "unit-restore:bookstack" {
t.Fatalf("the export must see the restore holding the marker, got %q", busy)
}
err := ex.Begin("app-export:immich", []string{"immich"})
if !errors.Is(err, backup.ErrAppStopBusy) {
t.Fatalf("the export's Begin must be refused while the restore holds the marker, got %v", err)
}
b, _ := os.ReadFile(marker)
if !strings.Contains(string(b), undo) || strings.Contains(string(b), "immich") {
t.Fatalf("the restore's Undo is gone from the marker (or the export replaced it): %s", b)
}
// the restore ends → the export may run
g.End()
if err := ex.Begin("app-export:immich", []string{"immich"}); err != nil {
t.Fatalf("after the restore ended the export must start: %v", err)
}
ex.End()
// a marker a CRASH left (a new process over the same file) does not block: nothing in this process will End it
if err := g.Begin("volume-dump:paperless", backup.ReasonVolumeDump, []string{"paperless"}); err != nil {
t.Fatal(err)
}
fresh := exportStopGuard{g: backup.NewAppStopGuard(marker, log.New(io.Discard, "", 0))}
if busy := fresh.BusyWith(); busy != "" {
t.Fatalf("a crash-left marker is not a live operation, got %q", busy)
}
}
@@ -0,0 +1,56 @@
package main
import (
"os"
"regexp"
"strings"
"testing"
)
// R-208: `ARG VERSION` / `ARG GIT_COMMIT` declared ABOVE `RUN go mod download` put a per-build value
// into that RUN's cache key, so every release re-downloaded the modules (measured: 208 cache records,
// every one with usage count 1). The consequence pinned here, in the builder stage: no ARG whose value
// changes per build is declared before the module-download step, and both are still declared before
// the `go build` that reads them (or the binary would report "dev"/"unknown").
func TestR208_DockerfileVersionArgsSitBelowModuleDownload(t *testing.T) {
src, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(src), "\n")
argRe := regexp.MustCompile(`^\s*ARG\s+(VERSION|GIT_COMMIT|BUILD_TIME)\b`)
download, build := -1, -1
args := map[string]int{}
for i, l := range lines {
if strings.HasPrefix(strings.TrimSpace(l), "FROM ") && i > 0 && download >= 0 && build >= 0 {
break // the runtime stage: only the builder matters
}
if m := argRe.FindStringSubmatch(l); m != nil {
if _, seen := args[m[1]]; !seen {
args[m[1]] = i // the FIRST declaration: any one above the download is the defect
}
}
if strings.Contains(l, "go mod download") && download < 0 {
download = i
}
if strings.Contains(l, "go build") && build < 0 {
build = i
}
}
if download < 0 || build < 0 {
t.Fatalf("the builder stage no longer has a `go mod download` (%d) or a `go build` (%d) — this test reads the wrong file", download, build)
}
for _, name := range []string{"VERSION", "GIT_COMMIT"} {
at, ok := args[name]
if !ok {
t.Errorf("ARG %s is not declared in the builder stage — the binary would be built without it", name)
continue
}
if at < download {
t.Errorf("R-208: ARG %s (line %d) is declared above `go mod download` (line %d), so every build with a new value re-downloads the modules", name, at+1, download+1)
}
if at > build {
t.Errorf("ARG %s (line %d) is declared after the `go build` that reads it (line %d)", name, at+1, build+1)
}
}
}
+28 -9
View File
@@ -5,6 +5,8 @@ import (
"go/parser"
"go/token"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// R-359 — the scheduled job must be PROVEN WIRED.
@@ -87,16 +89,33 @@ func TestR359_DebugCallbackIsWired(t *testing.T) {
func TestR359_OutcomeMessagesCarryNoMachineDetail(t *testing.T) {
// The customer gets a sentence; restic's words go to the log. R-379: 615 bytes of raw database
// text reached a customer once.
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
if contains(integrityFailedMsg, bad) {
t.Errorf("the failure sentence carries machine detail %q: %q", bad, integrityFailedMsg)
}
// text reached a customer once. R-585: the sentence is a bundle key now, so BOTH languages are
// held to it — an English household must be told what not to do just as plainly.
b, err := i18n.Shared()
if err != nil {
t.Fatal(err)
}
// It must still tell them what to do — and what NOT to do.
for _, want := range []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"} {
if !contains(integrityFailedMsg, want) {
t.Errorf("the failure sentence is a dead end; missing %q", want)
for _, tc := range []struct {
lang string
wants []string
}{
{"hu", []string{"Ne törölj semmit", "vedd fel velünk a kapcsolatot"}},
{"en", []string{"Do not delete anything", "contact us"}},
} {
msg, fellBack, ok := b.Text(tc.lang, "event.backup_integrity_failed")
if !ok || fellBack {
t.Fatalf("%s: event.backup_integrity_failed is missing (ok=%v fellBack=%v)", tc.lang, ok, fellBack)
}
for _, bad := range []string{"sftp:", "restic", "exit status", "@", "/srv/"} {
if contains(msg, bad) {
t.Errorf("%s: the failure sentence carries machine detail %q: %q", tc.lang, bad, msg)
}
}
// It must still tell them what to do — and what NOT to do.
for _, want := range tc.wants {
if !contains(msg, want) {
t.Errorf("%s: the failure sentence is a dead end; missing %q", tc.lang, want)
}
}
}
}
@@ -0,0 +1,58 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"time"
)
// R-363 / R-547: the fill watcher ran once a day (plus once after start), so a filesystem that filled
// right after the 03:30 run went unannounced for ~24 h, and a disk held at 96 % for ten minutes raised
// nothing. The consequence pinned here: main.go registers the SAME fillWatcher.Check on a periodic
// job whose interval is no longer than the shortest measured silent window (10 min), so any such
// window contains a check. Edge-triggering (no repeat mails at a faster cadence) is pinned in
// internal/fillwatch (TestWarnsOnceThenIsSilent, TestEdgeStateSurvivesARestart).
func TestFillWatchRunsOnAnInterval(t *testing.T) {
const measuredSilentWindow = 10 * time.Minute // R-547, chaos night 2026-09-17
if fillWatchInterval <= 0 || fillWatchInterval > measuredSilentWindow {
t.Errorf("fillWatchInterval = %s; it must be >0 and <= %s, or a fill window like R-547's can pass unseen", fillWatchInterval, measuredSilentWindow)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
periodic := 0
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Every" || len(call.Args) != 3 {
return true
}
iv, ok := call.Args[1].(*ast.Ident)
if !ok || iv.Name != "fillWatchInterval" {
return true
}
// The job body must call fillWatcher.Check — the same edge-triggered check, not a new one.
ast.Inspect(call.Args[2], func(m ast.Node) bool {
if c, ok := m.(*ast.CallExpr); ok {
if s, ok := c.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Check" {
if x, ok := s.X.(*ast.Ident); ok && x.Name == "fillWatcher" {
periodic++
}
}
}
return true
})
return true
})
if periodic != 1 {
t.Errorf("R-363: main.go registers fillWatcher.Check on a periodic (sched.Every, fillWatchInterval) job %d times, want 1 — without it the fill check is daily only", periodic)
}
}
@@ -0,0 +1,124 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-475 — the update reads EVERY backup tier, through the one adapter.
func TestR475_TierConstantsAgree(t *testing.T) {
if stacks.UpdateTierLocal != backup.UpdateTierLocal ||
stacks.UpdateTierSecondDrive != backup.UpdateTierSecondDrive ||
stacks.UpdateTierOffsite != backup.UpdateTierOffsite {
t.Fatal("stacks and backup number the tiers differently — a hold would name the wrong copy")
}
}
// adapterMethodSelectors returns the selector names used in updateGuardsAdapter.<name>'s body.
func adapterMethodSelectors(t *testing.T, name string) string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Recv == nil || fn.Name.Name != name || fn.Body == nil {
continue
}
star, ok := fn.Recv.List[0].Type.(*ast.StarExpr)
if !ok {
continue
}
if id, ok := star.X.(*ast.Ident); !ok || id.Name != "updateGuardsAdapter" {
continue
}
var names []string
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
names = append(names, sel.Sel.Name)
}
return true
})
return " " + strings.Join(names, " ") + " "
}
t.Fatalf("updateGuardsAdapter.%s not found in main.go", name)
return ""
}
func TestR475_AdapterReadsEveryTier(t *testing.T) {
rp := adapterMethodSelectors(t, "RestorePoints")
if !strings.Contains(rp, " UpdateRestorePoints ") {
t.Errorf("the adapter must read every tier through backup.UpdateRestorePoints; selectors:%s", rp)
}
if strings.Contains(rp, " Tier2UnitRestorePoint ") {
t.Error("the Tier-2-only predicate must not come back into the update path (R-475)")
}
if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") {
t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb)
}
// R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the
// adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's
// "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier —
// pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too).
if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") {
t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h)
}
}
// R-490 — /api/system/info reaches the API router; the web layer's /api/system/ prefix used to eat it.
//
// COMPANION RED-PROOF (REPORT.md): delete the exact-path mount — this fails.
func TestR490_SystemInfoIsMountedAheadOfTheWebPrefix(t *testing.T) {
src, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var exact, prefix bool
ast.Inspect(src, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 2 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Handle" {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok {
return true
}
switch lit.Value {
case `"/api/system/info"`:
exact = strings.Contains(fmtNode(call.Args[1]), "apiRouter")
case `"/api/system/"`:
prefix = true
}
return true
})
if !prefix {
t.Fatal("the /api/system/ prefix mount is gone — this test's premise moved")
}
if !exact {
t.Error("/api/system/info must be mounted on the API router ahead of the web layer's /api/system/ prefix (R-490)")
}
}
func fmtNode(n ast.Node) string {
var names []string
ast.Inspect(n, func(x ast.Node) bool {
if id, ok := x.(*ast.Ident); ok {
names = append(names, id.Name)
}
return true
})
return strings.Join(names, " ")
}
@@ -0,0 +1,94 @@
package main
import (
"go/ast"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
type fakeHDD struct {
sts []stacks.Stack
hdd map[string]string
}
func (f fakeHDD) GetStacks() []stacks.Stack { return f.sts }
func (f fakeHDD) LoadAppConfigByName(n string) *stacks.AppConfig {
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": f.hdd[n]}}
}
// R-521: one unplugged drive mailed the operator five times — storage_disconnected plus an
// app_start_failed per app on it. The consequence asserted: the NOTIFIER is told the drive's apps are
// not down (no per-app mail), while the dashboard's dead list still carries them, and an app on
// another drive that is genuinely down still alarms.
func TestR521_DriveLostAppsDoNotMailOneByOne(t *testing.T) {
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/mnt/felhom-drives/usb", "/mnt/felhom-drives/other"} {
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: p, Schedulable: true}); err != nil {
t.Fatal(err)
}
}
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", true, []string{"immich"}); err != nil {
t.Fatal(err)
}
sts := []stacks.Stack{
stack("immich", stacks.StateExited, true, false), // recorded by the disconnect
stack("paperless", stacks.StateExited, true, false), // on the lost drive by HDD_PATH
stack("romm", stacks.StateExited, true, false), // another drive, genuinely broken
}
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
mgr := fakeHDD{sts: sts, hdd: map[string]string{"immich": "/mnt/felhom-drives/usb", "paperless": "/mnt/felhom-drives/usb", "romm": "/mnt/felhom-drives/other"}}
dead, states := classifyRunStates(sts, nil, nil, time.Now())
notified := downByName(maskDriveLost(states, driveLostApps(sett, mgr)))
if notified["immich"] || notified["paperless"] {
t.Fatalf("R-521: an app stopped by the lost drive is still reported down to the notifier: %v", notified)
}
if !notified["romm"] {
t.Fatal("a genuinely broken app on another drive stopped alarming — over-correction")
}
if dn := deadNames(dead); !dn["immich"] || !dn["paperless"] {
t.Fatalf("the dashboard's dead list must still carry the drive's apps: %v", dn)
}
// Reconnected: nothing is masked any more.
if err := sett.SetDisconnected("/mnt/felhom-drives/usb", false, nil); err != nil {
t.Fatal(err)
}
if got := driveLostApps(sett, mgr); len(got) != 0 {
t.Fatalf("with no drive disconnected nothing may be masked, got %v", got)
}
}
// The wiring: the dead-app job hands the notifier the masked states.
func TestR521_DeadAppJobMasksDriveLost(t *testing.T) {
found := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "NotifyAppStartFailures" || len(call.Args) != 1 {
return true
}
if inner, ok := call.Args[0].(*ast.CallExpr); ok {
if id, ok := inner.Fun.(*ast.Ident); ok && id.Name == "maskDriveLost" {
found = true
}
}
return true
})
if !found {
t.Fatal("R-521: main calls NotifyAppStartFailures without maskDriveLost")
}
}
@@ -0,0 +1,68 @@
package main
import (
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/fillwatch"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
type fillPushRecord struct {
eventType, severity, message, household string
details interface{}
}
type fakeFillPusher struct{ got []fillPushRecord }
func (f *fakeFillPusher) PushEventHousehold(eventType, severity, message, household string, details interface{}) {
f.got = append(f.got, fillPushRecord{eventType, severity, message, household, details})
}
// R-521 F7 (BIGNIGHT 2026-09-14): the guest's own system disk at 95 % produced only `health_degraded`,
// whose mail a cooldown swallowed — no disk-specific event reached the hub. The consequence pinned here,
// through the REAL target list (fillTargets), the REAL watcher and the REAL push closure (fillEventPush):
// the guest's root filesystem at F7's numbers sends the hub's existing `disk_critical` event (severity
// critical, a type the hub already allowlists), ONCE (edge-triggered), carrying the Hungarian sentence
// AND the household's own (English here).
func TestR521F7_RootFilesystemSendsTheDiskEventInBothLanguages(t *testing.T) {
cfg := &config.Config{}
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
usage := func(p string) *fillwatch.Usage {
if p == system.DockerVolumePath { // the guest's root filesystem as the controller sees it
return &fillwatch.Usage{UsedPercent: 95, AvailGB: 3.5, UsedGB: 61.8, TotalGB: 68.7}
}
return &fillwatch.Usage{UsedPercent: 30, AvailGB: 50, UsedGB: 20, TotalGB: 70}
}
w := fillwatch.New(filepath.Join(t.TempDir(), "fill.json"), log.New(io.Discard, "", 0),
func() []fillwatch.Target { return fillTargets(cfg, nil) }, usage)
rec := &fakeFillPusher{}
w.SetNotify(fillEventPush(rec))
w.SetLang(func() string { return "en" })
for i := 0; i < 2; i++ { // the second check sees the same band: nothing new (edge-triggered)
if err := w.Check(); err != nil {
t.Fatal(err)
}
}
if len(rec.got) != 1 {
t.Fatalf("pushes = %d (%+v), want exactly 1 disk event for the root filesystem", len(rec.got), rec.got)
}
p := rec.got[0]
if p.eventType != "disk_critical" || p.severity != "critical" {
t.Errorf("pushed %s/%s, want disk_critical/critical", p.eventType, p.severity)
}
if d, _ := p.details.(map[string]any); d == nil || d["path"] != system.DockerVolumePath {
t.Errorf("details = %v, want path %q", p.details, system.DockerVolumePath)
}
if !strings.Contains(p.message, "Alkalmaz") || !strings.Contains(p.message, "kritikusan") {
t.Errorf("Hungarian message = %q", p.message)
}
if !strings.Contains(p.household, "App area") || !strings.Contains(p.household, "critically full") {
t.Errorf("household (English) message = %q", p.household)
}
}
@@ -0,0 +1,47 @@
package main
import (
"go/ast"
"strings"
"testing"
)
// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk
// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy.
// An AST walk, not a substring (a commented-out call satisfies strings.Contains).
func TestR659_HoldAndEventAreWired(t *testing.T) {
_, f, _ := slice4CallLines(t)
var calls []string
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil {
continue
}
if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
calls = append(calls, sel.Sel.Name)
}
return true
})
}
body := strings.Join(calls, " ")
if !strings.Contains(body, "HoldAfterFailedUpdateWhole") {
t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)")
}
if strings.Contains(body, "HoldAfterFailedUpdateHolding") {
t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy")
}
sends := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" {
sends = true
}
return true
})
if !sends {
t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told")
}
}
@@ -0,0 +1,87 @@
package main
import (
"go/ast"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own
// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and
// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact.
type fakeHeld map[string]bool
func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f }
// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds.
// A genuinely exited app beside it must still alarm (the over-correction guard).
//
// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app
// was reported DOWN".
func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) {
sts := []stacks.Stack{
stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update
stack("romm", stacks.StateExited, true, false), // genuinely broken
}
// A held app's intent is "running" — the household never asked for the stop.
for i := range sts {
sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning}
}
dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now())
down := downByName(states)
if down["nextcloud"] || deadNames(dead)["nextcloud"] {
t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)")
}
if !down["romm"] {
t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault")
}
// Control: WITHOUT the held set the same app does alarm — so the suppression is what decides.
_, states = classifyRunStates(sts, nil, nil, time.Now())
if !downByName(states)["nextcloud"] {
t.Fatal("control failed: the fixture's app does not alarm even without the suppression")
}
}
// The wiring: main passes the backup manager, and the scan unions its held set.
func TestR660_ScanIsGivenTheHeldSet(t *testing.T) {
_, f, _ := slice4CallLines(t)
reads := false
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" {
reads = true
}
}
return true
})
}
if !reads {
t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)")
}
withB := false
ast.Inspect(mainBody(t), func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" {
for _, a := range call.Args {
if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" {
withB = true
}
}
}
return true
})
if !withB {
t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read")
}
}
@@ -0,0 +1,31 @@
package main
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/quiesce"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-721 (v0.283.1) — the seam, not the rule. The rule is pinned in internal/backup and internal/quiesce with
// fakes that DO answer WantsStopped; production hands the backup manager a *stackAdapter and the quiesce loop
// a *stacks.Manager, and a type that does not answer makes the check silently skip. Measured live on 9202
// 2026-09-30 with v0.283.0: the adapter lacked the method and the dump restarted a stopped app.
// COMPANION RED-PROOF: delete stackAdapter.WantsStopped → this test fails.
func TestR721_TheBackupsStackProviderAnswersWantsStopped(t *testing.T) {
var prov interface{} = &stackAdapter{}
if _, ok := prov.(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the backup manager's production stack provider does not answer WantsStopped — a household Stop is undone by the volume dump (R-721)")
}
// The quiesce loop is handed the stacks manager itself (main.go quiesce.Options{Stacks: stackMgr}).
// The startup crash recovery is handed gatedAppStopStarter{inner: stackMgr} (main.go SetStarter).
var gs interface{} = gatedAppStopStarter{inner: (*stacks.Manager)(nil)}
if _, ok := gs.(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the crash recovery's starter does not answer WantsStopped (R-721)")
}
var mgr *stacks.Manager
var _ quiesce.Stacks = mgr
if _, ok := interface{}(mgr).(interface{ WantsStopped(string) bool }); !ok {
t.Fatal("the quiesce loop's production Stacks does not answer WantsStopped (R-721)")
}
}
@@ -0,0 +1,85 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
)
// R-856 (`09` §3 decision 143): the dead-app check — the source of app_start_failed and
// app_stopped_unhealthy — waits on the crash-aware boot grace, not on the fixed 90 s constant.
//
// Red-proof: put `time.Since(startTime) < deadAppBootGrace` back as the check's gate — this test fails.
func TestR856_DeadAppCheckWaitsOnTheCrashAwareGrace(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var body *ast.BlockStmt
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) < 3 {
return true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); !ok || sel.Sel.Name != "Every" {
return true
}
if lit, ok := call.Args[0].(*ast.BasicLit); ok && lit.Value == `"deadapp-check"` {
if fl, ok := call.Args[2].(*ast.FuncLit); ok {
body = fl.Body
}
}
return true
})
if body == nil {
t.Fatal("the deadapp-check schedule is gone from main.go")
}
within, constant := false, false
ast.Inspect(body, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.SelectorExpr:
if id, ok := x.X.(*ast.Ident); ok && id.Name == "bootGrace" && x.Sel.Name == "Within" {
within = true
}
case *ast.Ident:
if x.Name == "deadAppBootGrace" {
constant = true
}
}
return true
})
if !within || constant {
t.Errorf("the dead-app check must gate on bootGrace.Within (found=%v) and not on deadAppBootGrace (found=%v)", within, constant)
}
}
// The normal grace is today's 90 s: a normal boot must keep it exactly (decision 143's other half).
func TestR856_NormalGraceIsTheDeadAppBootGrace(t *testing.T) {
if crashboot.NormalGrace != deadAppBootGrace {
t.Errorf("crashboot.NormalGrace = %s, deadAppBootGrace = %s — a normal boot must keep today's grace", crashboot.NormalGrace, deadAppBootGrace)
}
}
// R-1112: main opens the family store at start (webServer.OpenFamilyStore), so an old family.json is converted on every
// box — not only on one where someone passes a family gate. Red-proof: remove the call from main.go → this fails.
func TestR1112_MainOpensTheFamilyStore(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "OpenFamilyStore" {
found = true
}
}
return true
})
if !found {
t.Fatal("main.go does not call OpenFamilyStore — an old family.json keeps its session values until a family request")
}
}
@@ -0,0 +1,63 @@
package main
import (
"context"
"go/ast"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/nightchain"
)
// TestR871_CatchUpWiring — v0.295.0 (`09` decision 109): the catch-up is built, triggered at start and on a host
// resume, and holds the whole-guest cycle — the seam-built-but-never-wired class (four earlier instances).
// COMPANION RED-PROOF: delete the `catchUp.Evaluate(ctx, "controller start")` line → this fails.
func TestR871_CatchUpWiring(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, call := range []string{"ResumeWatch", "SetCatchUpFn", "catchUpLegs", "NotifyBackupCatchUp"} {
if len(lines[call]) == 0 {
t.Errorf("%s is never called in main.go — the catch-up is built but not wired", call)
}
}
// Both triggers, by their reason argument: the start (a literal) and the resume (inside ResumeWatch's callback).
_, f, _ := slice4CallLines(t)
start := 0
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Evaluate" && len(call.Args) == 2 {
if lit, ok := call.Args[1].(*ast.BasicLit); ok && lit.Value == `"controller start"` {
start++
}
}
}
return true
})
if start != 1 {
t.Errorf("the catch-up's START trigger (Evaluate(ctx, \"controller start\")) is called %d times, want 1", start)
}
}
// The catch-up's leg table holds the three BACKUP legs and nothing else, and the off-site body it shares with the
// night never calls the update leg (that is chained only around the SCHEDULED off-site job).
// COMPANION RED-PROOF: put `stackMgr.RunUpdateLeg` inside offsiteOnly's body → "the update leg is inside".
func TestR871_CatchUpRunsNoUpdateLeg(t *testing.T) {
noop := func(context.Context) error { return nil }
legs := catchUpLegs(noop, noop, noop)
if len(legs) != 3 || legs[nightchain.LegDBDump] == nil || legs[nightchain.LegTier2] == nil || legs[nightchain.LegOffsite] == nil {
t.Fatalf("catchUpLegs = %v, want exactly db-dump, tier2, offsite", legs)
}
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
i := strings.Index(s, "offsiteOnly := withLeg(nightchain.LegOffsite,")
j := strings.Index(s[i:], "sched.Daily(\"offbox-backup\"")
if i < 0 || j < 0 {
t.Fatal("the shared off-site leg body was not found in main.go")
}
if body := s[i : i+j]; strings.Contains(body, "RunUpdateLeg") || strings.Contains(body, "chainUpdateLeg") {
t.Fatal("the update leg is inside the off-site body the catch-up runs")
}
}
@@ -0,0 +1,84 @@
package main
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
// R-970 (v0.322.0) — the box's own hub API key opens NO route on the box.
//
// Until v0.322.0 a Bearer equal to the hub key reached /api/config/ (read the whole controller.yaml with every secret,
// and REPLACE it), /api/selfupdate/ and /api/geo/ from the internet, unsigned — against `01` §4 („no inbound control
// endpoint") and §10. The test drives the REAL route table (buildMux) with real web.Server and api.Router: a password
// is set (so auth is on), the request carries the key and no session, and the planted controller.yaml must be neither
// served nor changed.
//
// RED-PROOF (2026-10-16, REPORT): with an /api/geo/ route mounted without the session check, this failed at
// „GET /api/geo/countries with the hub key answered 200".
func TestR970_HubKeyOpensNoDoor(t *testing.T) {
const hubKey = "TESTONLY-hub-key-5d1b"
const secret = "TESTONLY-tunnel-token-q9"
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
cfgPath := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\ninfrastructure:\n cf_tunnel_token: " + secret + "\n"
if err := os.WriteFile(cfgPath, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Hub.APIKey = hubKey
cfg.Paths.DataDir = filepath.Join(dir, "data")
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.SetPasswordHash("$2a$10$abcdefghijklmnopqrstuuJ1A9pDq6s9F2x1H3bQb0v8Y3wq6z2Om"); err != nil {
t.Fatal(err)
}
ws := web.NewServer(cfg, nil, nil, nil, nil, sett, nil, nil, nil, lg, "test")
ar := api.NewRouter(cfg, cfgPath, sett, nil, nil, nil, nil, nil, nil, nil, lg)
mux := buildMux(ar, ws)
// Control: auth is on — a request with no key and no session is refused, so a refusal below means something.
ctl := httptest.NewRequest(http.MethodGet, "/api/stacks", nil)
ctl.Host = "felhom.example.hu"
cw := httptest.NewRecorder()
mux.ServeHTTP(cw, ctl)
if cw.Code != http.StatusUnauthorized {
t.Fatalf("control: an anonymous /api/stacks answered %d, want 401 — auth is not on in this harness", cw.Code)
}
paths := []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash",
"/api/selfupdate/check", "/api/selfupdate/update", "/api/geo/settings", "/api/geo/countries", "/api/geo/sync"}
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
for _, p := range paths {
for _, m := range methods {
req := httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: evil.example\n"))
req.Host = "felhom.example.hu"
req.Header.Set("Authorization", "Bearer "+hubKey)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code/100 == 2 {
t.Errorf("%s %s with the hub key answered %d — the key opens a door", m, p, w.Code)
}
if strings.Contains(w.Body.String(), secret) {
t.Fatalf("%s %s with the hub key served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(cfgPath); string(got) != planted {
t.Fatal("controller.yaml was replaced through a hub-key request")
}
}
@@ -0,0 +1,57 @@
package main
import (
"fmt"
"log"
"path/filepath"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-550 — the restore record survives a restart, and an interruption is raised ONCE.
//
// Two functions so main() makes two plain calls a test can see (TestMainWiresRestoreRecord): the
// record must be loaded BEFORE the web server serves a restore page, and the event can only be pushed
// AFTER the notifier exists, which main() constructs later.
// restoreRecordFileName lives in DataDir, beside settings.json — the SSD state dir that survives a
// container recreation.
const restoreRecordFileName = "restore-status.json"
// restoreEventPusher is the notifier method main() hands in (an interface so a test can record it).
type restoreEventPusher interface {
PushEvent(eventType, severity, message string, details interface{})
}
// loadRestoreRecordAtStartup wires persistence and returns the restore the last stop interrupted.
// putBack names the apps the crash recovery put back (AppStopRecovery.PutBack, D22 / R-1195); waiting those whose
// put-back waits for their drive (AppStopRecovery.Waiting, R-1251).
func loadRestoreRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger, putBack, waiting []string) *backup.RestoreOpResult {
if mgr == nil {
return nil
}
path := filepath.Join(dataDir, restoreRecordFileName)
mgr.SetRestoreRecordPath(path)
res := mgr.LoadRestoreRecordAfterRecovery(putBack, waiting)
if logger != nil {
logger.Printf("[INFO] [backup] restore record wired: %s (interrupted at startup: %t)", path, res != nil)
}
return res
}
// reportInterruptedRestore raises restore_interrupted (warning; for the household — hub v0.117.0).
func reportInterruptedRestore(p restoreEventPusher, res *backup.RestoreOpResult) {
if p == nil || res == nil {
return
}
if res.PutBack || res.PutBackWaiting {
// D22 / R-1195: the data is back as before the restore — the record's own sentence, in the box's language.
// R-1251: or it is put back when the drive is there; „start it again" would restore the backup over it.
p.PushEvent("restore_interrupted", "warning", res.Stack+": "+res.Message,
map[string]any{"stack": res.Stack, "op": res.Op, "put_back": res.PutBack, "put_back_waiting": res.PutBackWaiting})
return
}
p.PushEvent("restore_interrupted", "warning",
fmt.Sprintf("A(z) %s visszaállítása megszakadt, mert a doboz újraindult — indítsd el újra.", res.Stack),
map[string]any{"stack": res.Stack, "op": res.Op})
}
@@ -0,0 +1,82 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
type recordedPush struct{ eventType, severity, message string }
type pushRecorder struct{ got []recordedPush }
func (r *pushRecorder) PushEvent(et, sev, msg string, _ interface{}) {
r.got = append(r.got, recordedPush{et, sev, msg})
}
// R-550 — the functions main() calls: a restore left in flight is found at startup and raised as
// restore_interrupted (warning), exactly once.
//
// RED-PROOF: make loadRestoreRecordAtStartup skip LoadRestoreRecord → nothing is pushed →
// "an interrupted restore raised no restore_interrupted".
func TestRestoreRecordAtStartup_RaisesInterruptedOnce(t *testing.T) {
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
before := new(backup.Manager)
before.SetRestoreRecordPath(dir + "/" + restoreRecordFileName)
before.BeginRestoreOp("restore", "gokapi") // the box stops here
rec := &pushRecorder{}
reportInterruptedRestore(rec, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
if len(rec.got) != 1 || rec.got[0].eventType != "restore_interrupted" || rec.got[0].severity != "warning" {
t.Fatalf("an interrupted restore raised no restore_interrupted (warning): %+v", rec.got)
}
again := &pushRecorder{}
reportInterruptedRestore(again, loadRestoreRecordAtStartup(new(backup.Manager), dir, lg, nil, nil))
if len(again.got) != 0 {
t.Fatalf("the same interruption was raised again on the next start: %+v", again.got)
}
}
// The seam must be CALLED from main(): both functions, in that order.
func TestMainWiresRestoreRecord(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatalf("parse main.go: %v", err)
}
pos := map[string]token.Pos{}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "main" {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok {
if id.Name == "loadRestoreRecordAtStartup" || id.Name == "reportInterruptedRestore" {
if _, seen := pos[id.Name]; !seen {
pos[id.Name] = c.Pos()
}
}
}
}
return true
})
}
load, okL := pos["loadRestoreRecordAtStartup"]
report, okR := pos["reportInterruptedRestore"]
if !okL || !okR {
t.Fatalf("main() does not call both restore-record functions (load=%t report=%t) — the persistence is an inert seam", okL, okR)
}
if load > report {
t.Fatalf("main() reports before it loads — nothing would ever be reported")
}
}
@@ -0,0 +1,33 @@
package main
import (
"log"
"path/filepath"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
const runRecordFileName = "appdata-run.json"
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
if mgr == nil {
return time.Time{}
}
path := filepath.Join(dataDir, runRecordFileName)
mgr.SetRunRecordPath(path)
cut := mgr.LoadRunRecord()
if logger != nil {
if !cut.IsZero() {
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
} else {
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
}
}
return cut
}
@@ -0,0 +1,59 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519: the function main() calls finds a cut run.
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
before := new(backup.Manager)
before.SetRunRecordPath(dir + "/" + runRecordFileName)
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
writeRunning(t, dir+"/"+runRecordFileName)
after := new(backup.Manager)
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
t.Fatal("a cut run was not found at startup")
}
}
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
// RED-PROOF: delete the call in main() → this fails.
func TestMainWiresRunRecord(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
found = true
}
}
return true
})
if !found {
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
}
}
func writeRunning(t *testing.T, path string) {
t.Helper()
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
if err := writeFileForTest(path, b); err != nil {
t.Fatal(err)
}
}
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }
@@ -0,0 +1,122 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Update arc slice 4 — the seams are WIRED, in the order that makes them true. A seam built and never
// wired has shipped in this project seven times; each assertion here fails if a line moves or goes.
func slice4CallLines(t *testing.T) (map[string][]int, *ast.File, *token.FileSet) {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
lines := map[string][]int{}
ast.Inspect(f, func(n ast.Node) bool {
if call, ok := n.(*ast.CallExpr); ok {
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
lines[sel.Sel.Name] = append(lines[sel.Sel.Name], fset.Position(call.Pos()).Line)
} else if id, ok := call.Fun.(*ast.Ident); ok {
lines[id.Name] = append(lines[id.Name], fset.Position(call.Pos()).Line)
}
}
return true
})
return lines, f, fset
}
func TestSlice4_UpdateGuardsAreWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdateGuards"]) == 0 {
t.Fatal("SetUpdateGuards is never called — every update would be refused as unwired")
}
if len(lines["RecoverUpdates"]) == 0 || len(lines["runBootReconcile"]) == 0 {
t.Fatal("RecoverUpdates or the boot reconciler is missing from main.go")
}
if lines["RecoverUpdates"][0] > lines["runBootReconcile"][0] {
t.Errorf("RecoverUpdates (line %d) must run BEFORE the boot reconciler (line %d), or the sweep can start a half-updated app",
lines["RecoverUpdates"][0], lines["runBootReconcile"][0])
}
if len(lines["ResumeInterruptedUpdates"]) == 0 || lines["ResumeInterruptedUpdates"][0] < lines["SetUpdateGuards"][0] {
t.Error("ResumeInterruptedUpdates must run AFTER SetUpdateGuards — a resumed update that fails must be able to HOLD")
}
}
func funcBodySource(t *testing.T, f *ast.File, fset *token.FileSet, recv, name string) string {
t.Helper()
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != name || fn.Body == nil {
continue
}
if recv != "" {
if fn.Recv == nil {
continue
}
id, ok := fn.Recv.List[0].Type.(*ast.Ident)
if !ok || id.Name != recv {
continue
}
}
var names []string
ast.Inspect(fn.Body, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok {
names = append(names, sel.Sel.Name)
}
return true
})
return strings.Join(names, " ")
}
t.Fatalf("func %s.%s not found", recv, name)
return ""
}
func TestSlice4_BootSweepAndDeadAppAlarmKnowAboutUpdates(t *testing.T) {
_, f, fset := slice4CallLines(t)
if !strings.Contains(funcBodySource(t, f, fset, "bootDriveGate", "MayStart"), "IsUpdating") {
t.Error("the boot sweep must refuse an app a guarded update is moving")
}
if !strings.Contains(funcBodySource(t, f, fset, "", "scanDeployedAppRunStates"), "UpdatingStacks") {
t.Error("the dead-app alarm must not count an app the update itself is recreating")
}
}
// The shared start gate names WHICH hold it is honouring — an operator reading the boot log must not
// be told a restore failed when an update did.
func TestSlice4_DriveStartGate_NamesAnUpdateHold(t *testing.T) {
sett := holdTestSettings(t)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "bookstack", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed}); err != nil {
t.Fatal(err)
}
ok, why := driveStartGate{sett: sett}.MayStart("bookstack")
if ok || !strings.Contains(why, "held after a failed update") {
t.Errorf("ok=%v why=%q", ok, why)
}
}
// v0.238.1: the backup manager must be told which apps are mid-update, or the nightly legs overwrite a
// restore point during an update's health wait (found live, Scenario F).
func TestSlice4_UpdatingCheckIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdatingCheck"]) == 0 {
t.Fatal("backupMgr.SetUpdatingCheck is never called — the nightly legs cannot see an update in progress")
}
}
// R-645 (09 §3 decision 142): the backup manager must be told which apps are not running their pinned
// version, or the night backup writes a just-failed definition over the good unit after a hand-lifted hold.
func TestR645_VersionCheckIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetVersionCheck"]) == 0 {
t.Fatal("backupMgr.SetVersionCheck is never called — the night backup cannot see an app off its pin")
}
}
@@ -0,0 +1,27 @@
package main
import (
"os"
"strings"
"testing"
)
// The stray-copy fix: main.go removes every leftover second-drive copy container BEFORE the scheduler and the web
// server start (either could begin a backup or a restore beside a lost copy). RED-PROOF (2026-10-22 night): the call
// removed, or moved below sched.Start, → this fails.
func TestStrayCopy_ClearIsWiredBeforeTheScheduler(t *testing.T) {
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
s := string(src)
c := strings.Index(s, "backup.ClearLeftoverTier2Copies(ctx)")
st := strings.Index(s, "sched.Start(ctx)")
ws := strings.Index(s, "web.NewServer(")
if c < 0 || st < 0 || ws < 0 {
t.Fatalf("missing: clear=%d sched.Start=%d web.NewServer=%d", c, st, ws)
}
if c > st || c > ws {
t.Fatal("the leftover-copy removal must run before the scheduler and the web server start")
}
}
@@ -0,0 +1,40 @@
package main
import (
"os"
"strings"
"testing"
)
// v0.264.0. The update-event sink and the R-646 backfill are both CALLED from main.go (an AST walk —
// a comment naming them would not count). A sink built and never wired would fail silently: no event,
// no mail, no error — this project's commonest defect, four recorded instances.
//
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateEventSink call in main.go — this fails.
func TestUpdateEventSinkIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUpdateEventSink"]) == 0 {
t.Fatal("SetUpdateEventSink is never called — an undone or held update would tell nobody")
}
for _, callee := range []string{"NotifyAppUpdateUndone", "NotifyAppUpdateHeld", "RestoreHoldForLang"} {
if len(lines[callee]) == 0 {
t.Errorf("main.go must call %s inside the sink", callee)
}
}
if len(lines["BackfillAppliedMeta"]) == 0 || len(lines["AdoptPins"]) == 0 || lines["BackfillAppliedMeta"][0] < lines["AdoptPins"][0] {
t.Error("BackfillAppliedMeta (R-646) must be called, after AdoptPins")
}
}
// R-647 (2) — the held event carries the copy_holds KEY, never the Hungarian phrase: the hub prints the
// raw details as the household mail's `Note:` line. COMPANION RED-PROOF (REPORT.md): wire
// UpdateCopyHolds back — this fails.
func TestR647_HeldEventCarriesTheCopyHoldsKey(t *testing.T) {
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "d.CopyHolds = backupMgr.UpdateCopyHoldsKey(") {
t.Fatal("main.go must fill AppUpdateDetails.CopyHolds with UpdateCopyHoldsKey (the key), not the Hungarian phrase")
}
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"errors"
"os"
"strings"
"testing"
)
// v0.271.0 — the automatic update leg (`09` §6.4 part 7).
// TestChainUpdateLeg_EveryPath — the leg runs after the off-site half on EVERY path it can take:
// nothing to do (no target), ran ok, returned an error (incl. every RunOffboxBackup early return, which
// returns to here), and panicked. The off-site error reaches the scheduler unchanged; a panic becomes one.
//
// COMPANION RED-PROOF (REPORT.md): call the leg after `return offsite(ctx)` instead of in the defer —
// the error and panic cases fail at "the leg did not run after the off-site half".
func TestChainUpdateLeg_EveryPath(t *testing.T) {
boom := errors.New("sftp: connection refused")
cases := []struct {
name string
offsite func(context.Context) error
wantErr string
}{
{"no off-site target (nothing to do)", func(context.Context) error { return nil }, ""},
{"off-site ran ok", func(context.Context) error { return nil }, ""},
{"off-site failed / an early return", func(context.Context) error { return boom }, "connection refused"},
{"off-site panicked", func(context.Context) error { panic("nil map") }, "panicked"},
}
for _, c := range cases {
ran := 0
err := chainUpdateLeg(context.Background(), c.offsite, func(context.Context) { ran++ })
if ran != 1 {
t.Errorf("%s: the leg did not run after the off-site half (ran %d times)", c.name, ran)
}
if c.wantErr == "" && err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
if c.wantErr != "" && (err == nil || !strings.Contains(err.Error(), c.wantErr)) {
t.Errorf("%s: error %v, want one containing %q", c.name, err, c.wantErr)
}
}
}
// TestUpdateLegIsWiredAtStartup — the leg, its chain and the gate's interlock are CALLED from main.go (an
// AST walk; a comment naming them would not count). A seam built and never wired is this project's
// commonest defect.
//
// COMPANION RED-PROOF (REPORT.md): comment out the SetUpdateLeg call — this fails.
func TestUpdateLegIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, callee := range []string{"SetUpdateLeg", "chainUpdateLeg", "RunUpdateLeg", "FreshWholeCopy"} {
if len(lines[callee]) == 0 {
t.Errorf("main.go never calls %s — the automatic update leg is not wired", callee)
}
}
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "Enabled: sett.GetAppUpdateUnattended,") {
t.Error("the leg's switch must be the household's app_update.unattended (sett.GetAppUpdateUnattended)")
}
if !strings.Contains(string(src), "UpdateLegFn: stackMgr.UpdateLegState") {
t.Error("the quiesce loop must be given stackMgr.UpdateLegState (decision 20's interlock)")
}
// the chain must sit INSIDE the offbox-backup job, not beside it
i := strings.Index(string(src), `sched.Daily("offbox-backup"`)
if i < 0 || !strings.Contains(string(src)[i:i+600], "chainUpdateLeg(") {
t.Error("the offbox-backup job must run through chainUpdateLeg")
}
}
// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk).
// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails.
func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
if len(lines["SetUndoCopyRemover"]) == 0 {
t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever")
}
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"context"
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
)
// qbLimitShare is qBittorrent's default share of the measured upload line — CC's pick, open for the operator (decide
// row in felhom.eu STATUS): 50 %.
const qbLimitShare = 0.5
// baselineMinMinutes: an app the box cannot ask is judged only once its history holds ~20 hours of minutes.
const baselineMinMinutes = 1200
// newUpwatch builds the upload and CPU watch (internal/upwatch; felhom.eu `08` §9). It does not start it.
func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore,
notifier *notify.Notifier, line agentLine, logger *log.Logger) *upwatch.Service {
box := mediawire.DockerBox{}
qb := &upwatch.QBittorrent{Box: box}
tr := &upwatch.Transmission{Box: box}
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
Measured: line.Measured, Log: logger,
// R-1250: a box that seeds all day is line-tested by holding its torrent app low for one test on request.
Torrents: []upwatch.Holdable{qb, tr}, TorrentRunning: box.Running, LineWaiting: line.Waiting, LineRun: line.Run}
w := &upwatch.Watcher{
Source: &upwatch.DockerAPI{},
Explainers: map[string]upwatch.Explainer{
"qbittorrent": qb,
"transmission": tr,
"plex": &upwatch.Plex{Token: func() string { return mediawire.PlexToken(box) }, URL: mediawire.PlexURL},
},
Rules: upwatch.DefaultRules(),
Line: svc.Line,
Busy: func(stack string) bool {
if backupMgr != nil && backupMgr.IsRunning() {
return true
}
if st, ok := stackMgr.GetStack(stack); ok && st != nil && (st.Updating || st.Deploying) {
return true
}
return false
},
Emit: notifier.NotifyUnexplained,
Log: logger,
}
if metricsStore != nil {
w.Baseline = dailyBaseline(metricsStore)
}
svc.Watcher = w
return svc
}
// agentLine is the host's line test as the watch uses it; every field nil without a local API.
type agentLine struct {
Measured func(context.Context) (float64, time.Time, bool)
Waiting func(context.Context) bool // waiting_for_quiet (agent ≥ 0.165.0, R-1250)
Run func(context.Context) (upwatch.LineRunResult, error) // POST /host/line-test/run
}
// agentLineTest reads the host's weekly line test through the agent's local API (agent ≥ 0.164.0; an older agent
// answers 404 → not measured, and never waiting). Empty without a local API.
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) agentLine {
if endpoint == "" || token == "" {
return agentLine{}
}
ac, err := agentapi.New(endpoint, token, fingerprint)
if err != nil {
logger.Printf("[WARN] [upwatch] agent client init failed (%v) — no line test; qBittorrent gets no limit", err)
return agentLine{}
}
read := func(ctx context.Context) (agentapi.LineTestState, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return ac.LineTest(cctx)
}
return agentLine{
Measured: func(ctx context.Context) (float64, time.Time, bool) {
st, err := read(ctx)
if err != nil {
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
return 0, time.Time{}, false
}
return st.Mbps, st.MeasuredAtTime(), st.Present
},
Waiting: func(ctx context.Context) bool {
st, err := read(ctx)
return err == nil && st.WaitingForQuiet
},
Run: func(ctx context.Context) (upwatch.LineRunResult, error) {
r, err := ac.LineTestRun(ctx)
return upwatch.LineRunResult{OK: r.OK, Mbps: r.Mbps, Refused: r.Refused, Error: r.Error}, err
},
}
}
// dailyBaseline caches each app's p95 for a day (a 7-day query is a few hundred thousand rows).
func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bool) {
type entry struct {
at time.Time
p95 float64
ok bool
}
var mu sync.Mutex
cache := map[string]entry{}
return func(stack string, containers []string) (float64, bool) {
mu.Lock()
defer mu.Unlock()
if e, ok := cache[stack]; ok && time.Since(e.at) < 24*time.Hour {
return e.p95, e.ok
}
p95, minutes, err := ms.StackTxP95Mbps(containers, time.Now().Add(-7*24*time.Hour))
e := entry{at: time.Now(), p95: p95, ok: err == nil && minutes >= baselineMinMinutes}
cache[stack] = e
return e.p95, e.ok
}
}
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends. A torrent limit a
// stopped controller left at the line-test value is put back first (R-1250), before any rule reads it.
func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger) {
svc.RecoverHold(ctx)
svc.RefreshMeasured(ctx)
go svc.Watcher.Run(ctx, time.Minute)
go func() {
t := time.NewTicker(15 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
svc.SeedLineTestNow(ctx) // R-1250: holds the torrent app only while the agent waits for quiet
svc.QBLimitNow(ctx)
}
}
}()
l, ok := svc.Line()
logger.Printf("[INFO] [upwatch] upload and CPU watch started (line %s, qBittorrent share %.0f %%)", lineText(l, ok), qbLimitShare*100)
}
func lineText(l float64, ok bool) string {
if !ok {
return "unknown — no weekly line test yet and no counting off-site push"
}
return upwatch.MbpsText(int64(l*1e6/8)) + " Mbit/s"
}
@@ -0,0 +1,102 @@
package main
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
)
// The upload watch is built, started, handed to the off-site push BEFORE the scheduler can start one, and its view
// handed to the pages — a seam built and never wired is this project's commonest defect.
// RED-PROOF: delete the SetOffboxPushObserver line → fails; move it below the scheduler's start → fails; pass nil
// instead of agentLineTest(...) → the qBittorrent limit never gets a tested line → fails.
func TestUpwatchIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, fn := range []string{"newUpwatch", "agentLineTest", "SetOffboxPushObserver", "startUpwatch", "SetUpwatchView"} {
if len(lines[fn]) == 0 {
t.Fatalf("main.go never calls %s", fn)
}
}
if lines["SetOffboxPushObserver"][0] > lines["Daily"][0] {
t.Fatalf("SetOffboxPushObserver (line %d) comes after the first scheduled job (line %d)", lines["SetOffboxPushObserver"][0], lines["Daily"][0])
}
}
// newUpwatch asks the three apps the spike proved, reports through the notifier, and reads exact counters.
// RED-PROOF: drop the transmission entry → fails.
func TestNewUpwatch_ExplainersAndEmit(t *testing.T) {
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), agentLine{}, lg)
for _, a := range []string{"qbittorrent", "transmission", "plex"} {
if _, ok := svc.Watcher.Explainers[a]; !ok {
t.Errorf("no explainer for %s", a)
}
}
if svc.Watcher.Emit == nil || svc.Watcher.Line == nil || svc.QB == nil || svc.QBShare != 0.5 {
t.Fatalf("service not wired: %+v", svc)
}
if _, ok := svc.Watcher.Source.(*upwatch.DockerAPI); !ok {
t.Fatal("the watch must read the Engine API's exact counters, not docker stats' text")
}
// R-1250: both torrent apps can be held, qBittorrent first; without a local API there is no hold at all.
if len(svc.Torrents) != 2 || svc.Torrents[0].Stack() != "qbittorrent" || svc.Torrents[1].Stack() != "transmission" ||
svc.TorrentRunning == nil || svc.LineWaiting != nil || svc.LineRun != nil {
t.Fatalf("line-test hold not wired: %d torrents, waiting=%v run=%v", len(svc.Torrents), svc.LineWaiting != nil, svc.LineRun != nil)
}
if svc.Watcher.Baseline != nil {
t.Fatal("no metrics store, yet a baseline: an app with no explainer would be judged without history")
}
}
// R-1250: the start-up check puts a held limit back BEFORE the first rule reads it, and every tick runs the hold
// before the 50 % rule. Asserted on startUpwatch's source order.
// RED-PROOF: move svc.RecoverHold below the ticker, or drop SeedLineTestNow → fails.
func TestStartUpwatchRecoversTheHoldFirst(t *testing.T) {
src, err := os.ReadFile("upwatch_wiring.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
i := strings.Index(body, "func startUpwatch(")
if i < 0 {
t.Fatal("no startUpwatch")
}
body = body[i:]
rec, ticker := strings.Index(body, "svc.RecoverHold(ctx)"), strings.Index(body, "time.NewTicker(")
seed, rule := strings.Index(body, "svc.SeedLineTestNow(ctx)"), strings.Index(body, "svc.QBLimitNow(ctx)")
if rec < 0 || rec > ticker {
t.Fatalf("RecoverHold at %d, the ticker at %d: a held limit must go back before anything else runs", rec, ticker)
}
if seed < 0 || seed > rule {
t.Fatalf("SeedLineTestNow at %d, QBLimitNow at %d: the hold must run before the 50 %% rule", seed, rule)
}
}
// With a local API, agentLineTest yields all three halves, and newUpwatch hands the hold both agent calls.
// RED-PROOF: drop LineRun from newUpwatch's Service, or Run from agentLineTest → fails.
func TestUpwatchHoldGetsBothAgentCalls(t *testing.T) {
lg := log.New(io.Discard, "", 0)
line := agentLineTest("127.0.0.1:1", "token-canary", strings.Repeat("ab", 32), lg)
if line.Measured == nil || line.Waiting == nil || line.Run == nil {
t.Fatalf("agentLineTest with a local API: measured=%v waiting=%v run=%v", line.Measured != nil, line.Waiting != nil, line.Run != nil)
}
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), line, lg)
if svc.Measured == nil || svc.LineWaiting == nil || svc.LineRun == nil {
t.Fatalf("newUpwatch dropped an agent call: measured=%v waiting=%v run=%v", svc.Measured != nil, svc.LineWaiting != nil, svc.LineRun != nil)
}
}
@@ -57,7 +57,6 @@ stacks:
- "cloudflared"
- "felhom-controller"
- "filebrowser"
update_window: "03:00-05:00"
compose_command: ""
# --- Backup ---
+3 -3
View File
@@ -1,12 +1,12 @@
module gitea.dooplex.hu/admin/felhom-controller
go 1.24.0
go 1.27.2
require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.58.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.45.0
)
@@ -18,7 +18,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+6 -6
View File
@@ -18,8 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
@@ -27,10 +27,10 @@ golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+3 -1
View File
@@ -22,7 +22,9 @@
# is a Finder Settings -> Sidebar toggle). Treat sidebar discovery as an OPEN
# question, not a shipped feature.
# Evidence: felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md.
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# 1.2.0 (R-972): Alpine 3.24.2, pinned by digest — the same base as the hub. 3.21 carried 48 fixable high findings
# (audits/security-review-2026-10-12/scanners/). Same packages; scanner before → after in the controller's v0.323.0 CHANGELOG.
FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
# samba = smbd + nmbd + smbpasswd/testparm (meta-package proven installable in the spike);
# wsdd = WS-Discovery daemon; tini = a proper PID1 to reap nmbd/wsdd/avahi and forward signals;
@@ -0,0 +1,108 @@
// Package aftercrash runs the apps' after_unclean_boot blocks ONCE per controller start that followed an unclean
// host boot (R-1007, `09` §3 decision 236).
//
// The fact is crashboot's (the host crash guard, read through the agent): the same verdict that holds the app mails
// for CrashGrace. Unknown is a normal boot — a repair never runs on a guess. A normal boot never runs it. After a crash
// boot it waits CrashGrace from the controller's start (the apps come up slowly after a crash), then runs each app's
// block one after the other and reports each app's counts. It never stops an app; a failed run is reported, not
// retried. Pinned by aftercrash_test.go.
package aftercrash
import (
"log"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Verdict is crashboot.Grace's Decided (a seam for tests).
type Verdict func(now time.Time) (resolved, crash bool, bootAt time.Time)
// Runner does the one run. Every func field is required; Logger may be nil.
type Runner struct {
Start time.Time
Verdict Verdict
Apps func() []string
Run func(name string, bootAt time.Time) stacks.AfterUncleanBootResult
Report func(name string, bootAt time.Time, res stacks.AfterUncleanBootResult)
Logger *log.Logger
// Wait is how long after Start a crash-boot run waits (default crashboot.CrashGrace).
Wait time.Duration
mu sync.Mutex
done bool
}
// New builds the runner on the controller's crash-boot grace.
func New(g *crashboot.Grace, apps func() []string,
run func(string, time.Time) stacks.AfterUncleanBootResult,
report func(string, time.Time, stacks.AfterUncleanBootResult), logger *log.Logger) *Runner {
return &Runner{Start: g.Start(), Verdict: g.Decided, Apps: apps, Run: run, Report: report, Logger: logger}
}
// Done reports whether this start's decision has been taken (ran, or decided not to).
func (r *Runner) Done() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.done
}
// Tick is called periodically (the scheduler). It returns true once the run has happened or been ruled out.
func (r *Runner) Tick(now time.Time) bool {
r.mu.Lock()
if r.done {
r.mu.Unlock()
return true
}
resolved, crash, bootAt := r.Verdict(now)
if !resolved {
r.mu.Unlock()
return false
}
if !crash {
r.done = true
r.mu.Unlock()
r.logf("[INFO] [aftercrash] a normal (or unknown) boot — no app repair after an unclean boot (R-1007)")
return true
}
wait := r.Wait
if wait <= 0 {
wait = crashboot.CrashGrace
}
if now.Sub(r.Start) < wait {
r.mu.Unlock()
return false
}
r.done = true // set BEFORE the run: a slow or failing run is never started twice
r.mu.Unlock()
apps := r.Apps()
r.logf("[INFO] [aftercrash] the host's boot at %s followed an unclean stop — %d app(s) with an after_unclean_boot block (R-1007)",
bootAt.UTC().Format(time.RFC3339), len(apps))
for _, name := range apps {
res := r.Run(name, bootAt)
if res.Skipped != "" {
r.logf("[WARN] [aftercrash] %s: skipped — %s", name, res.Skipped)
} else {
r.logf("[INFO] [aftercrash] %s: found %d empty file(s), repaired %d, still broken %d, lost %d (checked=%t) in %s%s",
name, res.Found, res.Repaired, res.StillBroken, res.Lost, res.LostChecked, res.Duration.Round(time.Millisecond), problem(res.Problem))
}
r.Report(name, bootAt, res)
}
return true
}
func problem(p string) string {
if p == "" {
return ""
}
return " — problem: " + p
}
func (r *Runner) logf(format string, args ...any) {
if r.Logger != nil {
r.Logger.Printf(format, args...)
}
}
@@ -0,0 +1,73 @@
package aftercrash
import (
"context"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crashboot"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-1007 (decision 236): the repair runs ONLY after an unclean host boot that THIS start followed, only after the
// crash grace, and only once. A normal boot and an unknown fact never run it. The verdict is crashboot's own (the
// real Grace with a fake probe), so these tests assert the consequence: was the app's block run?
var start = time.Date(2026, 10, 14, 12, 8, 0, 0, time.UTC)
func probe(f crashboot.Fact, err error) crashboot.Probe {
return func(context.Context) (crashboot.Fact, error) { return f, err }
}
func runner(p crashboot.Probe) (*Runner, *[]string) {
var ran []string
g := crashboot.New(start, p, nil)
r := New(g, func() []string { return []string{"immich"} },
func(name string, _ time.Time) stacks.AfterUncleanBootResult {
ran = append(ran, name)
return stacks.AfterUncleanBootResult{}
},
func(string, time.Time, stacks.AfterUncleanBootResult) {}, nil)
return r, &ran
}
// RED-PROOF: drop the `now.Sub(r.Start) < wait` check → the run happens at +1 min → fails.
// RED-PROOF: drop `r.done = true` before the run → a second tick runs it again → fails.
func TestAfterCrash_RunsOnceAfterTheGraceOnAnUncleanBootThisStartFollowed(t *testing.T) {
r, ran := runner(probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-40 * time.Second)}, nil))
for _, at := range []time.Duration{0, time.Minute, 14 * time.Minute} {
r.Tick(start.Add(at))
}
if len(*ran) != 0 {
t.Fatalf("ran inside the crash grace: %v", *ran)
}
r.Tick(start.Add(crashboot.CrashGrace + time.Second))
r.Tick(start.Add(crashboot.CrashGrace + time.Minute))
if len(*ran) != 1 {
t.Fatalf("ran %d time(s), want exactly once after the grace", len(*ran))
}
}
// RED-PROOF: in Tick, treat `!crash` like a crash (drop the early return) → a normal boot runs the repair → fails.
func TestAfterCrash_NeverOnANormalAnUnknownOrAnOldUncleanBoot(t *testing.T) {
cases := map[string]crashboot.Probe{
"normal boot": probe(crashboot.Fact{Known: true, Unclean: false, BootAt: start.Add(-time.Minute)}, nil),
"unknown (no crash guard)": probe(crashboot.Fact{}, nil),
"unknown (agent unreachable)": probe(crashboot.Fact{}, context.DeadlineExceeded),
"no agent at all": nil,
"an unclean boot days ago": probe(crashboot.Fact{Known: true, Unclean: true, BootAt: start.Add(-72 * time.Hour)}, nil),
"an unclean boot with no time": probe(crashboot.Fact{Known: true, Unclean: true}, nil),
}
for name, p := range cases {
r, ran := runner(p)
for at := time.Duration(0); at <= 2*crashboot.CrashGrace; at += 30 * time.Second {
r.Tick(start.Add(at))
}
if len(*ran) != 0 {
t.Errorf("%s: the repair ran", name)
}
if !r.Done() {
t.Errorf("%s: the decision was never taken", name)
}
}
}
+68 -1
View File
@@ -25,6 +25,9 @@ type BackupTierInfo struct {
Target string `json:"target"`
CadenceSeconds int64 `json:"cadence_seconds"`
Primary bool `json:"primary"`
// Storage (agent >= v0.131.0, R-518): "present" | "absent" | "unknown"; "" on an older agent.
// Only "absent" licenses skipping a tier — unknown and legacy are treated as present.
Storage string `json:"storage,omitempty"`
}
// TiersResponse mirrors the agent's GET /backup/tiers payload.
@@ -61,6 +64,21 @@ func targetQuery(target string) string {
return "?target=" + url.QueryEscape(target)
}
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
func backupStartQuery(target string, manual bool) string {
q := url.Values{}
if target != "" {
q.Set("target", target)
}
if manual {
q.Set("trigger", "manual")
}
if len(q) == 0 {
return ""
}
return "?" + q.Encode()
}
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
@@ -77,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
return c.StartBackupForTrigger(ctx, target, false)
}
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
var out BackupResponse
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
if err != nil {
return out, err
}
@@ -102,6 +127,48 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
return out, nil
}
// BackupJobsInFlight (R-921) lists the tiers whose backup job the agent holds in flight right now — phase
// running or snapshotted, the agent's own definition (localapi backupInFlight). A job in flight on another
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
// backing up. It does NOT see the agent's host-wide heavy-operation gate; HostBusy does (newer agents).
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
tiers, err := c.BackupTiers(ctx)
if errors.Is(err, ErrTiersUnsupported) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []string
for _, t := range tiers.Tiers {
st, err := c.BackupStatusFor(ctx, t.Target)
if err != nil {
return nil, err
}
if st.Phase == PhaseRunning || st.Phase == PhaseSnapshotted {
out = append(out, t.Target)
}
}
return out, nil
}
// HostBusy (R-921) reads GET /backup/status's host_busy: whether the agent's host-wide heavy-operation gate is held
// right now (another guest's backup, the restore test, a night OS step …) — the agent's second reason to refuse
// POST /backup. known=false: the agent does not serve the field (older than the agent release that adds it) — no
// information, never „free". An error is returned; the caller fails toward backing up.
func (c *Client) HostBusy(ctx context.Context) (busy bool, what string, known bool, err error) {
st, err := c.BackupStatusFor(ctx, "")
if err != nil {
return false, "", false, err
}
if st.HostBusy == nil {
return false, "", false, nil
}
return st.HostBusy.Busy, st.HostBusy.What, true, nil
}
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
type SetBackupTargetResponse struct {
Target string `json:"target"`
+160 -14
View File
@@ -199,6 +199,35 @@ type StatusResponse struct {
JobID string `json:"job_id"`
Error string `json:"error"`
Backup *BackupRecord `json:"backup,omitempty"`
// Tiers (agent >= v0.131.0, R-517) — per tier: newest success, last attempt, storage presence.
// Absent on an older agent; the page then falls back to the single latest record.
Tiers []TierBackupState `json:"tiers,omitempty"`
// HostBusy (R-921, agent Unreleased 2026-10-19) — whether the agent's host-wide heavy-operation gate is held
// right now (then POST /backup answers 409). nil = an older agent that does not serve it.
HostBusy *HostBusyState `json:"host_busy,omitempty"`
}
// HostBusyState mirrors the agent's localapi.HostBusyState.
type HostBusyState struct {
Busy bool `json:"busy"`
What string `json:"what,omitempty"`
}
// TierBackupState mirrors the agent's localapi.TierBackupState.
type TierBackupState struct {
Target string `json:"target"`
Primary bool `json:"primary"`
Storage string `json:"storage"` // present | absent | unknown
LastSuccess *BackupRecord `json:"last_success,omitempty"`
LastSuccessSource string `json:"last_success_source,omitempty"` // record | storage
LastAttempt *TierAttempt `json:"last_attempt,omitempty"`
}
// TierAttempt is a tier's newest recorded attempt.
type TierAttempt struct {
StartedAt string `json:"started_at"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// BackupRecord mirrors the agent's hub.Backup — one whole-guest vzdump/PBS backup result. The
@@ -235,6 +264,9 @@ const (
PhaseRunning = "running"
PhaseDone = "done"
PhaseFailed = "failed"
// PhaseSnapshotted (8B.2): the storage snapshot is taken, the upload still runs and still holds the
// guest — the agent counts it as IN FLIGHT (felhom-agent localapi backupInFlight).
PhaseSnapshotted = "snapshotted"
)
// BackupDue reports whether a policy-scheduled backup is due for this guest (the quiesce trigger).
@@ -252,15 +284,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
var out BackupResponse
body, err := c.post(ctx, "/backup", struct{}{})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
}
return out, nil
return c.StartBackupForTrigger(ctx, "", false)
}
// BackupStatus reports the current/last backup job phase for this guest.
@@ -340,8 +364,18 @@ type DiskInfo struct {
// agent predates the field — the disk-health card + 6h degradation check feature-detect on this and
// render "Nincs adat" (never alarm) when nil. See DiskVerdictFor.
Smart *SmartSummary `json:"smart,omitempty"`
// Physical-disk identity (agent v0.157.0, `16` §3): kind, the keys of the physical disks under the place,
// proven|unknown + reason. Empty from an older agent or for an absent drive.
Kind string `json:"kind,omitempty"` // system | internal | disk | partition | network (felhom.eu `16` §3)
PhysicalDisks []string `json:"physical_disks,omitempty"`
Identity string `json:"identity,omitempty"`
IdentityReason string `json:"identity_reason,omitempty"`
}
// PlaceKindInternal is the agent's kind for the one-disk box's internal data place („Belső tárhely", agent ≥ 0.158.0,
// felhom.eu architecture/16 §5): a host mount on the system disk, bound at /mnt/felhom-drives/belso.
const PlaceKindInternal = "internal"
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
func (d DiskInfo) FSUUID() string {
@@ -375,6 +409,10 @@ type FormatResult struct {
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
// FSUUID (R-25, agent >= the R-25 agent half) is the UUID of the filesystem the agent just made,
// sent only while the format's durable id still names the formatted device. "" = not verified, or
// an agent too old to send it. The init flow mounts THIS rather than re-resolving from the /dev path.
FSUUID string `json:"fs_uuid,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -400,6 +438,36 @@ var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup de
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// ErrNoDurableID is the agent's anti-retarget refusal of a blank format (R-1069): the device has no durable id to
// bind the format to — a virtual disk passed on a virtio bus without a serial has no /dev/disk/by-id link. The agent's
// rule is a safety rule and stays; this only lets the dashboard say it in plain words. Matched on the agent's own
// refusal text (felhom-agent internal/localapi/disks.go „format refused: device has no durable id"); if that text
// ever changes, the refusal degrades to the raw message it was before. Pinned by TestFormatDisk_NoDurableIDIsTyped.
var ErrNoDurableID = fmt.Errorf("agentapi: format refused — the device has no durable id (path-only formats are not permitted)")
// SameDiskResult is the agent's answer to POST /disks/same-disk (v0.157.0). Act on Proven first:
// Proven+Different = a counted second copy; Proven+!Different = the same disk; !Proven = unknown
// (Reason vm|network|usb_bridge|weak_wwn|no_serial|unmapped).
type SameDiskResult struct {
Different bool `json:"different"`
Proven bool `json:"proven"`
Reason string `json:"reason,omitempty"`
}
// SameDisk asks the agent whether two guest paths sit on the same physical disk. Gate it with
// Supports(FeatureSameDisk) — an older agent answers 404.
func (c *Client) SameDisk(ctx context.Context, a, b string) (SameDiskResult, error) {
var out SameDiskResult
raw, err := c.post(ctx, "/disks/same-disk", map[string]string{"a": a, "b": b})
if err != nil {
return out, err
}
if err := json.Unmarshal(raw, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/same-disk: %w", err)
}
return out, nil
}
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
var out DisksResponse
@@ -429,8 +497,37 @@ type DiskCandidate struct {
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
AlreadyMounted bool `json:"already_mounted,omitempty"`
// Partitions (agent ≥ 0.158.0, felhom.eu architecture/16 §6): each partition of this disk as its own place —
// state empty („format this part"), filesystem („use this part" when mountable; format through the confirmed
// path), in_use (already a Felhom drive: shown, never offered). Absent from an older agent.
Partitions []CandidatePartition `json:"partitions,omitempty"`
// WholeDiskInUse is set by the CONTROLLER (never the agent) when a partition of this disk is a registered drive
// (R-542): the disk stays listed for its FREE parts only, and no whole-disk action is offered.
WholeDiskInUse bool `json:"whole_disk_in_use,omitempty"`
}
// CandidatePartition mirrors the agent's partition entry (felhom-agent internal/storage/partitions.go).
type CandidatePartition struct {
Device string `json:"device"`
Number int `json:"number,omitempty"`
SizeBytes int64 `json:"size_bytes"`
FSType string `json:"fstype,omitempty"`
State string `json:"state"` // empty | filesystem | in_use
Mountable bool `json:"mountable"`
DurableID string `json:"durable_id,omitempty"` // uuid:<fs-uuid> — the attach key
MountedAt string `json:"mounted_at,omitempty"`
// Formattable (agent ≥ 0.158.1): false for every part of a disk with a mounted node — the root mkfs wrapper refuses
// those and trusts no „it is Felhom's own" claim. Only formattable parts are offered for formatting.
Formattable bool `json:"formattable"`
}
// Partition states (agent ≥ 0.158.0).
const (
PartEmpty = "empty"
PartFilesystem = "filesystem"
PartInUse = "in_use"
)
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
// unclaimed) and attach (the mountable-FS subset).
type CandidatesResult struct {
@@ -490,6 +587,10 @@ type GuestMemoryInfo struct {
MaxMB int64 `json:"max_mb"`
FloorMB int64 `json:"floor_mb"`
Running bool `json:"running"`
// HostSwapMB / GuestSwapMB (agent ≥ the R-1192 swap release): the host's swap and this guest's swap allowance.
// nil = an older agent (not judged).
HostSwapMB *int64 `json:"host_swap_mb,omitempty"`
GuestSwapMB *int64 `json:"guest_swap_mb,omitempty"`
}
// MemoryResizeResult mirrors the agent's POST /guest/memory success body.
@@ -698,6 +799,11 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
return out, err
}
if err := refusalError("/disks/decommission", status, env); err != nil {
if status == http.StatusForbidden || status == http.StatusConflict {
// R-1063: the agent's two policy refusals (403 a protected role, 409 the whole-guest backup target) are
// typed, so the dashboard can say which in plain words. The text is refusalError's, unchanged.
return out, &DecommissionRefusedError{Status: status, Reason: refusalReason(env)}
}
return out, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
@@ -706,6 +812,17 @@ func (c *Client) Decommission(ctx context.Context, where string) (DecommissionRe
return out, nil
}
// DecommissionRefusedError is the agent saying no to a decommission: Status 403 (the mount is system/backup by its
// role) or 409 (the mount holds the whole-guest backup target). Nothing was done on the host.
type DecommissionRefusedError struct {
Status int
Reason string // the agent's own words, truncated (never a secret: the agent's refusal text)
}
func (e *DecommissionRefusedError) Error() string {
return fmt.Sprintf("agentapi: POST /disks/decommission: HTTP %d: %s", e.Status, e.Reason)
}
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
func refusalError(path string, status int, env apiResponse) error {
@@ -713,16 +830,21 @@ func refusalError(path string, status int, env apiResponse) error {
if accepted && env.OK {
return nil
}
reason := truncateErr(env.Error, 300)
if reason == "" {
reason = "(no reason in agent response)"
}
reason := refusalReason(env)
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
}
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
}
// refusalReason is the agent's refusal text as refusalError carries it.
func refusalReason(env apiResponse) string {
if r := truncateErr(env.Error, 300); r != "" {
return r
}
return "(no reason in agent response)"
}
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
func truncateErr(s string, maxLen int) string {
if len(s) <= maxLen {
@@ -769,6 +891,9 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirme
// real failure (e.g. the agent's 502 on a mkfs error: "device is mounted"). Returning the zero-value
// result with a nil error here made a failed destructive format read as a silent SUCCESS in the web
// layer. Surface it as an error so the caller (and the dashboard) report the failure.
if status == http.StatusConflict && strings.Contains(env.Error, "has no durable id") {
return out, ErrNoDurableID
}
if status < 200 || status >= 300 || !env.OK {
msg := strings.TrimSpace(env.Error)
if msg == "" {
@@ -788,6 +913,8 @@ type FormatStatusResult struct {
Device string `json:"device"`
FSType string `json:"fstype"`
Error string `json:"error"`
// FSUUID (R-25): see FormatResult.FSUUID — "" until the job is done and verified.
FSUUID string `json:"fs_uuid,omitempty"`
}
// FormatStatus fetches the agent's most-recent format-job record.
@@ -1039,6 +1166,13 @@ type SmartSummary struct {
ReallocatedSectors *int `json:"reallocated_sectors"`
PendingSectors *int `json:"pending_sectors"`
OfflineUncorrectable *int `json:"offline_uncorrectable"`
// R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188
// Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count.
// nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY:
// no verdict, banner or mail reads them yet — that would change what a household is told.
ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"`
CommandTimeout *int64 `json:"command_timeout,omitempty"`
UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"`
// NVMe attributes.
CriticalWarning *int `json:"critical_warning"`
MediaErrors *int `json:"media_errors"`
@@ -1157,6 +1291,18 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
// post issues an authenticated JSON POST and unwraps the {ok,data,error} envelope. The agent
// returns 200 or 202 for accepted requests.
func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) {
return c.postWith(c.hc, ctx, path, body)
}
// postSlow is post with its own overall timeout, for the few synchronous routes that outlast the client's 15 s
// (POST /host/line-test/run, about 55 s). It shares the pinned transport.
func (c *Client) postSlow(ctx context.Context, path string, body any, timeout time.Duration) (json.RawMessage, error) {
hc := *c.hc
hc.Timeout = timeout
return c.postWith(&hc, ctx, path, body)
}
func (c *Client) postWith(hc *http.Client, ctx context.Context, path string, body any) (json.RawMessage, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
@@ -1168,7 +1314,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
resp, err := hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
@@ -0,0 +1,33 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
)
// CrashGuardState mirrors the agent's GET /host/crash-guard (R-856, `09` §3 decision 143): what the
// host's crash guard (`felhom-crash-guard`, `11` §5.9) recorded about the most recent HOST boot. The
// agent reads /var/lib/felhom-crash-guard/state.json (0644) and passes these fields through.
//
// Present=false: the host has no crash guard or no state yet. An agent that predates the route answers
// 404 (a *StatusError) — both mean UNKNOWN, and the controller then keeps its normal boot grace.
type CrashGuardState struct {
Present bool `json:"present"`
LastBootAt string `json:"last_boot_at,omitempty"` // RFC3339 UTC ("2006-01-02T15:04:05Z")
LastBootUnclean bool `json:"last_boot_unclean"`
Tripped bool `json:"tripped"`
}
// CrashGuard calls GET /host/crash-guard.
func (c *Client) CrashGuard(ctx context.Context) (CrashGuardState, error) {
var out CrashGuardState
body, err := c.get(ctx, "/host/crash-guard")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /host/crash-guard: %w", err)
}
return out, nil
}
@@ -0,0 +1,87 @@
package agentapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
// R-856: GET /host/crash-guard decodes the crash guard's fields, and an agent that predates the route
// surfaces as a typed 404 (the caller reads it as unknown → the normal boot grace).
func TestR856_CrashGuardDecodesAndAnOlderAgentIs404(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /host/crash-guard", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"last_boot_at":"2026-10-04T12:00:00Z","last_boot_unclean":true,"tripped":false}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
st, err := c.CrashGuard(context.Background())
if err != nil {
t.Fatal(err)
}
if !st.Present || !st.LastBootUnclean || st.LastBootAt != "2026-10-04T12:00:00Z" || st.Tripped {
t.Fatalf("state = %+v", st)
}
old := httptest.NewTLSServer(http.NewServeMux())
defer old.Close()
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).CrashGuard(context.Background())
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("an older agent must answer a typed 404, got %v", err)
}
}
// R-1063: the agent's two decommission refusals come back TYPED (403 a protected role, 409 the whole-guest backup
// target), with the same text as before; any other failure is not that type.
func TestDecommission_PolicyRefusalsAreTyped(t *testing.T) {
for _, c := range []struct {
code int
typed bool
}{{http.StatusForbidden, true}, {http.StatusConflict, true}, {http.StatusBadGateway, false}} {
mux := http.NewServeMux()
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(c.code)
_, _ = w.Write([]byte(`{"ok":false,"error":"refused for the test"}`))
})
s := httptest.NewTLSServer(mux)
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).Decommission(context.Background(), "/mnt/x")
s.Close()
var dr *DecommissionRefusedError
if got := errors.As(err, &dr); got != c.typed {
t.Fatalf("HTTP %d: typed=%v, want %v (err %v)", c.code, got, c.typed, err)
}
if c.typed && (dr.Status != c.code || !strings.Contains(err.Error(), "HTTP "+strconv.Itoa(c.code)+": refused for the test")) {
t.Fatalf("HTTP %d: %+v / %q", c.code, dr, err)
}
}
}
// R-1069: the agent's anti-retarget refusal of a blank format (its exact text, a 409) comes back as ErrNoDurableID;
// another 409 does not.
func TestFormatDisk_NoDurableIDIsTyped(t *testing.T) {
for _, c := range []struct {
body string
typed bool
}{
{`{"ok":false,"error":"format refused: device has no durable id to bind the format to (path-only formats are not permitted)","data":{"formatted":false,"data_bearing":false}}`, true},
{`{"ok":false,"error":"format refused (device may have changed since inspection): gone","data":{"formatted":false}}`, false},
} {
mux := http.NewServeMux()
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(c.body))
})
s := httptest.NewTLSServer(mux)
_, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).FormatDisk(context.Background(), "/dev/vdb", "ext4", false, "")
s.Close()
if got := errors.Is(err, ErrNoDurableID); got != c.typed || err == nil {
t.Fatalf("body %s: ErrNoDurableID=%v want %v (err %v)", c.body[:60], got, c.typed, err)
}
}
}
+34 -18
View File
@@ -57,20 +57,20 @@ type DiskPrior struct {
// DiskVerdictFor maps a SmartSummary plus the previous observation to a verdict. Rules are evaluated
// TOP-DOWN and the FIRST match wins (v0.215.0):
//
// 1. nil / "" / UNKNOWN -> Nincs adat
// 2. Health == FAILING -> Hiba (drive self-reports)
// 3. temperature_c >= 60 -> Hiba
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
// 5. percentage_used >= 100 -> Hiba
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
// 10. reallocated > 0 -> Figyelmeztetés
// 11. media_errors > 0 -> Figyelmeztetés
// 12. percentage_used >= 90 -> Figyelmeztetés
// 13. temperature_c >= 55 -> Figyelmeztetés
// 14. otherwise -> Rendben
// 1. nil / "" / UNKNOWN -> Nincs adat
// 2. Health == FAILING -> Hiba (drive self-reports)
// 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
// 5. percentage_used >= 100 -> Hiba
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
// 7. unreadable > 0 AND reallocated > 0 -> Hiba (accumulating + remapping)
// 8. unreadable >= 64 -> Hiba (too large to be a blip)
// 9. unreadable > 0 -> Figyelmeztetés (first sighting)
// 10. reallocated > 0 -> Figyelmeztetés
// 11. media_errors > 0 -> Figyelmeztetés
// 12. percentage_used >= 90 -> Figyelmeztetés
// 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés
// 14. otherwise -> Rendben
//
// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187,
// 197 and 198 all carry thresh 0, and a normalized SMART value floors at 1, so it can never drop to
@@ -82,6 +82,12 @@ type DiskPrior struct {
// drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for
// a box that was powered off or restarted across the sustain window and so has no prior.
//
// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are
// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C
// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe
// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still
// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand.
//
// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
// 1 — no data. Never alarms.
@@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
}
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
// because the overall verdict is structurally unable to report this class of fault.
nvme := IsNVMe(s)
switch {
case atLeast(s.TemperatureC, TemperatureFailC): // 3
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
return DiskVerdictFail
case positive(s.CriticalWarning): // 4
return DiskVerdictFail
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
return DiskVerdictWarn
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
return DiskVerdictWarn
case atLeast(s.TemperatureC, temperatureWarnC): // 13
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
return DiskVerdictWarn
}
return DiskVerdictOK // 14
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
if atLeast(s.PercentageUsed, percentageUsedWarn) {
out = append(out, "elhasználódás")
}
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable.
if atLeast(s.TemperatureC, temperatureWarnC) {
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
// NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason.
if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) {
out = append(out, "hőmérséklet")
}
return out
}
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
// carries none of them.
func IsNVMe(s *SmartSummary) bool {
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
}
func positive(p *int) bool { return p != nil && *p > 0 }
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
@@ -0,0 +1,36 @@
package agentapi
import "testing"
// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means
// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays.
func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) {
noPrior := DiskPrior{}
cases := []struct {
name string
in *SmartSummary
want DiskVerdict
}{
// A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm.
{"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK},
{"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK},
// The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba.
{"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail},
{"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail},
// Spinning/SATA drives keep their 55/60 °C bands, unchanged.
{"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn},
{"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail},
{"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK},
}
for _, c := range cases {
if got := DiskVerdictFor(c.in, noPrior); got != c.want {
t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label())
}
}
// The alert body must not name heat as the reason for an NVMe drive.
for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) {
if a == "hőmérséklet" {
t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning")
}
}
}
+53 -9
View File
@@ -92,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
return out, nil
}
// EscrowClaim is a claimed recovery code. ConfirmRequired (agent ≥ 0.160.0, R-1151): the code's package reaches the
// hub only when EscrowCeremonyConfirm brings back the words at Positions (1-based); an older agent answers without
// it — its package is already on the hub, and no confirm is needed.
type EscrowClaim struct {
Code string
ConfirmRequired bool
Positions []int
}
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
// the agent's reason text, never the code.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
//
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
if err != nil {
return "", status, err
return EscrowClaim{}, status, err
}
if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
return EscrowClaim{}, status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
}
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
return "", status, err
return EscrowClaim{}, status, err
}
var out struct {
RecoveryCode string `json:"recovery_code"`
RecoveryCode string `json:"recovery_code"`
ConfirmRequired bool `json:"confirm_required"`
VerifyPositions []int `json:"verify_positions"`
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
return EscrowClaim{}, status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
}
if out.RecoveryCode == "" {
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
}
return out.RecoveryCode, status, nil
if out.ConfirmRequired && len(out.VerifyPositions) != 2 {
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim asks for a confirmation without two positions")
}
return EscrowClaim{Code: out.RecoveryCode, ConfirmRequired: out.ConfirmRequired, Positions: out.VerifyPositions}, status, nil
}
// EscrowCeremonyConfirm sends the two words the household typed back (R-1151, agent ≥ 0.160.0); on success the agent
// has stored the new package on the hub. The status is returned for the caller to classify: 422 the words do not
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
// waiting). The words are part of the recovery code — never logged.
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
if err != nil {
return status, err
}
if err := refusalError("/escrow/ceremony/confirm", status, env); err != nil {
return status, err
}
return status, nil
}
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
@@ -210,6 +242,10 @@ const (
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
// NOT known. The customer must not be told to check their typing as if it were.
RecoveryOlderUnchecked
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
@@ -251,6 +287,12 @@ func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) Recove
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
case http.StatusFailedDependency:
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
// claims only „not everything was checked; contact support", which is the same direction as
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
return RecoveryOlderUnchecked
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
@@ -276,6 +318,8 @@ func (f RecoveryFailure) String() string {
return "bundle-too-old"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
case RecoveryOlderUnchecked:
return "older-unchecked"
default:
return "unknown"
}
@@ -0,0 +1,63 @@
package agentapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-1151 — the claim asks the agent to keep the package until the words come back, and reads the agent's answer;
// the confirm carries the two words. An agent older than 0.160.0 answers without confirm_required.
func TestEscrowClaimAndConfirm_R1151(t *testing.T) {
var claimBody map[string]any
var confirmBody map[string][]string
newAgent := true
mux := http.NewServeMux()
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
claimBody = nil
_ = json.NewDecoder(r.Body).Decode(&claimBody)
if newAgent {
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c","confirm_required":true,"verify_positions":[1,3]}}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
})
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&confirmBody)
if confirmBody["words"][0] != "a" {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"ok":false,"error":"the words do not match the recovery code"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
claim, status, err := c.EscrowCeremonyClaim(context.Background())
if err != nil || status != http.StatusOK || claim.Code != "a-b-c" || !claim.ConfirmRequired || len(claim.Positions) != 2 || claim.Positions[1] != 3 {
t.Fatalf("claim: %+v %d %v", claim, status, err)
}
if claimBody["defer_upload"] != true {
t.Fatalf("the claim did not ask the agent to wait for the words: body %v", claimBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
t.Fatalf("confirm: %d %v", st, err)
}
if strings.Join(confirmBody["words"], ",") != "a,c" {
t.Fatalf("confirm sent %v", confirmBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"x", "c"}); err == nil || st != http.StatusUnprocessableEntity {
t.Fatalf("a wrong word must come back as a 422 error: %d %v", st, err)
}
newAgent = false // agent 0.159.2: no confirm request
claim, _, err = c.EscrowCeremonyClaim(context.Background())
if err != nil || claim.ConfirmRequired || claim.Code != "a-b-c" {
t.Fatalf("claim on an old agent: %+v %v", claim, err)
}
}
+22
View File
@@ -83,6 +83,16 @@ const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
// for two months, and it heals itself when the agent updates.
const FeatureRetainedRecoveryClass Feature = "retained_recovery_class"
// FeatureHostPowerOff is the household power-off (R-998, agent v0.155.0): POST /host/poweroff. A POST that switches
// the machine off cannot be probed, so the VERSION decides. The settings card hides only on a definite SupportNo; on
// Unknown it stays, and an old agent's 404 is answered with the update sentence.
const FeatureHostPowerOff Feature = "host_poweroff"
// FeatureSameDisk — POST /disks/same-disk (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md
// §4): the agent's physical-disk verdict for two guest paths. Below it the Tier-2 runner keeps the old st_dev
// rule (backup.Manager.diskRelation), logged once.
const FeatureSameDisk Feature = "same_disk"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -159,6 +169,13 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
// R-998: probing would switch the machine off. The row must exist (table-gap rule above).
FeatureHostPowerOff: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
FeatureSameDisk: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe // not probeable: decided by the version header alone
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -192,6 +209,11 @@ var featureMinAgent = map[Feature]string{
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
// preserves for boxes that have not updated yet.
FeatureRetainedRecoveryClass: "0.129.0",
// R-998 — POST /host/poweroff, the household's „A gép kikapcsolása".
FeatureHostPowerOff: "0.155.0",
FeatureSameDisk: "0.157.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
@@ -0,0 +1,90 @@
package agentapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// ── POST /host/poweroff (R-998, agent >= 0.155.0, felhom.eu documentation/architecture/14-host-power-off.md) ──
//
// The household's „A gép kikapcsolása". The agent answers 202 (it shuts this guest down, then the host) or 409
// with a machine-readable reason. Unlike post(), the 409 BODY matters here: the dashboard tells the household
// WHY (a backup runs, too soon after start, already asked…), so this call reads it.
// Agent refusal reasons (stable wire values; mirror felhom-agent internal/localapi/hostpoweroff.go).
const (
PowerOffTooSoon = "too_soon_after_boot"
PowerOffAlreadyAsked = "already_requested_this_boot"
PowerOffBackup = "backup_running"
PowerOffRestoreTest = "restore_test_running"
PowerOffOSUpdate = "os_update_running"
PowerOffCtrlUpdate = "controller_update_running"
PowerOffDrive = "drive_operation_running"
PowerOffEscrow = "escrow_ceremony_running"
PowerOffBusy = "host_busy"
PowerOffOtherGuests = "other_guests_running"
PowerOffUnknown = "state_unknown"
)
// PowerOffRefused is the agent's 409: Reason is one of the constants above (or a newer one), Detail is for the log.
type PowerOffRefused struct {
Reason string
Detail string
}
func (e *PowerOffRefused) Error() string {
return "agent refused the power-off: " + e.Reason + " (" + e.Detail + ")"
}
// HostPowerOff asks the agent to shut this guest down and power the host off. nil = accepted (the box goes down
// within minutes). A *PowerOffRefused = the agent said no, with its reason. A *StatusError 404 = an agent that
// predates the route.
func (c *Client) HostPowerOff(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/host/poweroff", bytes.NewReader([]byte("{}")))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("agentapi: POST /host/poweroff: %w", err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp)
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
logx.Debugf(c.logger, "[agentapi] POST /host/poweroff -> %d (%dms)", resp.StatusCode, time.Since(start).Milliseconds())
switch resp.StatusCode {
case http.StatusAccepted, http.StatusOK:
return nil
case http.StatusConflict:
var env struct {
Data struct {
Reason string `json:"reason"`
Detail string `json:"detail"`
} `json:"data"`
}
if json.Unmarshal(raw, &env) != nil || env.Data.Reason == "" {
return &PowerOffRefused{Reason: PowerOffUnknown, Detail: "unreadable 409 body"}
}
return &PowerOffRefused{Reason: env.Data.Reason, Detail: env.Data.Detail}
default:
return &StatusError{Method: http.MethodPost, Path: "/host/poweroff", Code: resp.StatusCode}
}
}
// IsPowerOffRefused returns the refusal, if err is one.
func IsPowerOffRefused(err error) (*PowerOffRefused, bool) {
var r *PowerOffRefused
ok := errors.As(err, &r)
return r, ok
}
+69
View File
@@ -0,0 +1,69 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
"time"
)
// LineTestState mirrors the agent's GET /host/line-test (agent ≥ 0.164.0; felhom.eu R-1247): the host's weekly upload
// line test — a 10-second iperf3 upload to ep0 inside the offsite tunnel, best of three runs, as ep0's server counted
// it. Present=false: never measured (no tunnel, not yet due, or disabled). An agent before the route answers 404 (a
// *StatusError); all three mean UNKNOWN, and the controller then sets no qBittorrent limit.
type LineTestState struct {
Present bool `json:"present"`
Mbps float64 `json:"mbps,omitempty"`
MeasuredAt string `json:"measured_at,omitempty"` // RFC3339 UTC
Method string `json:"method,omitempty"`
// WaitingForQuiet (agent ≥ 0.165.0, R-1250): a test is due and could start now, but the host's uplink has not been
// quiet for 10 minutes. With a seeding torrent app the controller then holds the app's limit and calls LineTestRun.
WaitingForQuiet bool `json:"waiting_for_quiet,omitempty"`
}
// LineTestRunResult mirrors POST /host/line-test/run. OK=false with Refused: nothing ran (not-due, outside-window,
// tries-used, no-tunnel, heavy-op, not-quiet, server-busy); OK=false without it: the runs failed.
type LineTestRunResult struct {
OK bool `json:"ok"`
Mbps float64 `json:"mbps,omitempty"`
RunsMbps []float64 `json:"runs_mbps,omitempty"`
Refused string `json:"refused,omitempty"`
Error string `json:"error,omitempty"`
}
// LineTestRunTimeout bounds the synchronous request: about 55 s on the agent (5 s settle, 10 s quiet check, three
// 10-second runs 5 s apart), each run itself bounded at 25 s.
const LineTestRunTimeout = 2 * time.Minute
// LineTestRun calls POST /host/line-test/run (R-1250): a test on request while the caller holds its torrent app's
// upload down. An agent before the route answers 404 (*StatusError).
func (c *Client) LineTestRun(ctx context.Context) (LineTestRunResult, error) {
var out LineTestRunResult
body, err := c.postSlow(ctx, "/host/line-test/run", struct{}{}, LineTestRunTimeout)
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /host/line-test/run: %w", err)
}
return out, nil
}
// LineTest calls GET /host/line-test.
func (c *Client) LineTest(ctx context.Context) (LineTestState, error) {
var out LineTestState
body, err := c.get(ctx, "/host/line-test")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /host/line-test: %w", err)
}
return out, nil
}
// MeasuredAtTime parses MeasuredAt (zero time when absent or garbled).
func (s LineTestState) MeasuredAtTime() time.Time {
t, _ := time.Parse(time.RFC3339, s.MeasuredAt)
return t
}
@@ -0,0 +1,64 @@
package agentapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// R-1247: GET /host/line-test decodes the agent's weekly line test, and an agent before the route is a typed 404 (the
// controller reads it as „not measured" → no qBittorrent limit).
func TestLineTestDecodesAndAnOlderAgentIs404(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"mbps":90.7,"measured_at":"2026-10-24T06:31:00Z","method":"iperf3-tunnel"}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
st, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).LineTest(context.Background())
if err != nil {
t.Fatal(err)
}
if !st.Present || st.Mbps != 90.7 || !st.MeasuredAtTime().Equal(time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC)) {
t.Fatalf("state = %+v", st)
}
old := httptest.NewTLSServer(http.NewServeMux())
defer old.Close()
_, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).LineTest(context.Background())
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("an older agent must answer a typed 404, got %v", err)
}
}
// R-1250: the request decodes its outcome, waiting_for_quiet decodes, and the request outlasts the client's own
// timeout (the agent answers after about 55 s; the client's default is 15 s).
func TestLineTestRunOutlastsTheClientTimeout(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"present":false,"waiting_for_quiet":true}}`))
})
mux.HandleFunc("POST /host/line-test/run", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true,"data":{"ok":true,"mbps":93.5,"runs_mbps":[92,93.5,93]}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
st, err := c.LineTest(context.Background())
if err != nil || st.Present || !st.WaitingForQuiet {
t.Fatalf("GET = %+v %v, want present:false waiting_for_quiet:true", st, err)
}
c.hc.Timeout = 100 * time.Millisecond // stands in for the 15 s default against a 55 s answer
if _, err := c.post(context.Background(), "/host/line-test/run", struct{}{}); err == nil {
t.Fatal("the plain POST should time out here — the seam does not test anything")
}
res, err := c.LineTestRun(context.Background())
if err != nil || !res.OK || res.Mbps != 93.5 || len(res.RunsMbps) != 3 {
t.Fatalf("LineTestRun = %+v %v", res, err)
}
}
@@ -0,0 +1,61 @@
package agentapi
import (
"encoding/json"
"reflect"
"testing"
)
// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw
// counters; the controller must DECODE them (a field the struct lacks is silently dropped by
// encoding/json) and must not yet ACT on them.
// The 2026-08-14 failing drive as the agent now reports it (values from
// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json).
const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null,
"reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352,
"reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0,
"critical_warning":null,"media_errors":null,"percentage_used":null}`
// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable`
// → this fails with "187 must decode from the agent's JSON (raw 1001); got <nil>". Restored.
func TestR330_DecodesTheThreeCounters(t *testing.T) {
var s SmartSummary
if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil {
t.Fatal(err)
}
if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 {
t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect)
}
// A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive.
if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 {
t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors)
}
// An older agent omits the keys → nil (unknown), never 0.
var old SmartSummary
if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil {
t.Fatal(err)
}
if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil {
t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v",
old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors)
}
}
// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading
// them is a change to what a household is told and is not taken here (R-330's next slice).
func TestR330_CountersChangeNoVerdictYet(t *testing.T) {
with := realDrive()
big := int64(5000)
with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big
without := realDrive()
for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} {
if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b {
t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b)
}
}
if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) {
t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b)
}
}
@@ -0,0 +1,47 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
func TestR899_PressCarriesTriggerManual(t *testing.T) {
var got []string
mux := http.NewServeMux()
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.URL.RawQuery)
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
ctx := context.Background()
if _, err := c.StartBackup(ctx); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
t.Fatal(err)
}
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
t.Fatal(err)
}
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
if len(got) != len(want) {
t.Fatalf("queries = %q, want %q", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
}
}
}
@@ -0,0 +1,107 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// R-921: the pre-check reads the agent's real wire shapes — GET /backup/tiers, then GET /backup/status per
// target — and reports exactly the tiers whose job is running or snapshotted (the agent's in-flight set).
func TestBackupJobsInFlight_ReportsRunningAndSnapshottedTiers(t *testing.T) {
phases := map[string]string{"local": "done", "felhom-pbs": "snapshotted", "extra": "running"}
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[` +
`{"target":"local","cadence_seconds":86400,"primary":true,"storage":"present"},` +
`{"target":"felhom-pbs","cadence_seconds":604800,"primary":false,"storage":"present"},` +
`{"target":"extra","cadence_seconds":604800,"primary":false,"storage":"present"}]}}`))
})
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
tg := r.URL.Query().Get("target")
ph, ok := phases[tg]
if !ok {
http.Error(w, "unexpected target "+tg, http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"phase":"` + ph + `","target":"` + tg + `"}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
got, err := c.BackupJobsInFlight(context.Background())
if err != nil {
t.Fatalf("BackupJobsInFlight: %v", err)
}
if want := []string{"felhom-pbs", "extra"}; !reflect.DeepEqual(got, want) {
t.Fatalf("in-flight tiers = %v, want %v (done must not count; running and snapshotted must)", got, want)
}
}
// A pre-R-82 agent (no /backup/tiers → 404) has no per-tier jobs: nil, nil — never an error that would be
// read as "cannot tell", and never a busy verdict.
func TestBackupJobsInFlight_PreR82AgentIsNotBusy(t *testing.T) {
mux := http.NewServeMux()
s := httptest.NewTLSServer(mux) // every route 404s
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
got, err := c.BackupJobsInFlight(context.Background())
if err != nil || got != nil {
t.Fatalf("pre-R-82 agent: got %v, %v — want nil, nil", got, err)
}
}
// A status read that fails is returned, so the loop can fail toward backing up and say so.
func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/tiers", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"tiers":[{"target":"local","primary":true}]}}`))
})
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
if _, err := c.BackupJobsInFlight(context.Background()); err == nil {
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
}
}
// R-921 part 2: HostBusy reads the agent's real wire shape (untargeted GET /backup/status, `host_busy`); an agent
// without the field is „not known" — never „free".
func TestHostBusy_ReadsTheAgentsGate(t *testing.T) {
for _, tc := range []struct {
name, body string
busy, known bool
what string
}{
{"held", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":true,"what":"os-pve-step"}}}`, true, true, "os-pve-step"},
{"free", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":false}}}`, false, true, ""},
{"older agent", `{"ok":true,"data":{"vmid":9201,"phase":"idle"}}`, false, false, ""},
} {
t.Run(tc.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
if r.URL.RawQuery != "" {
http.Error(w, "want the untargeted status", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(tc.body))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
busy, what, known, err := c.HostBusy(context.Background())
if err != nil || busy != tc.busy || known != tc.known || what != tc.what {
t.Fatalf("HostBusy = %v %q known=%v err=%v, want %v %q known=%v", busy, what, known, err, tc.busy, tc.what, tc.known)
}
})
}
}
@@ -1,39 +0,0 @@
package api
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestWriteConfig0600 asserts F8: controller.yaml is persisted 0600 (it holds infra secrets), even when
// the target file already existed with looser (0644) permissions. POSIX modes only — skipped on Windows.
func TestWriteConfig0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX file modes not represented on Windows")
}
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
// Pre-create with world-readable 0644 to prove the helper tightens an existing file.
if err := os.WriteFile(path, []byte("old: true\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := writeConfig0600(path, []byte("hub:\n api_key: redacted\n")); err != nil {
t.Fatalf("writeConfig0600: %v", err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if mode := fi.Mode().Perm(); mode != 0o600 {
t.Fatalf("config mode = %o, want 0600", mode)
}
// No leftover temp file.
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("temp file not cleaned up")
}
}
@@ -0,0 +1,17 @@
package api
import (
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// D4 (`16` §5): the no-drive refusal is a 400 the household can act on — not a 500.
func TestD4_NeedsDriveIs400(t *testing.T) {
err := util.MsgErrorf(stacks.ErrNeedsDrive, "err.stacks.needs_drive")
if got := deployStatusFor(err); got != http.StatusBadRequest {
t.Fatalf("ErrNeedsDrive → %d, want 400", got)
}
}
+6 -2
View File
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
}
for _, c := range cases {
r := newDeployGateRouter(t, c.class)
msg := r.refuseNetworkStubDeploy(c.hdd)
// v0.252.0 (R-557): the gate returns a bundle KEY, so the customer reads it in their own
// language. Rendered here rather than compared as a key, because what this test is about is
// the SENTENCE the customer is refused with -- a key renamed with its message intact is not
// a regression, and a message reworded is.
msg := r.msgLang("hu", r.refuseNetworkStubDeploy(c.hdd))
if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") {
t.Errorf("%s: want the refusal message, got %q", c.name, msg)
}
if !c.refuse && msg != "" {
if !c.refuse && r.refuseNetworkStubDeploy(c.hdd) != "" {
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
}
}
+25 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"sort"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
@@ -67,7 +68,7 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
if err := r.sett.SetGeoRestriction(geo); err != nil {
r.logger.Printf("[ERROR] [api] Failed to save geo settings: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
// away instead of after the next ~15-min cycle.
r.reportPushNow()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.set")})
}
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
func (r *Router) geoTriggerSync(w http.ResponseWriter, req *http.Request) {
if r.geoSync == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: r.msg(req, "api.geo.no_cloudflare")})
return
}
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
r.reportPushNow()
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.sync_started")})
}
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
// geoCountries serves the picker's country list in the request's language.
//
// The TABLE in internal/cloudflare is untouched and stays Hungarian: it is also the validator, and
// internal/report puts country CODES on the wire. Only the NAME shown to the customer is translated,
// and the list is re-sorted afterwards -- alphabetical order is per language, and a list sorted by
// the Hungarian names would read as shuffled in English.
func (r *Router) geoCountries(w http.ResponseWriter, req *http.Request) {
lang := r.langFor(req)
list := cf.AllCountries()
for i := range list {
list[i].Name = r.countryName(lang, list[i].Code, list[i].Name)
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
}
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
@@ -144,7 +157,7 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
override := &settings.AppGeoOverride{AllowedCountries: body.AllowedCountries}
if err := r.sett.SetGeoAppOverride(appName, override); err != nil {
r.logger.Printf("[ERROR] [api] Failed to save geo override for %s: %v", appName, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -159,10 +172,10 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_set")})
}
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
if appName == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
return
@@ -170,7 +183,7 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
if err := r.sett.RemoveGeoAppOverride(appName); err != nil {
r.logger.Printf("[ERROR] [api] Failed to remove geo override for %s: %v", appName, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -185,5 +198,5 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
}()
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_removed")})
}
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"net/http"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ───────────────────────
//
// Design: felhom.eu/documentation/architecture/10-localisation.md. The dashboard's page markup has
// followed the household's language since slice 1, but a large part of what a customer READS on
// those pages never passes through a template: the page's own script fetches /api/… and shows the
// `message` or `error` the answer carries. Until now those were Hungarian literals, so an English
// dashboard answered in Hungarian the moment anything was clicked.
//
// These helpers mirror internal/web's (i18n_web.go) exactly, and for the same reason: the Hungarian
// text is the SAME BYTES the literal carried, pinned by scripts/i18n_go_parity.py against a frozen
// capture of the base commit.
//
// NOT everything here is display. `internal/report` and `internal/notify` put Hungarian on the wire,
// where it is read by the hub and mailed to the household — those stay Hungarian in every language
// until slice 3 (R-558) teaches the hub which language to compose in, and their bytes are pinned by
// the wire goldens in those packages.
// langFor decides the language of one API request: `?lang=hu|en` (a testing override, never
// persisted), else the household's saved setting, else Hungarian. Identical in meaning to
// web.Server.langFor, deliberately — the page and the calls its script makes must never disagree.
func (r *Router) langFor(req *http.Request) string {
if req != nil {
if q := req.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
}
if r.sett != nil {
return r.sett.GetLanguage()
}
return i18n.Default
}
// bundle returns the process-wide bundle, or nil if it somehow failed to load. It is embedded in the
// binary, so a running box cannot reach the nil case; the callers still degrade to the key rather
// than panicking.
func bundle() *i18n.Bundle {
b, err := i18n.Shared()
if err != nil {
return nil
}
return b
}
// msg returns a message in the request's language, with the message's own printf verbs filled in.
func (r *Router) msg(req *http.Request, key string, a ...interface{}) string {
return r.msgLang(r.langFor(req), key, a...)
}
// msgLang is msg for a language that is already known.
func (r *Router) msgLang(lang, key string, a ...interface{}) string {
b := bundle()
if b == nil {
return key
}
if len(a) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, a...)
}
// errText renders an error in the request's language: its bundle message when it carries one, its own
// text otherwise (restic, docker, ssh and the Go stdlib print verbatim — that text is not ours).
func (r *Router) errText(req *http.Request, err error) string {
return util.ErrText(r.langFor(req), err)
}
// countryName returns a country's name in lang, falling back to the name the cloudflare table
// carries when the bundle has no entry for the code.
//
// The TABLE is not translated and must not be: internal/report puts country CODES on the wire, and
// cloudflare/countries.go is also what validates them. Only the NAME shown in the geo picker follows
// the language, which is why this lives here and not in that package.
func (r *Router) countryName(lang, code, fallback string) string {
b := bundle()
if b == nil {
return fallback
}
if v, _, ok := b.Text(lang, "country."+code); ok {
return v
}
return fallback
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
// household's language, and the Hungarian answer is byte-identical to what it was.
//
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
// English button and was answered in Hungarian.
//
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
// the literals that stood at the call sites at 736f54b49610.
func langRouter(t *testing.T, lang string) *Router {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if lang != "" {
if err := sett.SetLanguage(lang); err != nil {
t.Fatal(err)
}
}
return &Router{sett: sett, logger: lg}
}
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
cases := []struct {
key, hu, en string
args []interface{}
}{
{key: "api.deploy.started",
hu: "Telepítés elindítva – az állapot a kártyán követhető",
en: "The install has started – the card shows how it is going"},
{key: "api.backup.already_running",
hu: "Mentés már folyamatban", en: "A backup is already running"},
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
}
for _, c := range cases {
t.Run(c.key, func(t *testing.T) {
// hu: the saved setting, no query override — the ordinary household.
r := langRouter(t, "hu")
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
if got := r.msg(req, c.key, c.args...); got != c.hu {
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
}
// en: the same request shape on a box switched to English.
r = langRouter(t, "en")
if got := r.msg(req, c.key, c.args...); got != c.en {
t.Errorf("English answer\n got %q\n want %q", got, c.en)
}
})
}
}
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
func TestAPILangForPrecedence(t *testing.T) {
cases := []struct{ saved, query, want string }{
{"hu", "", "hu"},
{"en", "", "en"},
{"hu", "en", "en"},
{"en", "hu", "hu"},
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
{"", "", i18n.Default},
}
for _, c := range cases {
r := langRouter(t, c.saved)
url := "/api/stacks"
if c.query != "" {
url += "?lang=" + c.query
}
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
}
}
}
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
r := langRouter(t, "hu")
for _, c := range []struct{ lang, code, want string }{
{"hu", "DE", "Németország"},
{"en", "DE", "Germany"},
{"hu", "US", "Egyesült Államok"},
{"en", "US", "United States"},
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
{"en", "AO", "Angola"},
} {
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
}
}
// A code the bundle does not carry keeps the table's own name rather than showing a key.
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
t.Errorf("an unknown code must keep the table name, got %q", got)
}
// And the English list really is sorted by the English names: „Németország" sorts under N in
// Hungarian and Germany under G, so the two orders must differ.
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
t.Error("the country names did not change with the language at all")
}
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"fmt"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// keptChoiceData is what the install page needs to ask the household (`09` §3 decision 36). The
// sentences come rendered in the request's language; the page shows them as they are.
type keptChoiceData struct {
Code string `json:"code"` // "kept_data_choice"
Title string `json:"title"`
Body string `json:"body"`
UseLabel string `json:"use_label"`
UseDesc string `json:"use_desc"` // "" when use is off
UseOff string `json:"use_off"` // "" when use is offered
UseOffered bool `json:"use_offered"` // a database copy exists for these files
FreshLabel string `json:"fresh_label"`
FreshDesc string `json:"fresh_desc"`
NotBacked string `json:"not_backed_up"`
}
// keptDataAtInstall answers the install when the app's private drive folder already holds data:
//
// - no choice → 409 kept_data_choice with the sentences (nothing moved, nothing installed);
// - "use" → a LOAD from the newest usable copy (backup.KeptBestCopy: own unit, second drive, or —
// R-691 (2) — the off-site copy, downloaded unit-only at load time), then the app's after_load; 202.
// The page names the copy and its date. Refused 409 with use_off when no copy exists;
// - "fresh" → not handled here: DeployStack moves the old data aside and installs.
//
// Returns true when it wrote the response.
func (r *Router) keptDataAtInstall(w http.ResponseWriter, req *http.Request, name, hdd, choice string) bool {
old := r.stackMgr.OldAppData(name, hdd)
if len(old) == 0 || choice == stacks.KeptChoiceFresh {
return false
}
display := name
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
display = st.Meta.DisplayName
}
var size int64
var newest time.Time
for _, p := range old {
size += stacks.DirSizeBytes(p)
if t := stacks.DirModTime(p); t.After(newest) {
newest = t
}
}
var cp struct {
ok bool
c backup.KeptCopy
}
if r.backupMgr != nil {
cp.c, cp.ok = r.backupMgr.KeptBestCopy(req.Context(), name, hdd)
}
lang := r.langFor(req)
data := keptChoiceData{
Code: "kept_data_choice",
Title: r.msgLang(lang, "kept.choice.title"),
Body: r.msgLang(lang, "kept.choice.body", display, appbackup.HumanizeBytes(size), localDay(newest)),
UseLabel: r.msgLang(lang, "kept.choice.use.label"),
UseOffered: cp.ok,
FreshLabel: r.msgLang(lang, "kept.choice.fresh.label"),
FreshDesc: r.msgLang(lang, "kept.choice.fresh.desc"),
NotBacked: r.msgLang(lang, "kept.not_backed_up"),
}
if cp.ok {
data.UseDesc = r.msgLang(lang, "kept.choice.use.desc_from", r.keptCopyName(lang, cp.c))
} else {
data.UseOff = r.msgLang(lang, "kept.choice.use_off")
}
switch choice {
case "":
r.logger.Printf("[INFO] [api] Deploy %s: the drive already holds its old data %v (%d bytes) — asking the household (use offered: %v)", name, old, size, cp.ok)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.Title, Data: data})
return true
case stacks.KeptChoiceUse:
if !cp.ok {
r.logger.Printf("[WARN] [api] Deploy %s: use my kept data REFUSED — no database copy for %s", name, hdd)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: data.UseOff, Data: data})
return true
}
if st := r.backupMgr.RestoreStatus(); st.Running || r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msgLang(lang, "api.kept.busy")})
return true
}
src := cp.c.UnitDir
if cp.c.Tier == backup.KeptTierOffsite {
src = "off-site snapshot " + cp.c.SnapshotID
}
r.logger.Printf("[INFO] [api] Deploy %s: USE MY KEPT DATA — loading from %s (tier %d, %s) under the kept files %v", name, src, cp.c.Tier, cp.c.Time.UTC().Format(time.RFC3339), old)
r.dropLeftoverHold(name, "use-kept-data") // R-704: this is a new install of an app that is not installed
r.startKeptLoad(name, hdd, cp.c, lang, nil)
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msgLang(lang, "kept.load.started", display)})
return true
default:
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("kept_data %q is not a choice", choice)})
return true
}
}
// keptCopyName names a copy for the household: which copy and its date („saját mentés, 2026-09-28 04:15").
func (r *Router) keptCopyName(lang string, c backup.KeptCopy) string {
return r.msgLang(lang, backup.KeptCopyKey(c.Tier), localMinute(c.Time))
}
// startKeptLoad runs the load and, when it succeeded, the app's after_load. then(ok) runs last. The
// off-site copy goes through LoadKeptOffsite (download the unit alone, judge it, then the same restore).
func (r *Router) startKeptLoad(name, drive string, c backup.KeptCopy, lang string, then func(ok bool)) {
display := name
if st, ok := r.stackMgr.GetStack(name); ok && st.Meta.DisplayName != "" {
display = st.Meta.DisplayName
}
okMsg := func(error) string { return r.msgLang(lang, "kept.load.done", display) }
failMsg := func(err error) string { return r.msgLang(lang, "kept.load.failed", display, err) }
if c.Tier == backup.KeptTierOffsite {
r.backupMgr.LoadKeptOffsite(name, drive, c, nil, okMsg, failMsg, r.keptAfterLoad(name, then))
return
}
r.backupMgr.LoadKeptApp(name, c.UnitDir, okMsg, failMsg, r.keptAfterLoad(name, then))
}
// keptAfterLoad runs the app's after_load when the load succeeded, then then(ok).
func (r *Router) keptAfterLoad(name string, then func(ok bool)) func(ok bool) {
return func(ok bool) {
if ok {
if ran, err := r.stackMgr.RunAfterLoad(name, 10*time.Minute); ran && err != nil {
r.logger.Printf("[WARN] [api] kept load %s: after_load failed: %v", name, err)
}
}
if then != nil {
then(ok)
}
}
}
func localDay(t time.Time) string {
if t.IsZero() {
return "?"
}
return t.In(budapest()).Format("2006-01-02")
}
// localMinute is localDay with the time of day — the kept list's format, so both pages print one date.
func localMinute(t time.Time) string {
if t.IsZero() {
return "?"
}
return t.In(budapest()).Format("2006-01-02 15:04")
}
func budapest() *time.Location {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
return time.UTC
}
return loc
}
@@ -0,0 +1,289 @@
package api
import (
"context"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// `09` §3 decision 36 — the install API answers an install over old data with the CHOICE (409,
// code kept_data_choice, both sentences, "use" OFF when no database copy exists) and installs nothing;
// "use" without a copy is refused; "fresh" is left to DeployStack. Nothing here reaches Docker.
// COMPANION RED-PROOF: make keptDataAtInstall return false at once → the no-choice case writes nothing
// and the first assertion fails (the install would go on to DeployStack).
func TestKept_InstallAPIAsksAndInstallsNothing(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, logger: log.New(io.Discard, "", 0)}
call := func(choice string) (bool, int, map[string]interface{}) {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
handled := r.keptDataAtInstall(w, req, "cloudapp", drive, choice)
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
return handled, w.Code, body
}
handled, code, body := call("")
data, _ := body["data"].(map[string]interface{})
if !handled || code != http.StatusConflict || data["code"] != "kept_data_choice" {
t.Fatalf("no choice: handled=%v code=%d body=%v", handled, code, body)
}
if data["title"] != "This app's old data is still here" || data["use_offered"] != false ||
data["use_off"] != "There is no backup of the database, so the app cannot load the old files. You can look at the files under Kept data." ||
data["fresh_label"] != "Start fresh" || data["not_backed_up"] != "This is not backed up." {
t.Fatalf("the choice's sentences: %v", data)
}
if handled, code, _ := call("use"); !handled || code != http.StatusConflict {
t.Fatalf("use with no copy: handled=%v code=%d", handled, code)
}
if handled, _, _ := call("fresh"); handled {
t.Fatal("fresh must be left to DeployStack (which moves the old data aside)")
}
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
t.Fatal("something was installed")
}
// No old data → the API does not interfere.
_ = os.RemoveAll(filepath.Join(drive, "appdata/cloudapp"))
if handled, _, _ := call(""); handled {
t.Fatal("an install with no old data was intercepted")
}
}
// R-691 (2) — when the only database copy of kept files is OFF-SITE, the install choice offers „use" and
// names that copy and its date; before v0.277.0 it said "no backup". The repository is the restic runner
// seam; nothing reaches restic, ssh or Docker.
// COMPANION RED-PROOF: keptDataAtInstall back on KeptDBCopy (0.276.0) → use_offered=false and this fails.
func TestR691_InstallChoiceNamesTheOffsiteCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
snapAt := time.Date(2026, 9, 28, 2, 15, 0, 0, time.UTC) // 04:15 in Budapest
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
for _, a := range args {
if a == "snapshots" {
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": snapAt,
"tags": []string{"cloudapp"}, "paths": []string{drive + "/backups/primary/cloudapp", drive + "/appdata/cloudapp"}}})
}
}
return nil, nil
})
r := &Router{stackMgr: m, backupMgr: bm, logger: log.New(io.Discard, "", 0)}
for lang, want := range map[string]string{
"en": "We load the database from the off-site copy, 2026-09-28 04:15 and start the app with the old files. Changes made after that time can be missing.",
"hu": "Az adatbázist innen töltjük vissza: távoli mentés, 2026-09-28 04:15. A régi fájlokkal indítjuk az alkalmazást. Ami ezután változott, hiányozhat.",
} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang="+lang, nil)
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "") || w.Code != http.StatusConflict {
t.Fatalf("%s: the choice was not asked (code %d)", lang, w.Code)
}
var body map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &body)
data, _ := body["data"].(map[string]interface{})
if data["use_offered"] != true || data["use_desc"] != want || data["use_off"] != "" {
t.Fatalf("%s: use_offered=%v use_desc=%q use_off=%q\nwant use_desc=%q", lang, data["use_offered"], data["use_desc"], data["use_off"], want)
}
}
if st, _ := m.GetStack("cloudapp"); st.Deployed || st.Deploying {
t.Fatal("something was installed")
}
}
// R-704 (v0.278.0) — a hold an EARLIER install left under the app's name is dropped when a new install
// begins; the consequence measured on demo-hp was a fresh nextcloud whose backups skipped its volumes and
// unit because of a 2026-09-13 update hold. Driven through the real „use my kept data" path (the off-site
// copy, restic and the unit restore behind their seams — no Docker).
// COMPANION RED-PROOF: remove the dropLeftoverHold call from keptDataAtInstall → the hold is still there
// after the 202 and the first assertion fails; remove it from deployStack → the AST check fails.
func TestR704_AFreshInstallDropsALeftoverHold(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "cloudapp")
for _, d := range []string{app, filepath.Join(drive, "appdata/cloudapp"), cfg.Paths.DataDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n volumes:\n - ${HDD_PATH}/appdata/cloudapp:/data\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: Cloud App\n"), 0o644)
_ = os.WriteFile(filepath.Join(drive, "appdata/cloudapp/old.txt"), []byte("old"), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
_ = sett.SetOffboxTarget(&settings.OffboxTarget{Enabled: true, Host: "nas.local", Port: 22, User: "felhom",
RepoPath: "/srv/repo", Schedule: "daily", EscrowState: "escrowed"})
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
if err := bm.WriteOffboxSecrets("KEY", "nas.local ssh-ed25519 AAAA"); err != nil {
t.Fatal(err)
}
bm.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
unitPath := drive + "/backups/primary/cloudapp"
bm.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
var target string
isRestore := false
for i, a := range args {
if a == "snapshots" {
return json.Marshal([]map[string]interface{}{{"short_id": "ab12cd34", "time": time.Now().Add(-time.Hour),
"tags": []string{"cloudapp"}, "paths": []string{unitPath}}})
}
if a == "restore" {
isRestore = true
}
if a == "--target" && i+1 < len(args) {
target = args[i+1]
}
}
if isRestore && target != "" {
u := filepath.Join(target, strings.TrimPrefix(unitPath, "/"))
_ = os.MkdirAll(filepath.Join(u, "compose"), 0o755)
_ = os.MkdirAll(filepath.Join(u, "db-dumps"), 0o755)
_ = os.WriteFile(filepath.Join(u, "db-dumps", "cloudapp.sql"), []byte("x"), 0o644)
_ = os.WriteFile(filepath.Join(u, "compose", "docker-compose.yml"), []byte("services:\n cloudapp:\n image: busybox:1\n"), 0o644)
_ = os.WriteFile(filepath.Join(u, "compose", "app.yaml"), []byte("env:\n HDD_PATH: "+drive+"\n"), 0o600)
man, _ := json.Marshal(map[string]interface{}{"schema_version": 2, "app_name": "cloudapp", "db_dumps": []string{"cloudapp.sql"},
"data": map[string]interface{}{"at": time.Now().UTC().Format(time.RFC3339), "image_pins": []string{"busybox:1"}}})
_ = os.WriteFile(filepath.Join(u, "manifest.json"), man, 0o644)
}
return nil, nil
})
done := make(chan struct{}, 1)
bm.SetKeptUnitRestoreFn(func(app, unitDir string) (backup.UnitRestoreResult, error) {
done <- struct{}{}
return backup.UnitRestoreResult{}, nil
})
r := &Router{stackMgr: m, backupMgr: bm, sett: sett, logger: log.New(io.Discard, "", 0)}
// A leftover update hold from an earlier install (the demo-hp shape): the new install drops it.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonUpdateFailed})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/cloudapp/deploy?lang=en", nil)
if !r.keptDataAtInstall(w, req, "cloudapp", drive, "use") || w.Code != http.StatusAccepted {
t.Fatalf("use: code %d body %s", w.Code, w.Body.String())
}
if h, held := sett.GetRestoreHold("cloudapp"); held {
t.Fatalf("the earlier install's hold survived the new install: %+v", h)
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("the load never reached the unit restore")
}
// A restore hold (R-379) is NEVER dropped by an install.
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "cloudapp", At: "2026-09-13T19:56:32Z", Reason: settings.HoldReasonRestoreFailed})
r.dropLeftoverHold("cloudapp", "install")
if _, held := sett.GetRestoreHold("cloudapp"); !held {
t.Fatal("an install dropped an R-379 restore hold")
}
_, _ = sett.ClearRestoreHold("cloudapp")
// The plain install path drops it too — before DeployStack (AST: the call precedes it in deployStack).
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var dropAt, deployAt token.Pos
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "deployStack" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok {
switch s.Sel.Name {
case "dropLeftoverHold":
dropAt = s.Pos()
case "DeployStack":
deployAt = s.Pos()
}
}
return true
})
}
if dropAt == token.NoPos || deployAt == token.NoPos || dropAt > deployAt {
t.Fatalf("deployStack must call dropLeftoverHold before DeployStack (drop=%v deploy=%v)", dropAt, deployAt)
}
}
@@ -1,6 +1,7 @@
package api
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"go/ast"
"go/parser"
"go/token"
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
//
// v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The
// ruling is unchanged and still checkable in one step more: router.go must name that key, and the
// key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler
// is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to
// catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently.
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
const key = "api.deploy.not_installable"
const want = "Ez az alkalmazás jelenleg nem telepíthető."
found := false
ast.Inspect(f, func(n ast.Node) bool {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) {
found = true
}
return true
})
if !found {
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key)
}
b, err := i18n.Load()
if err != nil {
t.Fatal(err)
}
if got := b.Msg(i18n.Default, key); got != want {
t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want)
}
if en := b.Msg("en", key); en == "" || en == want {
t.Errorf("%s has no English of its own: %q", key, en)
}
}
@@ -0,0 +1,121 @@
package api
import (
"encoding/json"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"go/ast"
"go/parser"
"go/token"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-474 — the remove handler really deletes the whole unit, the Tier-2 mirror and the prefs.
func TestR474_RemoveHandlerDeletesUnitMirrorAndPrefs(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sel []string
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok {
sel = append(sel, s.Sel.Name)
}
return true
})
}
joined := " " + strings.Join(sel, " ") + " "
for _, want := range []string{"RecoveryUnitPath", "Tier2MirrorDirsForApp", "RemoveTier2Mirrors", "DeleteAppBackupPrefs"} {
if !strings.Contains(joined, " "+want+" ") {
t.Errorf("removeStack must call %s", want)
}
}
}
// R-486 — removing an app with its backups KEPT keeps its Tier-2 record. The only call that may
// forget the record is DeleteAppBackupPrefs, which the R-474 branch guards on remove_backups.
//
// COMPANION RED-PROOF (REPORT.md): put `r.sett.SetCrossDriveConfig(name, nil)` back into
// removeStack unconditionally — this fails.
func TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "SetCrossDriveConfig" {
t.Errorf("removeStack calls SetCrossDriveConfig at line %d — a removal with backups kept would forget the mirror it kept (R-486)", fset.Position(s.Pos()).Line)
}
return true
})
}
}
// R-491 — a removal clears the app's UPDATE hold (and only that kind).
//
// COMPANION RED-PROOF (REPORT.md): remove the ClearUpdateHold call from removeStack — this fails.
func TestR491_RemovalClearsTheUpdateHold(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
for _, d := range f.Decls {
fn, ok := d.(*ast.FuncDecl)
if !ok || fn.Name.Name != "removeStack" || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "ClearUpdateHold" {
found = true
}
return true
})
}
if !found {
t.Error("removeStack must clear the app's update hold (R-491), or a reinstall starts held")
}
}
// R-490 — with a default storage path registered, system-info reports the drive (the fallback every
// other reader already had).
func TestR490_SystemInfoFallsBackToTheDefaultStoragePath(t *testing.T) {
r, sett, _, _ := newSlice4Router(t)
r.sett = sett // the fixture router carries no settings; system-info reads the default path from them
if err := sett.AddStoragePath(settings.StoragePath{Path: t.TempDir(), Label: "HDD", Schedulable: true, IsDefault: true}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/system/info", nil))
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
System struct {
HDDConfigured bool `json:"hdd_configured"`
} `json:"system"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.Data.System.HDDConfigured {
t.Errorf("hdd_configured must be true with a default storage path registered; body %s", w.Body.String())
}
}
@@ -0,0 +1,36 @@
package api
import (
"os"
"strings"
"testing"
)
// R-536 — the ACCEPT-time path must not claim the app is installed.
//
// The router holds a concrete *notify.Notifier, so there is no seam to fake here; the claim is
// therefore asserted where it actually lives — in the source of the deploy handler. This is the same
// shape as the stacks package's AST test, and it exists for the same reason: the defect was not a
// wrong value, it was a call in the wrong place.
//
// What it pins: the deploy handler (which writes 202 „Telepítés elindítva" and returns, while the
// compose up runs afterwards) may announce that the install STARTED, and may not announce that it
// finished. The finishing half is stacks.deployDoneHook → main.go → NotifyAppDeployed, pinned by
// TestDeployDoneHook_FiresAtTheEndAndSaysWhichEndItWas.
//
// Red-proof: put `r.notifier.NotifyAppDeployed(name, displayName)` back beside the 202 → this fails.
func TestDeployAcceptance_DoesNotClaimTheAppIsInstalled(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
if strings.Contains(body, "NotifyAppDeployed(") {
t.Fatal("the deploy handler announces an INSTALLED app at accept time — the install has not run yet; " +
"an interrupted deploy then stands on the timeline as a completed one (measured 2026-09-16, mealie)")
}
if !strings.Contains(body, "NotifyAppDeployStarted(") {
t.Fatal("the acceptance is no longer recorded at all — the timeline loses the fact that the customer asked for this app")
}
}
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-552: an interrupted-restore notice was cleared only by a NEW restore of the same app, so a
// household that answered it by REMOVING the app kept a „Megszakadt visszaállítás" card about an app
// that no longer exists — for ever, because the notice is persisted. The consequence asserted: after
// the removal path's clear, the notice is gone from the page's list AND from the persisted record (a
// fresh Manager loading the file sees none), while another app's notice stays.
// COMPANION RED-PROOF: drop the clearInterruptedRestoreNotice call from removeStack → the AST check
// fails; make ClearInterruptedRestore a no-op → the notice is still listed.
func TestR552_RemoveClearsTheInterruptedRestoreNotice(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.DataDir = dir
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
recPath := filepath.Join(dir, "restore-status.json")
// The record two stops left behind: "otherapp" already carries a notice, "homebox" was running.
seed := map[string]any{
"running": true, "op": "restore", "stack": "homebox",
"interrupted": map[string]any{"otherapp": map[string]any{"op": "restore", "stack": "otherapp", "interrupted": true}},
}
b, _ := json.Marshal(seed)
if err := os.WriteFile(recPath, b, 0o600); err != nil {
t.Fatal(err)
}
load := func() *backup.Manager {
bm := backup.NewManager(cfg, sett, lg)
bm.SetRestoreRecordPath(recPath)
bm.LoadRestoreRecord()
return bm
}
bm := load()
if _, ok := bm.InterruptedRestore("homebox"); !ok {
t.Fatal("setup: no interrupted notice for homebox")
}
r := &Router{backupMgr: bm, logger: lg}
r.clearInterruptedRestoreNotice("homebox")
if _, ok := bm.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the removed app's interrupted-restore notice is still listed")
}
if _, ok := bm.InterruptedRestore("otherapp"); !ok {
t.Errorf("another app's notice was cleared by this app's removal")
}
again := load()
if _, ok := again.InterruptedRestore("homebox"); ok {
t.Errorf("R-552: the notice comes back after a restart — the clear was not persisted")
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "clearInterruptedRestoreNotice" {
called = true
}
return true
})
}
}
if !called {
t.Errorf("R-552: removeStack does not call clearInterruptedRestoreNotice")
}
}
@@ -0,0 +1,83 @@
package api
import (
"errors"
"net/http"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-553 — the deploy endpoint's status code must survive the day its Hungarian words are translated.
//
// Until v0.251.0 the handler read the refusal's TEXT: „kötelező", „memória", "does not exist",
// "already deployed". Localisation slice 2 translates exactly those words, and every empty required
// field would then answer 500 instead of 400 — a server error for something the customer can fix.
//
// The table below therefore passes messages that are ALREADY translated: the words are gone, only the
// kind remains. RED-PROOF (REPORT): restore the strings.Contains chain in deployStack and this fails
// on every translated row.
func TestR553_Deploy_DecisionSurvivesWordingChange(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"required field, Hungarian as shipped", util.KindErrorf(stacks.ErrRequiredField,
"a(z) %q (%s) mező kitöltése kötelező", "Jelszó", "PASSWORD"), http.StatusBadRequest},
{"required field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
"the %q (%s) field is required", "Password", "PASSWORD"), http.StatusBadRequest},
{"password field, TRANSLATED", util.KindErrorf(stacks.ErrRequiredField,
"fill in the %q field — use the Generate button", "Password"), http.StatusBadRequest},
{"path missing, TRANSLATED", util.KindErrorf(stacks.ErrPathMissing,
"a mappa %q nem létezik (%q mező)", "/mnt/nope", "Adatok"), http.StatusBadRequest},
{"not enough memory, Hungarian as shipped", util.KindError(stacks.ErrNotEnoughMemory,
"Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 512 MB, Elérhető: 100 MB"), http.StatusBadRequest},
{"not enough memory, TRANSLATED", util.KindError(stacks.ErrNotEnoughMemory,
"Not enough memory to install this app. Needed: 512 MB, available: 100 MB"), http.StatusBadRequest},
{"already deployed, TRANSLATED", util.KindErrorf(stacks.ErrAlreadyDeployed,
"a(z) %q alkalmazás már telepítve van", "privatebin"), http.StatusConflict},
{"anything else is a server error", errors.New("compose exploded"), http.StatusInternalServerError},
{"a message that merely CONTAINS the old words is not a refusal kind",
errors.New("docker: kötelező memória does not exist"), http.StatusInternalServerError},
}
for _, c := range cases {
if got := deployStatusFor(c.err); got != c.want {
t.Errorf("%s: deployStatusFor(%q) = %d, want %d", c.name, c.err.Error(), got, c.want)
}
}
}
// The seam has to be WIRED, not merely present: the handler must call deployStatusFor, and no
// decision in the deploy handler may read the error's text again (seam-built-but-never-wired, four
// instances in this repo).
func TestR553_DeployHandlerUsesTheKind(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
i := strings.Index(body, "func (r *Router) deployStack(")
if i < 0 {
t.Fatal("deployStack handler not found — this test no longer reads what it thinks it reads")
}
handler := body[i:]
if j := strings.Index(handler[10:], "\nfunc "); j > 0 {
handler = handler[:j+10]
}
if !strings.Contains(handler, "deployStatusFor(err)") {
t.Error("the deploy handler no longer asks deployStatusFor for the status code")
}
// Comments may still NAME those words (this one does); a DECISION may not read them.
for _, line := range strings.Split(handler, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
if strings.Contains(line, "strings.Contains(err.Error()") {
t.Errorf("the deploy handler decides by reading the error text again (R-553): %s", strings.TrimSpace(line))
}
}
}
@@ -0,0 +1,80 @@
package api
import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-569 — the stop/start/restart/update, remove and delete handlers pick their status by the
// refusal's KIND. Every row passes a REWORDED message: the words the handlers used to match
// ("protected", "not found", "not deployed", "still running", "not orphaned") are gone, only the
// kind remains. RED-PROOF (REPORT): restore any strings.Contains chain and its family's reworded rows
// answer 500.
func TestR569_StackOpStatus_SurvivesRewording(t *testing.T) {
cases := []struct {
family, name string
err error
want int
}{
// action family (start/stop/restart/update)
{"action", "unknown app, reworded", util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x"), http.StatusNotFound},
{"action", "unknown app, wrapped by the caller", fmt.Errorf("restarting: %w",
util.KindErrorf(stacks.ErrStackNotFound, "no app called %q", "x")), http.StatusNotFound},
{"action", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "%q is infrastructure", "traefik"), http.StatusForbidden},
{"action", "update refusal", &stacks.UpdateRefusal{Reason: "busy", Message: "foglalt"}, http.StatusConflict},
{"action", "update refusal: unknown app", &stacks.UpdateRefusal{Reason: "not_found", Message: "nincs ilyen"}, http.StatusNotFound},
// remove family
{"remove", "not deployed, reworded", util.KindErrorf(stacks.ErrNotDeployed, "%q has nothing installed", "x"), http.StatusConflict},
{"remove", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up — stop it first", "x"), http.StatusConflict},
{"remove", "protected, reworded", util.KindErrorf(stacks.ErrProtectedStack, "cannot take %q away", "traefik"), http.StatusForbidden},
// delete family
{"delete", "not orphaned, reworded", util.KindErrorf(stacks.ErrNotOrphaned, "%q still belongs to the catalog", "x"), http.StatusConflict},
{"delete", "still running, reworded", util.KindErrorf(stacks.ErrStillRunning, "%q is up", "x"), http.StatusConflict},
{"delete", "unknown, reworded", util.KindErrorf(stacks.ErrStackNotFound, "nothing named %q", "x"), http.StatusNotFound},
// the negative half: the old WORDS without a kind are not a refusal
{"any", "text that merely contains the old words", errors.New("docker: image not found; protected; still running"), http.StatusInternalServerError},
{"any", "anything else", errors.New("compose exploded"), http.StatusInternalServerError},
}
for _, c := range cases {
if got := stackOpStatusFor(c.err); got != c.want {
t.Errorf("%s / %s: stackOpStatusFor(%q) = %d, want %d", c.family, c.name, c.err.Error(), got, c.want)
}
}
}
// The seam must be WIRED in all three handlers, and none may decide by reading the error text again.
func TestR569_HandlersUseTheKind(t *testing.T) {
src, err := os.ReadFile("router.go")
if err != nil {
t.Fatal(err)
}
body := string(src)
for _, fn := range []string{"func (r *Router) actionStack(", "func (r *Router) removeStack(", "func (r *Router) deleteStack("} {
i := strings.Index(body, fn)
if i < 0 {
t.Fatalf("%s not found — this test no longer reads what it thinks it reads", fn)
}
h := body[i:]
if j := strings.Index(h[10:], "\nfunc "); j > 0 {
h = h[:j+10]
}
if !strings.Contains(h, "stackOpStatusFor(err)") {
t.Errorf("%s no longer asks stackOpStatusFor for the status code", fn)
}
for _, line := range strings.Split(h, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
if strings.Contains(line, "strings.Contains(err.Error()") {
t.Errorf("%s decides by reading the error text again (R-569): %s", fn, strings.TrimSpace(line))
}
}
}
}
@@ -0,0 +1,32 @@
package api
import (
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-575: the deploy answer renders the overcommit warning in the reader's language — English for an
// English request (it used to be Hungarian on every page), Hungarian by default.
func TestR575_OvercommitWarningFollowsTheLanguage(t *testing.T) {
r := &Router{}
en := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=en", nil), stacks.KeyMemoryOvercommitWarning)
hu := r.deployWarningData(httptest.NewRequest("POST", "/api/stacks/x/deploy?lang=hu", nil), stacks.KeyMemoryOvercommitWarning)
if en == nil || hu == nil {
t.Fatal("a warning key must produce a warning")
}
if !strings.Contains(en["warning"], "memory") || strings.Contains(en["warning"], "memóri") {
t.Errorf("English page got %q", en["warning"])
}
if !strings.Contains(hu["warning"], "memóri") {
t.Errorf("Hungarian page got %q", hu["warning"])
}
if strings.Contains(en["warning"], "err.stacks.") || strings.Contains(hu["warning"], "err.stacks.") {
t.Error("a raw key reached the page")
}
if d := r.deployWarningData(httptest.NewRequest("POST", "/", nil), ""); d != nil {
t.Errorf("no warning -> no data, got %v", d)
}
}
@@ -0,0 +1,89 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-619: GET /api/stacks/<n>/deploy-fields served a `type: password` field as `required:false` (as the
// template declares), while the deploy refuses 400 without it — so a caller that trusted the contract
// was refused (measured on grafana, 2026-09-21). The consequence asserted, on the wire through the real
// handler: the password field arrives `required:true`; a `secret` field (which the box DOES generate)
// keeps its declared `required:false`; and the stack's own metadata is not changed for the next reader.
func TestR619_PasswordFieldIsServedAsRequired(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
app := filepath.Join(cfg.Paths.StacksDir, "grafana")
if err := os.MkdirAll(app, 0o755); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n grafana:\n image: busybox\n"), 0o644)
_ = os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(`display_name: Grafana
deploy_fields:
- env_var: GF_SECURITY_ADMIN_PASSWORD
label: Admin jelszo
type: password
generate: "password:16"
required: false
- env_var: GF_SECRET_KEY
label: Titkos kulcs
type: secret
generate: "hex:32"
required: false
`), 0o644)
m, err := stacks.NewManager(cfg, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, logger: log.New(io.Discard, "", 0)}
read := func() map[string]bool {
w := httptest.NewRecorder()
r.getDeployFields(w, httptest.NewRequest(http.MethodGet, "/api/stacks/grafana/deploy-fields", nil), "grafana")
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var body struct {
Data struct {
Metadata struct {
DeployFields []struct {
EnvVar string `json:"env_var"`
Required bool `json:"required"`
} `json:"deploy_fields"`
} `json:"metadata"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, f := range body.Data.Metadata.DeployFields {
out[f.EnvVar] = f.Required
}
return out
}
got := read()
if !got["GF_SECURITY_ADMIN_PASSWORD"] {
t.Errorf("R-619: the password field reaches the wire as required:false, but the deploy refuses without it")
}
if req, ok := got["GF_SECRET_KEY"]; !ok || req {
t.Errorf("a secret field (the box generates it) must keep its declared required:false; got present=%v required=%v", ok, req)
}
if meta, _, _ := m.GetDeployFields("grafana"); meta.DeployFields[0].Required {
t.Errorf("the derivation leaked into the stack's metadata — it must be applied to the answer only")
}
}
@@ -0,0 +1,50 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// R-642 (v0.263.0): a start or restart is never answered "completed" — measured 2026-09-23 on docmost
// and romm, both 200 "start completed" while `Restarting (1)` behind a 404 front door.
//
// COMPANION RED-PROOF (REPORT.md): put `Message: "Stack " + name + " " + action + " completed"` back in
// startAnswer — the first assertion fails.
func TestR642_StartIsNeverReportedCompleted(t *testing.T) {
for _, action := range []string{"start", "restart"} {
a := startAnswer("docmost", action, "restarting")
if strings.Contains(a.Message, "completed") {
t.Errorf("%s answered %q — a start cannot know it completed", action, a.Message)
}
if !strings.Contains(a.Message, "restarting") {
t.Errorf("%s must say the state it sees, got %q", action, a.Message)
}
if d, _ := a.Data.(map[string]interface{}); d["state"] != "restarting" {
t.Errorf("%s data = %v", action, a.Data)
}
}
}
// TestR642_TheHandlerUsesStartAnswer walks the router's AST: the stack-action handler must CALL
// startAnswer (a comment naming it would not count — the v0.154.0 / R-106 inert-seam class).
func TestR642_TheHandlerUsesStartAnswer(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
calls := 0
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "startAnswer" {
calls++
}
}
return true
})
if calls != 1 {
t.Errorf("startAnswer must be called exactly once from router.go, found %d", calls)
}
}
+12
View File
@@ -0,0 +1,12 @@
package api
import (
"os"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
)
// TestMain puts a silent docker stub on PATH for every test in this package: R-650, the fixtures
// here build a real stacks.Manager, and on the build host (DooPlex) that reached production Docker.
func TestMain(m *testing.M) { os.Exit(dockerexec.RunWithStub(m)) }
@@ -0,0 +1,67 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.269.0 (`09` §3 decision 27, R-666) — while a held app's page says support is informed, the remove
// API allows only "remove the app, keep my data". Through the production wiring (NewManager + ScanStacks,
// the hold read through UpdateGuards as main.go wires it).
type heldGuards struct{ noWhole bool }
func (g heldGuards) HoldFor(string) (bool, string) { return true, "HELD" }
func (g heldGuards) HoldNoWholeCopy(string) bool { return g.noWhole }
func (g heldGuards) Busy(string) (bool, string) { return false, "" }
func (g heldGuards) CanBackUp(string) (bool, string) { return true, "" }
func (g heldGuards) BackupNow(context.Context, string) error { return nil }
func (g heldGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil }
func (g heldGuards) RestorePoints(context.Context, string, func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
return stacks.UpdateRestorePoint{}, false, nil
}
func (g heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
// COMPANION RED-PROOF (REPORT): delete the decision-27 block in removeStack — the data-deleting remove
// then reaches RemoveStack and this test fails at "was not refused".
func TestR666_HeldWithNoWholeCopyRemovesOnlyKeepingData(t *testing.T) {
for _, body := range []string{`{"remove_hdd_data":true,"remove_backups":false}`, `{"remove_hdd_data":false,"remove_backups":true}`} {
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
code, resp := postRemove(t, r, body)
if code != http.StatusConflict || resp.OK {
t.Fatalf("%s: HTTP %d ok=%v — a data-deleting remove of a stranded app was not refused", body, code, resp.OK)
}
if !strings.Contains(resp.Error, "ügyfélszolgálat") {
t.Fatalf("error = %q, want the support sentence", resp.Error)
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
t.Fatal("app.yaml gone — something was removed by a refused request")
}
}
// The dialog is told to offer keep-data only …
r, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true})
w := httptest.NewRecorder()
r.getStackHDDData(w, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
if !strings.Contains(w.Body.String(), `"keep_data_only":true`) {
t.Fatalf("hdd-data does not tell the dialog: %s", w.Body.String())
}
// … and a hold that NAMES a whole copy keeps the full dialog (positive control).
r2, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n")
r2.stackMgr.SetUpdateGuards(heldGuards{noWhole: false})
w2 := httptest.NewRecorder()
r2.getStackHDDData(w2, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app")
if strings.Contains(w2.Body.String(), "keep_data_only") {
t.Fatalf("a hold with a whole copy must keep the full dialog: %s", w2.Body.String())
}
}
@@ -0,0 +1,76 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-706 (v0.279.0) — a removal "with its backups" also deletes the app's off-site verification copy, and
// ONLY that app's. COMPANION RED-PROOF: remove the removeVerificationCopy call from removeStack → the AST
// check fails; make the helper skip the delete → the copy is still on disk.
func TestR706_RemovalWithBackupsDeletesTheVerificationCopy(t *testing.T) {
dir := t.TempDir()
drive := filepath.Join(dir, "drive")
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
_ = os.MkdirAll(cfg.Paths.DataDir, 0o755)
_ = os.MkdirAll(drive, 0o755)
sett, err := settings.Load(filepath.Join(cfg.Paths.DataDir, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
bm := backup.NewManager(cfg, sett, log.New(io.Discard, "", 0))
mine, other := bm.OffsiteRestoreScratchPath("cloudapp"), bm.OffsiteRestoreScratchPath("otherapp")
if mine == "" || other == "" {
t.Fatalf("no verification-copy location resolved (%q, %q)", mine, other)
}
for _, p := range []string{mine, other} {
_ = os.MkdirAll(p, 0o755)
_ = os.WriteFile(filepath.Join(p, "restored.bin"), []byte("x"), 0o644)
}
r := &Router{backupMgr: bm, logger: log.New(io.Discard, "", 0)}
got := r.removeVerificationCopy("cloudapp")
if len(got) != 1 || !strings.HasPrefix(got[0], mine) {
t.Fatalf("reported %v, want the copy at %s", got, mine)
}
if _, err := os.Stat(mine); !os.IsNotExist(err) {
t.Fatalf("the verification copy is still on disk: %v", err)
}
if _, err := os.Stat(filepath.Join(other, "restored.bin")); err != nil {
t.Fatalf("another app's verification copy was touched: %v", err)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
called := false
for _, d := range f.Decls {
if fn, ok := d.(*ast.FuncDecl); ok && fn.Name.Name == "removeStack" && fn.Body != nil {
ast.Inspect(fn.Body, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok && s.Sel.Name == "removeVerificationCopy" {
called = true
}
return true
})
}
}
if !called {
t.Fatal("removeStack does not call removeVerificationCopy (R-706)")
}
}
@@ -0,0 +1,67 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-839: a deploy whose HDD_PATH names a folder INSIDE a registered drive is refused at the API, so
// the boot sweep can never again hold an app for asking a folder whether it is a mountpoint. Through
// the real deployStack handler. PATH is emptied so a regression that lets the deploy proceed can
// never reach a real docker (tests that reach real docker act on DooPlex).
func TestR839_DeployRefusesASubPathOfADrive(t *testing.T) {
t.Setenv("PATH", "")
lg := log.New(io.Discard, "", 0)
root := t.TempDir()
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
const drive = "/mnt/felhom-drives/scratch_hdd"
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "HDD", Schedulable: true}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
cfg.Stacks.ComposeCommand = "docker compose"
if err := os.MkdirAll(cfg.Paths.StacksDir, 0o755); err != nil {
t.Fatal(err)
}
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
r := &Router{stackMgr: m, cfg: cfg, sett: sett, logger: lg}
r.classifyFSPath = func(string) string { return "" }
deploy := func(hdd string) (int, string) {
w := httptest.NewRecorder()
body := `{"values":{"HDD_PATH":"` + hdd + `"}}`
r.deployStack(w, httptest.NewRequest(http.MethodPost, "/api/stacks/paperless-ngx/deploy", strings.NewReader(body)), "paperless-ngx")
return w.Code, w.Body.String()
}
code, body := deploy(drive + "/userdata/paperless-ngx")
if code != http.StatusConflict || !strings.Contains(body, "nem egy mappa") || !strings.Contains(body, drive) {
t.Fatalf("R-839: a folder inside a drive must be refused naming the drive; got %d %s", code, body)
}
// Controls: the drive itself and an unregistered path are not this gate's business.
for _, ok := range []string{drive, drive + "/", "/srv/elsewhere"} {
if _, b := deploy(ok); strings.Contains(b, "nem egy mappa") {
t.Errorf("R-839: %q must not be refused as a folder inside a drive: %s", ok, b)
}
}
if d, in := sett.DriveOfSubPath(drive + "x/userdata"); in {
t.Errorf("a sibling path sharing the prefix (%s) is not inside the drive", d)
}
}
@@ -0,0 +1,41 @@
package api
import (
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// R-970 (v0.322.0): the router has no /config routes at all — even a SIGNED-IN request gets the router's 404 for every
// method, and the settings file is neither served nor written.
// RED-PROOF (2026-10-16, REPORT): with the `/config` GET case put back, this failed at „GET /api/config answered 200".
func TestR970_ConfigRoutesAreGone(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "controller.yaml")
planted := "customer:\n id: t\n domain: example.hu\nhub:\n api_key: TESTONLY-key-r970\n"
if err := os.WriteFile(path, []byte(planted), 0o600); err != nil {
t.Fatal(err)
}
r := &Router{configPath: path, logger: log.New(io.Discard, "", 0)}
r.restart = func() { t.Fatal("a /config request restarted the controller") }
for _, p := range []string{"/api/config", "/api/config/", "/api/config/apply", "/api/config/hash"} {
for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(m, p, strings.NewReader("customer:\n id: evil\n domain: e.example\n")))
if w.Code != http.StatusNotFound {
t.Errorf("%s %s answered %d, want the router's 404", m, p, w.Code)
}
if strings.Contains(w.Body.String(), "TESTONLY-key-r970") {
t.Fatalf("%s %s served the settings file", m, p)
}
}
}
if got, _ := os.ReadFile(path); string(got) != planted {
t.Fatal("the settings file was written")
}
}
@@ -0,0 +1,95 @@
package api
import (
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-442 through the PRODUCTION WIRING: the POST /api/stacks/{name}/remove handler → the real
// stacks.Manager (constructor + ScanStacks, no hand-set state) → RemoveStack. Two refusals, both
// non-2xx with the exact customer sentence, and in both cases app.yaml is still on disk afterwards.
// Neither reaches the compose boundary (the refusal precedes it), so no docker is touched.
func newR442Router(t *testing.T, appYAML string) (*Router, string) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
compose := "services:\n app:\n image: nginx:1.27\n volumes:\n - ${HDD_PATH}/appdata/app:/data\n"
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = root
cfg.Paths.SystemDataPath = "/mnt/sys_drive"
cfg.Stacks.ComposeCommand = "docker compose" // skips detection; never invoked on the refusal path
lg := log.New(io.Discard, "", 0)
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
return &Router{cfg: cfg, stackMgr: m, logger: lg}, dir
}
func postRemove(t *testing.T, r *Router, body string) (int, apiResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/stacks/app/remove", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.removeStack(w, req, "app")
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
func TestRemoveHandler_R442_UnresolvedDriveIsRefusedNon2xx(t *testing.T) {
r, dir := newR442Router(t, "deployed: true\nenv: {}\n")
code, resp := postRemove(t, r, `{"remove_hdd_data":true,"remove_backups":false}`)
if code/100 == 2 || resp.OK {
t.Fatalf("HTTP %d ok=%v — a 2xx over a removal that could not resolve the data location is the R-442 defect (body error=%q)", code, resp.OK, resp.Error)
}
if code != http.StatusConflict {
t.Fatalf("status = %d, want 409", code)
}
const want = "Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva."
if resp.Error != want {
t.Fatalf("error = %q, want the exact customer sentence", resp.Error)
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
t.Fatal("app.yaml gone — the app was removed with its data left behind")
}
}
func TestRemoveHandler_R442_DriveAbsentIsRefusedNon2xx(t *testing.T) {
drive := t.TempDir() // a plain directory is not a mountpoint → the drive is "absent"
r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: "+drive+"\n")
code, resp := postRemove(t, r, `{"remove_hdd_data":true}`)
if code != http.StatusConflict || resp.OK {
t.Fatalf("HTTP %d ok=%v, want 409 refusal (error=%q)", code, resp.OK, resp.Error)
}
if !strings.Contains(resp.Error, drive) || !strings.Contains(resp.Error, "vissza nem csatlakozik") {
t.Fatalf("error = %q, want the drive-absent sentence naming %s", resp.Error, drive)
}
if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil {
t.Fatal("app.yaml gone on a drive-absent refusal")
}
}
+402 -202
View File
@@ -1,14 +1,12 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
@@ -108,8 +106,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
// local (non-network) and empty paths keep today's behavior exactly.
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
@@ -119,7 +117,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
if r.classifyFSPath(hdd) != system.FSClassStub {
return ""
}
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
return "api.deploy.netstorage_unreachable"
}
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
@@ -179,17 +177,11 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/selfupdate/update" && req.Method == http.MethodPost:
r.selfupdateTrigger(w, req)
// POST /api/config/apply — Hub pushes generated YAML to update controller.yaml
case path == "/config/apply" && req.Method == http.MethodPost:
r.configApply(w, req)
// GET /api/config/hash — return current config file hash
case path == "/config/hash" && req.Method == http.MethodGet:
r.configHash(w, req)
// GET /api/config — return raw controller.yaml content
case path == "/config" && req.Method == http.MethodGet:
r.configContent(w, req)
// v0.322.0 (R-970): /api/config, /api/config/hash and /api/config/apply are GONE. They read the box's
// controller.yaml with every secret in it and replaced it — reachable with the box's own hub key, unsigned,
// which contradicted `01-topology-and-trust.md` §4/§10. Nothing called them (the hub never connects to a box;
// the agent injects config at provision; the signed config bundle writes root files). Pinned by
// TestR970_ConfigRoutesAreGone and cmd/controller TestR970_HubKeyOpensNoDoor.
// POST /api/selfrestart — customer-facing graceful self-restart (auth + CSRF via /api/ mount)
case path == "/selfrestart" && req.Method == http.MethodPost:
@@ -222,19 +214,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
r.actionStack(w, req, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
r.actionStack(w, req, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
r.actionStack(w, req, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
r.actionStack(w, req, "update", extractName(path, "/update"))
// POST /api/stacks/{name}/optional-config
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
@@ -357,11 +349,11 @@ func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}
func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
func (r *Router) rescanStacks(w http.ResponseWriter, req *http.Request) {
r.logger.Printf("[INFO] [api] Manual stack rescan requested")
if err := r.stackMgr.ScanStacks(); err != nil {
r.logger.Printf("[ERROR] [api] Stack rescan failed: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
stackCount := len(r.stackMgr.GetStacks())
@@ -372,22 +364,49 @@ func (r *Router) rescanStacks(w http.ResponseWriter, _ *http.Request) {
})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStack(w http.ResponseWriter, req *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
// v0.264.0 (R-606): the page polls this for the Update button's label and the outcome. Rendered in
// the reader's language; a Hungarian reader gets exactly the bytes it always got. GetStack returns a
// COPY, so nothing here touches the manager's own state.
// v0.265.0 (R-647): EVERY reader, Hungarian included — a Hungarian reader of an English box read
// the hold in English before. On a Hungarian box a Hungarian reader still gets the same bytes.
lang := r.langFor(req)
stack.UpdatePhaseLabel = stacks.UpdatePhaseLabelIn(lang, stack.UpdatePhase)
stack.UpdateError = r.stackMgr.UpdateErrorFor(*stack, lang)
stack.HoldReason = r.stackMgr.HoldReasonFor(*stack, lang)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
// markPasswordFieldsRequired (R-619) serves the deploy refusal's real rule on the wire: a
// `type: password` field is MANDATORY however the template's `required` reads, because the deploy
// never generates a password the household has not seen (stacks/deploy.go, the "password" case).
// Served `required:false`, a caller that trusts the contract was refused 400. Derived here, not stored,
// so templates need no edit. meta is the handler's own fresh LoadMetadata copy, so changing it touches
// no shared state. Pinned by TestR619_PasswordFieldIsServedAsRequired.
func markPasswordFieldsRequired(meta *stacks.Metadata) {
if meta == nil {
return
}
for i := range meta.DeployFields {
if meta.DeployFields[i].Type == "password" {
meta.DeployFields[i].Required = true
}
}
}
func (r *Router) getDeployFields(w http.ResponseWriter, req *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
markPasswordFieldsRequired(meta)
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
@@ -397,13 +416,35 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// deployStatusFor maps a deploy refusal to its HTTP status by the refusal's KIND (R-553).
//
// It used to read the customer's Hungarian sentence — `strings.Contains(err.Error(), "kötelező")`,
// „memória", plus the English "does not exist" / "already deployed". Localisation slice 2 translates
// exactly those words, and on that day every validation refusal would have become a 500: the customer
// sees a server error for a field they simply left empty, and the API contract changes silently.
// The sentences are untouched; the kinds come from internal/stacks (deploy_errors.go).
// Pinned by TestR553_Deploy_DecisionSurvivesWordingChange.
func deployStatusFor(err error) int {
switch {
case errors.Is(err, stacks.ErrAlreadyDeployed), errors.Is(err, stacks.ErrKeptDataChoice):
return http.StatusConflict
case errors.Is(err, stacks.ErrProtectedStack):
return http.StatusForbidden
case errors.Is(err, stacks.ErrRequiredField), errors.Is(err, stacks.ErrPathMissing), errors.Is(err, stacks.ErrNeedsDrive),
errors.Is(err, stacks.ErrNotEnoughMemory):
return http.StatusBadRequest
}
return http.StatusInternalServerError
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[INFO] [api] Deploy requested for stack: %s", name)
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
var body struct {
Values map[string]string `json:"values"`
Values map[string]string `json:"values"`
KeptData string `json:"kept_data"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
@@ -421,7 +462,15 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
name, st.Meta.EffectiveLifecycle())
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
Error: r.msg(req, "api.deploy.not_installable")})
return
}
// v0.320.0: a protected infra stack is part of the box, never an app to install. Refused here, before the
// kept-data check or anything else that reads the request, and again inside DeployStack for every other caller.
if st, ok := r.stackMgr.GetStack(name); ok && (st.Protected || r.cfg.IsProtectedStack(name)) {
r.logger.Printf("[WARN] [api] Deploy refused for %s: a protected infra stack is part of the box", name)
writeJSON(w, http.StatusForbidden, apiResponse{OK: false, Error: r.msg(req, "api.deploy.part_of_box")})
return
}
@@ -432,9 +481,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
hr.AvailGB, hr.ReserveGB)})
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
return
}
@@ -442,9 +490,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
// send the app's data to the system drive.
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
return
}
@@ -462,22 +510,38 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
return
}
// R-839: HDD_PATH must name the drive itself, never a folder inside it — see
// settings.DriveOfSubPath. The deploy page only offers drive roots; this guards every other caller.
if drive, inside := r.sett.DriveOfSubPath(body.Values["HDD_PATH"]); inside {
r.logger.Printf("[WARN] [api] Deploy refused for %s: HDD_PATH %s is a folder inside the drive %s, not the drive (R-839)", name, body.Values["HDD_PATH"], drive)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.deploy.hdd_path_inside_drive", drive)})
return
}
// `09` §3 decision 36: the app's drive folder already holds its old data → the household chooses.
if handled := r.keptDataAtInstall(w, req, name, body.Values["HDD_PATH"], body.KeptData); handled {
return
}
// R-704 (v0.278.0): a fresh install drops a hold an EARLIER install left under this name.
r.dropLeftoverHold(name, "install")
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
KeptData: body.KeptData,
}
warning, err := r.stackMgr.DeployStack(deployReq)
if err != nil {
r.logger.Printf("[ERROR] [api] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
// R-553 — the status code comes from the refusal's KIND (deployStatusFor), never from its words.
resp := apiResponse{OK: false, Error: r.errText(req, err)}
if errors.Is(err, stacks.ErrNeedsDrive) {
// D4 (`16` §5): the wizard renders the sentence with a link to the drives page.
resp.Data = map[string]string{"code": "needs_drive", "link": "/storage"}
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "kötelező") || strings.Contains(err.Error(), "memória") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, deployStatusFor(err), resp)
return
}
@@ -485,9 +549,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
if warning != "" {
resp.Data = map[string]string{"warning": warning}
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
if d := r.deployWarningData(req, warning); d != nil {
resp.Data = d
}
writeJSON(w, http.StatusAccepted, resp)
@@ -497,7 +561,10 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
if s, ok := r.stackMgr.GetStack(name); ok && s.Meta.DisplayName != "" {
displayName = s.Meta.DisplayName
}
r.notifier.NotifyAppDeployed(name, displayName)
// R-536: this is the ACCEPTANCE, not the installation. `app_deployed` moved to the async
// path's own end (stacks.deployDoneHook → Router.OnDeployDone), because the deploy runs after
// this returns and an interrupted one used to leave a completed-install record behind.
r.notifier.NotifyAppDeployStarted(name, displayName)
}
// Re-sync geo rules (new hostname may need to be added)
@@ -562,7 +629,37 @@ func desiredStateForAction(action string) (string, bool) {
}
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// stackOpStatusFor maps a stack-lifecycle refusal (stop/start/restart/update, remove, delete) to its
// HTTP status by the refusal's KIND (R-569) — the R-553 rule one handler family over.
//
// Until R-569 these three handlers matched "protected", "not found", "not deployed", "still running"
// and "not orphaned" in err.Error(). Those strings are internal English, so localisation never moved
// them; a reworded internal message would have, silently turning a 404/403/409 into a 500. The kinds
// come from internal/stacks (stack_errors.go); the messages are unchanged.
//
// One function serves the three families because their kinds are disjoint: an action never returns
// ErrNotOrphaned, a remove never returns an UpdateRefusal. Pinned by
// TestR569_StackOpStatus_SurvivesRewording and TestR569_HandlersUseTheKind.
func stackOpStatusFor(err error) int {
var ref *stacks.UpdateRefusal
switch {
case errors.Is(err, stacks.ErrStackNotFound):
return http.StatusNotFound
case errors.As(err, &ref):
if ref.Reason == "not_found" {
return http.StatusNotFound
}
return http.StatusConflict
case errors.Is(err, stacks.ErrProtectedStack):
return http.StatusForbidden
case errors.Is(err, stacks.ErrNotDeployed), errors.Is(err, stacks.ErrStillRunning),
errors.Is(err, stacks.ErrNotOrphaned):
return http.StatusConflict
}
return http.StatusInternalServerError
}
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
@@ -578,9 +675,27 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
// R-379/R-380: an app held after a failed restore + failed rollback must not start from the
// customer's button either. Checked BEFORE the drive gate because it applies to driveless apps,
// which is the class the hold exists for.
if action == "start" || action == "restart" {
//
// R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be
// updated — the one action most likely to make a held app's data worse. Pinned by
// TestR439_UpdateOfAHeldAppIsRefused.
// v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started
// again by the household's Start — the hold is lifted and the app gets one more try. Restart and
// Update stay refused while it holds; any other hold kind is untouched.
if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop {
if r.backupMgr.LiftUnhealthyStop(name) {
r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name)
}
}
if action == "start" || action == "restart" || action == "update" {
if held, why := r.restoreHoldFor(name); held {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why})
// `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app
// answers 409 with no machine-readable reason at all, and an unattended caller cannot
// tell it from a passing backup window: it would press a terminally-refused button for
// ever. Found while writing the reason test, not by reading. `held` is TERMINAL until a
// person acts, and it is the one reason that matters most to get right.
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: why,
Data: map[string]string{"reason": "held"}})
return
}
}
@@ -589,7 +704,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if gated, hdd := r.startGatedByMissingDrive(name); gated {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
Error: r.msg(req, "api.start.drive_missing", hdd),
})
return
}
@@ -609,7 +724,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
if afterMB > usableMB {
writeJSON(w, http.StatusConflict, apiResponse{
OK: false,
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
})
return
}
@@ -617,6 +732,34 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
}
}
// Slice 4: every cheap refusal of an update — busy, already updating, deploying, memory, disk, and
// "no backup to return to" — BEFORE the intent below is recorded, so an update that was never going
// to happen records nothing (§8.2). Each is a 409 with the Hungarian sentence.
if action == "update" {
if ref := r.stackMgr.UpdatePreflight(name); ref != nil {
status := http.StatusConflict
if ref.Reason == "not_found" {
status = http.StatusNotFound
}
// errText, not ref.Message (v0.260.0): a refusal built with refuseUpdateErr carries its
// bundle key, so the household reads the refusal in its own language. A refusal still
// built from a Hungarian literal has no key and errText returns ref.Message byte for
// byte — which is why this line is safe to change for all of them at once, and why the
// R-524 downgrade refusal below is not a key nobody reads (the "seam built but never
// wired" class).
//
// `data.reason` (v0.261.0) is ADDITIVE and is for a caller that is not a person. The
// sentence says WHAT happened; only the reason says whether trying again can ever work —
// `busy`/`updating`/`deploying`/`migrating`/`self_updating` are TRANSIENT, `held` and
// `downgrade` are TERMINAL until a human acts. An unattended caller that cannot tell
// those apart either gives up on a passing backup window or presses a refused button for
// ever. The sentence is unchanged, so no page moves.
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, ref),
Data: map[string]string{"reason": ref.Reason}})
return
}
}
// R-166: THE CUSTOMER-INTENT POINT. This switch is where a human's decision about whether their
// app should be running enters the system, and until v0.189.0 that decision was recorded nowhere
// — so the box had to infer it from container counts, and inferred wrong for a power cut and for
@@ -631,7 +774,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
writeJSON(w, http.StatusInternalServerError, apiResponse{
OK: false,
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
Error: r.msg(req, "api.action.state_not_saved"),
})
return
}
@@ -646,23 +789,42 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
case "restart":
err = r.stackMgr.RestartStack(name)
case "update":
err = r.stackMgr.UpdateStack(name)
// Slice 4: the GUARDED update. It returns as soon as the job has started; the result is only
// ever known from GET /api/stacks/{name} (updating / update_phase / update_error).
err = r.stackMgr.StartGuardedUpdate(name)
}
if err != nil {
r.logger.Printf("[ERROR] [api] %s failed for %s: %v", action, name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
// R-443 (slice 4): an update is NEVER reported completed here. The spike measured this line saying
// "update completed" over an app that was already crash-looping. It answers 202 — accepted, not
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
if action == "update" {
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
Data: map[string]interface{}{"accepted": true, "completed": false}})
return
}
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
// the state the containers are in right now, so that is what it says; whether the APP works is
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
if action == "start" || action == "restart" {
state := ""
_ = r.stackMgr.RefreshStatus()
if st, ok := r.stackMgr.GetStack(name); ok {
state = string(st.State)
}
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
} else {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
}
// Trigger integration lifecycle hooks after successful action
if r.integrationMgr != nil {
@@ -689,11 +851,11 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil {
r.logger.Printf("[ERROR] [api] Optional config update failed for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
}
// --- Integration API handlers ---
@@ -734,13 +896,13 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled)
if err != nil {
r.logger.Printf("[ERROR] [api] Integration toggle failed for %s:%s: %v", provider, target, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
msg := "Integráció engedélyezve"
msg := r.msg(req, "api.integration.enabled")
if !body.Enabled {
msg = "Integráció kikapcsolva"
msg = r.msg(req, "api.integration.disabled")
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
}
@@ -758,22 +920,23 @@ func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name str
output, err := r.stackMgr.GetLogs(name, lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name string) {
resp, err := r.stackMgr.GetStackHDDData(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
func (r *Router) getStackBackupData(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
return
@@ -783,18 +946,76 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name
// mount IS the namespace; SSD-only: <systemDataPath>/felhom-data). Passing the namespace root
// (not the raw drive) keeps GetStackBackupData's paths single-nested under Model A.
var nsRoot string
var mirrors []string
if r.backupMgr != nil {
nsRoot = r.backupMgr.AppNamespaceRoot(name)
mirrors = r.backupMgr.Tier2MirrorDirsForApp(name) // R-485: the real second-drive copy, not a dead `rsync` path
}
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot)
resp, err := r.stackMgr.GetStackBackupData(name, nsRoot, mirrors)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
}
// dropLeftoverHold (R-704, v0.278.0) clears the update or crash-loop hold of an app that is NOT installed,
// before a new install of it begins (a plain install, or „use my kept data"). Such a hold can only have
// been left by an earlier install: before v0.242.0 a removal kept update holds, and before v0.278.0 it
// kept crash-loop stops — measured 2026-09-28: a nextcloud update hold from 2026-09-13 on demo-hp made
// the backup skip the FRESH install's volumes and unit, so its off-site copy held no data; on 9202 a
// calcom crash-loop stop refused the fresh install's update. A restore hold (R-379) is never touched.
// Pinned by TestR704_AFreshInstallDropsALeftoverHold.
func (r *Router) dropLeftoverHold(name, why string) {
if r.sett == nil {
return
}
if st, ok := r.stackMgr.GetStack(name); ok && (st.Deployed || st.Deploying) {
return
}
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
r.logger.Printf("[WARN] [api] %s %s: the hold an earlier install left could not be cleared: %v", why, name, err)
} else if cleared {
r.logger.Printf("[INFO] [api] %s %s: a hold left by an EARLIER install of this app is dropped (R-704)", why, name)
}
}
// clearInterruptedRestoreNotice (R-552) drops the removed app's „Megszakadt visszaállítás" notice. The
// notice tells the household to run the restore again; once the app is removed that advice has no
// subject, and nothing else would ever clear it. Pinned by TestR552_RemoveClearsTheInterruptedRestoreNotice.
func (r *Router) clearInterruptedRestoreNotice(name string) {
if r.backupMgr == nil {
return
}
if r.backupMgr.ClearInterruptedRestore(name) {
r.logger.Printf("[INFO] [api] remove %s: its interrupted-restore notice is cleared with it (R-552)", name)
}
}
// removeVerificationCopy (R-706, v0.279.0) deletes the app's off-site VERIFICATION copy
// (`backups/offsite-restore/<app>`, left by a full off-site restore for the household to inspect) when the
// app is removed "with its backups". Measured 2026-09-28 on demo-hp: ~1 GB stayed after such a removal, and
// the app list no longer showed anything it belonged to. Returns the removed path as the removal reports it.
// Pinned by TestR706_RemovalWithBackupsDeletesTheVerificationCopy.
func (r *Router) removeVerificationCopy(name string) []string {
if r.backupMgr == nil {
return nil
}
var out []string
for _, c := range r.backupMgr.ListOffsiteRestoreCopies() {
if c.Stack != name {
continue
}
if err := r.backupMgr.DeleteOffsiteRestoreCopy(name); err != nil {
r.logger.Printf("[WARN] [api] remove %s: its off-site verification copy %s could not be deleted: %v", name, c.Path, err)
continue
}
out = append(out, fmt.Sprintf("%s (%s)", c.Path, c.SizeHuman))
}
return out
}
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
if name == "" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
@@ -814,39 +1035,84 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
}
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
// v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only
// "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule).
if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) {
r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name)
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)})
return
}
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
var backupPaths []string
//
// R-474 (v0.240.0): "delete backups" deletes the app's WHOLE recovery unit (definition, db-dumps,
// volume tars) and its Tier-2 mirror, not only db-dumps. The mirror is on another drive, outside
// RemoveStack's per-app base, so the backup manager removes it — and its location is read NOW,
// before the cross-drive record is cleared below. Off-site snapshots are not touched by this path.
var backupPaths, mirrorDirs []string
if body.RemoveBackups && r.backupMgr != nil {
nsRoot := r.backupMgr.AppNamespaceRoot(name)
if nsRoot != "" {
backupPaths = append(backupPaths, backup.AppDBDumpPath(nsRoot, name))
backupPaths = append(backupPaths, backup.RecoveryUnitPath(nsRoot, name), backup.AppDBDumpPath(nsRoot, name))
}
mirrorDirs = r.backupMgr.Tier2MirrorDirsForApp(name)
}
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
if err != nil {
r.logger.Printf("[ERROR] [api] Remove failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
// R-442: a refused data removal is a first-class, non-2xx answer with the exact customer
// sentence — never a 200 with empty lists. The app was not removed either.
var refused *stacks.RemoveRefusedError
if errors.As(err, &refused) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
// R-633: "the backup side owns this app right now" is a CONFLICT, not a server error. It is
// matched by TYPE rather than by prose — the status mapping below greps the error text, and
// the busy sentence contains none of the words it looks for, so the first live run of this
// guard answered 500 while doing exactly the right thing.
var busy *stacks.RemoveBusyError
if errors.As(err, &busy) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
// Clean up cross-drive backup config for this stack
if r.sett != nil {
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
r.logger.Printf("[WARN] [api] Failed to clean cross-drive config for %s: %v", name, err)
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
}
if body.RemoveBackups {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
}
// R-486 (v0.240.0): the app's backup preferences — and with them the Tier-2 RECORD that
// tier2RecordedCopyDir needs — are forgotten ONLY when the customer asked for the backups to be
// deleted. Until v0.239.0 the cross-drive record was cleared on every removal, so an app removed
// with its backups kept could not be restored from its intact second-drive mirror (measured on
// demo-hp 2026-09-13: „nincs másodlagos fájlmásolat" over a 236 MB mirror). Pinned by
// TestR486_RemovalKeepsTheTier2RecordUnlessBackupsGo.
if r.sett != nil && body.RemoveBackups {
if err := r.sett.DeleteAppBackupPrefs(name); err != nil {
r.logger.Printf("[WARN] [api] Failed to forget the backup preferences of %s: %v", name, err)
}
}
// R-491 (v0.242.0): an app held after a failed update that the customer then REMOVES is not held
// any more — the hold named a copy for a deploy that no longer exists, and every start gate reads
// the store, so a reinstall under the same name would begin refused. Measured on demo-hp
// 2026-09-13 (nextcloud). An R-379 restore hold is deliberately NOT cleared here.
if r.sett != nil {
if cleared, err := r.sett.ClearUpdateHold(name); err != nil {
r.logger.Printf("[WARN] [api] Failed to clear the update hold of %s: %v", name, err)
} else if cleared {
r.logger.Printf("[INFO] [api] remove %s: its update hold is cleared with it (R-491)", name)
}
}
r.clearInterruptedRestoreNotice(name)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
@@ -883,17 +1149,13 @@ func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name stri
resp, err := r.stackMgr.DeleteStack(name, body.RemoveHDDData)
if err != nil {
r.logger.Printf("[ERROR] [api] Delete failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
var refused *stacks.RemoveRefusedError // R-442, same as removeStack
if errors.As(err, &refused) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: refused.Message})
return
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
if strings.Contains(err.Error(), "not orphaned") || strings.Contains(err.Error(), "still running") {
status = http.StatusConflict
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
status := stackOpStatusFor(err) // R-569: by kind, never by the error's words
writeJSON(w, status, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -915,7 +1177,15 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
info := system.GetInfo(r.cfg.Paths.HDDPath, r.cpuCollector)
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
hddPath := ""
if r.sett != nil {
if p := r.sett.GetDefaultStoragePath(); p != "" {
hddPath = p
}
}
info := system.GetInfo(hddPath, r.cpuCollector)
// F1: GetInfo now reports the guest RAM cap (from the Docker daemon) as TotalMemMB, but the guest-wide
// "used" is not observable from the container. Report the controller's accurate committed-app memory
// (sum of running apps' mem requests) as used — a meaningful "allocated of cap" figure for the UI.
@@ -930,7 +1200,10 @@ func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
info.MemPercent = float64(used) / float64(info.TotalMemMB) * 100
}
}
syncStatus := r.syncer.Status()
var syncStatus interface{}
if r.syncer != nil { // nil in tests; production always wires the syncer
syncStatus = r.syncer.Status()
}
data := map[string]interface{}{
"system": info,
"sync_status": syncStatus,
@@ -1016,7 +1289,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
@@ -1025,7 +1298,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
@@ -1033,23 +1306,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
go r.backupMgr.RunDBDumps(context.Background())
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
}
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
return
}
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
go r.backupMgr.RunAllTier2()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
}
// --- Metrics handlers ---
@@ -1066,7 +1339,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
samples, err := r.metricsStore.QuerySystemMetrics(from, to, resolution)
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query system metrics: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1094,7 +1367,7 @@ func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
}})
}
func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request) {
func (r *Router) metricsContainerSummary(w http.ResponseWriter, req *http.Request) {
if r.metricsStore == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
@@ -1103,7 +1376,7 @@ func (r *Router) metricsContainerSummary(w http.ResponseWriter, _ *http.Request)
summary, err := r.metricsStore.QueryContainerSummary()
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query container summary: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1122,7 +1395,7 @@ func (r *Router) metricsContainer(w http.ResponseWriter, req *http.Request, name
samples, err := r.metricsStore.QueryContainerMetrics(name, from, to, resolution)
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to query container metrics for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
@@ -1236,108 +1509,17 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
}
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
if r.updater == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
return
}
if err := r.updater.TriggerUpdate("manual"); err != nil {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: err.Error()})
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, err)})
return
}
r.logger.Println("[INFO] [api] Manual self-update triggered")
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
}
// --- Config apply handler ---
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
// Read YAML body (limit to 1MB)
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "failed to read request body"})
return
}
if len(body) == 0 {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "empty request body"})
return
}
// Validate it's parseable YAML by attempting to load it
if _, err := config.LoadFromBytes(body); err != nil {
r.logger.Printf("[WARN] [api] Config apply rejected: invalid YAML: %v", err)
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: fmt.Sprintf("invalid config YAML: %v", err)})
return
}
// No-op guard: if the pushed config is byte-identical to what is already on disk, do
// nothing — don't rewrite, don't restart. The hub may re-push the same config
// idempotently, and a self-restart on every push would be a needless flap.
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
return
}
// Write config 0600: it holds infra credentials (cf_api_token, cf_tunnel_token, hub api_key) in
// plaintext (F8), so it must not be world-readable. writeConfig0600 enforces the mode even when the
// target file already existed with looser perms (os.WriteFile does not chmod an existing file).
if err := writeConfig0600(r.configPath, body); err != nil {
r.logger.Printf("[ERROR] [api] Config apply: failed to write config: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to apply config"})
return
}
// Respond to the hub FIRST (and flush), THEN self-restart. The new config only takes
// effect on restart — singletons such as the Cloudflare client are built once at
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
flushResponse(w)
if r.restart != nil {
r.restart()
}
}
// writeConfig0600 writes config bytes to path with mode 0600, atomically when possible (tmp+rename),
// falling back to a direct write for Docker bind mounts (where os.Rename returns EBUSY). It ALWAYS
// enforces 0600 on the final file — even if it already existed with looser perms — because controller.yaml
// holds infra credentials in plaintext (F8); os.WriteFile only applies the mode when creating a new file.
func writeConfig0600(path string, body []byte) error {
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, body, 0600); err != nil {
return fmt.Errorf("writing temp config: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
if err := os.WriteFile(path, body, 0600); err != nil { // bind-mount fallback
return fmt.Errorf("writing config: %w", err)
}
}
if err := os.Chmod(path, 0600); err != nil {
return fmt.Errorf("chmod config 0600: %w", err)
}
return nil
}
func (r *Router) configHash(w http.ResponseWriter, _ *http.Request) {
hash, err := config.FileHash(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"hash": hash, "path": filepath.Base(r.configPath)}})
}
func (r *Router) configContent(w http.ResponseWriter, _ *http.Request) {
data, err := os.ReadFile(r.configPath)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: "failed to read config"})
return
}
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write(data)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
}
// --- Asset sync handlers ---
@@ -1376,3 +1558,21 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
func limitBody(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
// containers are in right now — never "completed", which nothing at this point can know.
func startAnswer(name, action, state string) apiResponse {
return apiResponse{OK: true,
Message: "Stack " + name + " " + action + " requested — state now: " + state,
Data: map[string]interface{}{"state": state}}
}
// deployWarningData renders the deploy's soft warning for the answer. R-575: DeployStack hands back the
// warning's bundle KEY, never a sentence, so it is rendered here in the reader's language. nil = none.
// Pinned by TestR575_OvercommitWarningFollowsTheLanguage.
func (r *Router) deployWarningData(req *http.Request, warningKey string) map[string]string {
if warningKey == "" {
return nil
}
return map[string]string{"warning": r.msg(req, warningKey)}
}
@@ -1,7 +1,6 @@
package api
import (
"bytes"
"io"
"log"
"net/http"
@@ -28,61 +27,6 @@ func newRestartTestRouter(t *testing.T, prior []byte) (*Router, *int, string) {
return r, &calls, path
}
// a minimal config that passes config.LoadFromBytes (customer.id + customer.domain required).
var validConfig = []byte("customer:\n id: test\n domain: test.example\n")
func TestConfigApply_ChangedConfig_Restarts(t *testing.T) {
prior := []byte("customer:\n id: old\n domain: old.example\n")
r, calls, path := newRestartTestRouter(t, prior)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 1 {
t.Fatalf("restart called %d times, want 1 (config changed)", *calls)
}
got, _ := os.ReadFile(path)
if !bytes.Equal(got, validConfig) {
t.Fatalf("config not written: got %q", got)
}
}
// COMPANION: an identical re-push must NOT restart (the old handler always restarted /
// always ran its post-apply hook regardless of whether anything changed).
func TestConfigApply_IdenticalConfig_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig) // prior == body
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader(validConfig))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (config byte-identical)", *calls)
}
}
func TestConfigApply_InvalidYAML_NoRestart(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, validConfig)
req := httptest.NewRequest(http.MethodPost, "/api/config/apply", bytes.NewReader([]byte("customer:\n id: only-id-no-domain\n")))
rec := httptest.NewRecorder()
r.configApply(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (missing customer.domain)", rec.Code)
}
if *calls != 0 {
t.Fatalf("restart called %d times, want 0 (validation failed)", *calls)
}
}
func TestSelfRestart_InvokesRestarter(t *testing.T) {
r, calls, _ := newRestartTestRouter(t, nil)
@@ -0,0 +1,174 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Update arc slice 4 through the PRODUCTION handler: actionStack → the real stacks.Manager
// (NewManager + ScanStacks) and the real backup.Manager over real settings. No docker is reached:
// every path here refuses, or fails at the pin (the app's catalog template is absent on purpose).
type apiFakeGuards struct {
b *backup.Manager
points []stacks.UpdateRestorePoint
cannotBackUp bool
// blindToHolds makes the manager-side preflight NOT see holds, so a test can prove the ROUTER's
// own hold check refuses — the two layers are each pinned separately (the preflight's by
// TestSlice4_D_CheapRefusals/held). Without it, removing either layer passes inertly, because the
// other refuses with the same sentence (observed on the first run of red-proof 4, 2026-09-13).
blindToHolds bool
}
func (g *apiFakeGuards) HoldFor(n string) (bool, string) {
if g.blindToHolds {
return false, ""
}
return g.b.RestoreHoldFor(n)
}
func (g *apiFakeGuards) Busy(string) (bool, string) { return false, "" }
func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) {
for _, p := range g.points {
if accept == nil || accept(p) {
return p, true, g.points
}
}
return stacks.UpdateRestorePoint{}, false, g.points
}
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
return nil, nil
}
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards, string) {
t.Helper()
root := t.TempDir()
dir := filepath.Join(root, "stacks", "app")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx:1.27\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(slice4AppYAML), 0o600); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.StacksDir = filepath.Join(root, "stacks")
cfg.Paths.DataDir = filepath.Join(root, "data")
cfg.Paths.SystemDataPath = filepath.Join(root, "sys")
cfg.Stacks.ComposeCommand = "docker compose"
lg := log.New(io.Discard, "", 0)
m, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
b := backup.NewManager(cfg, sett, lg)
g := &apiFakeGuards{b: b, points: []stacks.UpdateRestorePoint{{Tier: stacks.UpdateTierSecondDrive, ProvenAt: time.Now().Add(-time.Hour)}}}
m.SetUpdateGuards(g)
return &Router{cfg: cfg, stackMgr: m, backupMgr: b, logger: lg}, sett, g, dir
}
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// TestR439_UpdateOfAHeldAppIsRefused — R-439 closed.
//
// COMPANION RED-PROOF 4 (REPORT.md): remove `|| action == "update"` from actionStack's hold check. The
// preflight then refuses on its own grounds with a DIFFERENT sentence, and this test fails on the
// message — which is what proves the router line is the one doing it.
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
g.blindToHolds = true // only the router's line can produce the hold's sentence
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
code, resp := postUpdate(t, r)
_, holdText := r.backupMgr.RestoreHoldFor("app")
if code != http.StatusConflict || resp.OK || resp.Error != holdText {
t.Fatalf("a HELD app's update must be refused with the hold's own sentence: code=%d ok=%v error=%q", code, resp.OK, resp.Error)
}
after, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
if string(before) != string(after) {
t.Error("a refused update must record no intent — app.yaml changed")
}
}
func TestSlice4_Router_NoBackupIs409AndRecordsNothing(t *testing.T) {
r, _, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true
before, _ := os.ReadFile(filepath.Join(dir, "app.yaml"))
code, resp := postUpdate(t, r)
if code != http.StatusConflict || resp.Error != fmt.Sprintf(stacks.MsgUpdateNoBackupFmt, "app") {
t.Fatalf("code=%d error=%q", code, resp.Error)
}
if after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")); string(before) != string(after) {
t.Error("the preflight refusal must come BEFORE the intent write")
}
}
// TestR443_UpdateIsNeverReportedCompleteSynchronously — R-443 closed. The handler answers 202 with
// completed:false; the job then runs (and here fails at the pin, the catalog being absent), and the
// outcome exists ONLY on GET /api/stacks/{name}.
func TestR443_UpdateIsNeverReportedCompleteSynchronously(t *testing.T) {
r, _, _, _ := newSlice4Router(t)
code, resp := postUpdate(t, r)
if code != http.StatusAccepted {
t.Fatalf("an accepted update must answer 202, got %d (%+v)", code, resp)
}
data, _ := resp.Data.(map[string]interface{})
if data["completed"] != false || data["accepted"] != true {
t.Errorf("the body must say accepted and NOT completed, got %v", resp.Data)
}
if resp.Message == "Stack app update completed" {
t.Error("the synchronous response claimed completion — R-443")
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if st, ok := r.stackMgr.GetStack("app"); ok && !st.Updating {
if st.UpdatePhase != stacks.UpdatePhaseFailed || st.UpdateError != stacks.MsgUpdatePinFailed {
t.Errorf("the job's truth must be on the stack: phase=%q err=%q", st.UpdatePhase, st.UpdateError)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the job never finished")
}
@@ -0,0 +1,199 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively.
//
// WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried
// only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and
// the distinction it needs is not decorative:
//
// TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later
// TERMINAL — held, downgrade → never press this again
//
// An unattended caller that cannot tell them apart either gives up on a passing backup window, or
// presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this.
//
// The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive.
// reasonOf pulls `data.reason` out of a refusal body, or "" when absent.
func reasonOf(t *testing.T, resp apiResponse) string {
t.Helper()
if resp.Data == nil {
return ""
}
m, ok := resp.Data.(map[string]interface{})
if !ok {
t.Fatalf("data is not an object: %#v", resp.Data)
}
s, _ := m["reason"].(string)
return s
}
// TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without
// docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was
// the one that had no reason at all until this test was written.
//
// COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's
// update paths. The corresponding row fails with `reason = ""`. Reverted.
func TestR609_EveryRefusalCarriesItsReason(t *testing.T) {
cases := []struct {
name string
arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string)
wantReason string
wantCode int
}{
{
name: "held — the ROUTER's own line, before the preflight",
arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) {
if err := sett.SetRestoreHold(settings.RestoreHold{
Stack: "app", At: "2026-09-21T08:00:00Z",
Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z",
}); err != nil {
t.Fatal(err)
}
},
wantReason: "held", wantCode: http.StatusConflict,
},
{
name: "no_backup — nothing on any tier and none can be taken",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) {
g.points, g.cannotBackUp = nil, true
},
wantReason: "no_backup", wantCode: http.StatusConflict,
},
{
name: "self_updating — the controller is swapping itself (v0.261.0)",
arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {
r.stackMgr.SetSelfUpdatingCheck(func() bool { return true })
},
wantReason: "self_updating", wantCode: http.StatusConflict,
},
{
name: "downgrade — the box runs something newer than the catalog (R-524)",
arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) {
setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27")
},
wantReason: "downgrade", wantCode: http.StatusConflict,
},
{
name: "not_found — an app that exists nowhere",
arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {},
wantReason: "not_found", wantCode: http.StatusNotFound,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
c.arrange(t, r, sett, g, dir)
name := "app"
if c.wantReason == "not_found" {
name = "no-such-app"
}
code, resp := postUpdateNamed(t, r, name)
if code != c.wantCode {
t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error)
}
if resp.OK {
t.Fatal("a refusal must not answer ok")
}
if got := reasonOf(t, resp); got != c.wantReason {
t.Errorf("reason = %q, want %q", got, c.wantReason)
}
// The sentence is the household's and must still be there, in words — never a bare key.
if resp.Error == "" {
t.Error("the reason is ADDITIVE: the sentence must still be present")
}
})
}
}
// TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot
// mistake a normal answer for one.
func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) {
r, _, _, _ := newSlice4Router(t)
_, resp := postStopNamed(t, r, "app")
if got := reasonOf(t, resp); got != "" {
t.Errorf("a non-refusal must carry no reason, got %q", got)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
// postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent).
func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name)
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only.
func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) {
t.Helper()
w := httptest.NewRecorder()
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name)
var resp apiResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
}
return w.Code, resp
}
// setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm.
//
// It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a
// catalog template under <DataDir>/catalog-cache/templates/<app>/ — and then rescans. **No test-only
// seam is added to production code for this**: the whole point of the api tests here is that they go
// through the production handler over a real stacks.Manager, and a setter that only tests call would
// be a second way to reach the state, which is how the two drift apart.
func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) {
t.Helper()
appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed +
"\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n"
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
t.Fatal(err)
}
catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app")
if err := os.MkdirAll(catDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"),
[]byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := r.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
// Positive control: the state this helper claims to create must actually exist, or the row it
// serves would pass for the wrong reason (a different refusal firing first).
if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead {
t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got)
}
}
func mustStack(t *testing.T, r *Router, name string) stacks.Stack {
t.Helper()
st, ok := r.stackMgr.GetStack(name)
if !ok {
t.Fatalf("stack %q missing", name)
}
return *st
}

Some files were not shown because too many files have changed in this diff Show More