## Changelog ### v0.158.0 — apps get a lifecycle: available / hidden / abandoned (2026-07-21) No agent coupling; MinAgent unchanged. Until now the catalog knew only two states: a template is present, or it is gone. "Gone" is not a usable way to withdraw an app, because **it orphans every customer already running it** — their app gets flagged `Elavult` and offered a Törlés button, for software that works fine. That is what the short-lived `retired/` directory move (2026-07-21, same day) would have done, and it is why this replaces it. `.felhom.yml` gains an optional top-level `lifecycle:`: - **`available`** — the default. Absent or empty means this, so all 52 existing templates are unchanged. - **`hidden`** — not offered for new installs. Nothing is shown to anyone already running it; "we stopped offering this" is not their problem. - **`abandoned`** — not offered for new installs, AND every box already running it carries a permanent „Nem karbantartott" badge plus a notice on the app page: *„Az alkalmazás fejlesztője felhagyott a fejlesztéssel. A telepített verzió továbbra is használható, de frissítések és biztonsági javítások már nem érkeznek hozzá."* **A deployed instance keeps full function in every state.** Lifecycle governs what is OFFERED, never what runs. - **The deploy gate is server-side and fail-closed** (`api.deployStack`, before any mutation), with the ruled Hungarian refusal „Ez az alkalmazás jelenleg nem telepíthető." Hiding a button is not a gate — a stale link, a bookmarked deploy form or a direct POST must all be refused. A second check in `stacks.DeployStack` covers any future caller that does not route through the API. - **The unknown-value posture is fail-OPEN, deliberately, and it is the opposite of the gate's.** An unrecognised value degrades to `available` with one WARN. A typo — or a state added in a later catalog than this controller understands — must never silently pull a working app out of every customer's catalog. The gate that actually protects installation reads the same `EffectiveLifecycle`, so the two can never disagree. - **Orphan detection is untouched, and that is asserted.** Withdrawn templates stay in the catalog tree; `getCatalogTemplateSlugs` never looks at lifecycle. A red-proof adds that filter and shows the abandoned app immediately reading as an orphan. - **Badge plumbing is generic**: `MetaBadge` + the `meta_badge` partial + a `lifecycleBadge` funcmap entry. R-56's difficulty labels are meant to be a sibling funcmap function returning the same type — no new markup, no new CSS. - **plant-it returns to `templates/`** as the first `abandoned` app, so the mechanism is proven on the case that motivated it. Its compose is deliberately left as-is: the app is not installable, and rewriting it would imply it is. **Red-proofs, all four run:** removing the API gate → the wiring test reports the gate INERT; dropping the `Deployed ||` clause from the catalog filter → a customer's running app vanishes from their own Alkalmazások page; removing the badge line → the abandoned app renders unmarked; making orphan detection lifecycle-aware → `catalog set = map[bookstack:true]`, the two withdrawn apps read as orphans. The wiring test walks the AST, not `strings.Contains`, because a commented-out call still contains the string; it also asserts the gate precedes `DeployStack`. ### v0.157.1 — anchor the `controller` .gitignore entry (2026-07-21) Tooling only; no behaviour change, no rebuild needed. `controller/.gitignore` carried a bare `controller`, which git matches against DIRECTORIES as well as files — so it also matched `cmd/controller/`. Two opposite failure modes came out of that, and both manufacture inert seams: ripgrep silently skipped `cmd/controller/main.go`, so a search for a setter's caller returned nothing and read as "this is unused" (a false no-caller reading has already been recorded once); and genuinely-new files under `cmd/controller/` needed `git add -f` or were never committed at all. Anchored to `/controller` + `/controller.exe`, which still ignores the built binary at the module root — verified both ways. ### v0.157.0 — the boot bind gate honours a customer's Stop (R-55) (2026-07-21) **Your Stop now means Stop across a guest reboot for drive-backed apps too** — the guarantee R-52 already gave every other app. Found by STOP-1's R-52 leg on 2026-07-21, which was designed to prove the opposite: immich, stopped from the UI seconds earlier, came back running after the reboot. The boot bind gate (`internal/web/intermediary.go`) keyed its recreate on `Deployed && HDD_PATH && drive-present` alone. `Deployed` is a deploy-lifecycle flag — it stays true across a Stop — so the gate had no way to tell "the guest went down under this app" from "the customer switched this off", and it resurrected both. R-52 was never implicated: its own gate behaved exactly as specified (immich, at zero containers, was never a candidate for it). The gate simply reaches every drive-backed app first. **The fix is R-52's own predicate, translated.** `shouldRecreateOnBoot` now also requires `len(Stack.Containers) > 0` (from `docker ps -a`, so `Exited` containers count): - containers EXIST but are down → the guest went down under the app; docker's records survive the reboot → boot orphan → recreate, as before. - ZERO containers → a UI Stop is `compose down`, which REMOVES the containers → deliberate → leave it. **What deliberately did NOT change: container STATE is still not a filter.** That is the original design's load-bearing part — a `State != stopped` filter misses an app that simply hasn't been auto-restarted yet after the boot, or is stuck `Exited` on a create-time bind failure with `RestartCount=0`. `hasContainers` is a different question ("does docker still have records of it") and, unlike liveness, it survives a reboot as a statement of intent. `TestShouldRecreateOnBoot` now pins both axes at once — they pull in opposite directions, which is the whole difficulty of this gate. - **Ordering trap, handled:** the evidence is sampled into the `bootStack` snapshot BEFORE any recreate runs, because `recreate` calls `StopStack` (`compose down`) and so destroys the very signal the decision needs. - **The drive-absent gate is not regressed.** Apps it stopped are also at zero containers, so this path now skips them — correctly: they are recorded in `StoragePath.StoppedStacks` and restarted by `ReconcileDriveGates`' `Return` branch, which runs on the same `driveGateLoop` tick. - **Honoured Stops are observable.** `leftStopped` is counted and logged separately from `skipped` at INFO (`… left stopped — zero containers means the customer stopped them on purpose`). Conflating them would have fired a WARN about a missing drive bind for an app behaving exactly as asked, and a silent correct path is how an inert seam hides. - **Red-proof (run):** dropping `hasContainers` from the predicate makes `TestRecreateDriveBackedApps_HonoursCustomerStop` fail with `recreated=[romm immich]` — the live defect, by name. ### v0.156.0 — a dead primary alerts (R-51); a boot orphan restarts itself (R-52) (2026-07-21) **No new agent coupling — MinAgent stays 0.90.0.** Two independent failures from the same live audit, both unattended-resilience holes: the box was broken and nobody was told, then the box could have fixed itself and did not. **R-51 — a multi-container app whose MAIN container is dead now counts as down.** On 2026-07-20 `immich-server` sat `Exited` for **18 hours** with the app 100 % unreachable, and the box produced no dead-app banner and no `app_start_failed` event — while single-container Calibre-Web, down for the same reason, alerted in 90 seconds (AUDIT-vacation-remote-ops-2026-07-20 F4). The defect was one branch in `aggregateState`: a stack with *some* members running and *some* stopped returned `StateRunning` — "partial" — and `IsDownState` (correctly) does not treat running as down. So the alarm never had anything to fire on. *(The ROADMAP row's diagnosis — "aggregation classifies such a stack `unhealthy`" — is wrong at the source; corrected in the row.)* - New `StateDegraded`. The mixed branch now asks each DOWN member for its restart policy: a member docker is supposed to keep running (`always` / `unless-stopped`) makes the stack **degraded**, a finished one-shot (`no` / `on-failure`) leaves it running. `IsDownState` gains `degraded` and **nothing else** — the `unhealthy` / `restarting` / `paused` / `unknown` exclusions are byte- identical, because folding `unhealthy` into down is what fix-3 removed the flapping by not doing. - An **unreadable** policy counts as supervised (fail-CLOSED), the opposite of the IsDownState fail-open rule and for a different reason: there the *state* is ambiguous, here a member is known dead and only the excuse is missing. The P2 census backs it — all 53 catalog templates / 78 services are `unless-stopped`, and zero one-shot containers exist today. - The policy read is one `docker inspect` per down member of a *mixed* stack, cached per container+state and pruned to the live container set, so the 10 s refresh does not grow a docker call per container. - Everything that asks "are there live containers here" learns the state too: quiesce (`RunningAppStacks`), delete's stop-first guard, the export stop-first guard, telemetry, health probes. Everything that asks "is this app working" counts it as down: the dashboard counter, the stopped filter, the dead-app banner and the alarm. UI: „Részlegesen leállt", warn colour, and the URL is flagged unpublished (Traefik 404s when the routed member is the dead one). **R-52 — an app the boot left behind now gets exactly one recovery.** The same shutdown left immich and calibre-web `Exited` while ten sibling containers came back; the controller *reported* them for 18 hours and never started them (F5). - New `internal/bootrecon`: one bounded sweep at startup — at most 2 attempts, 30 s apart, then it stops and the alarm owns the problem. **Never a restart loop.** - **A deliberate Stop survives a reboot.** The UI's Stop is `compose down`, which REMOVES the containers; an interrupted boot leaves them behind as `Exited`. So the boot-orphan signature is "deployed, has containers, and they are down", and a zero-container stack is never touched. - The whole sweep (5 s settle + one 30 s gap) fits inside the 90 s `deadAppBootGrace`, so a successful recovery never alerts and a failed one alerts honestly. A test asserts that arithmetic rather than leaving it to a comment. **Seam discipline (the reason both features have a wiring test).** Two inert-seam defects shipped in the two days before this: controller v0.154.0 and agent v0.91.0, both a correct component with green tests and no production caller. So the boot sweep is asserted from `package main` — including an AST walk proving `func main()` actually contains the `go runBootReconcile(...)`. That test was written first as a `strings.Contains` and **its own red-proof passed it**, because a commented-out call still contains the string. Comments are not callers; the AST version fails as it should. Red-proofs (all run, all failed on the pre-fix shape, all restored): the mix branch reverted to `return StateRunning` → the immich fixture and both production-path tests fail with `"running"`; the boot hook commented out → the wiring test fails; the zero-container gate dropped → the user-stopped app is started, which is the one thing R-52 must never do. ### v0.155.0 — the restore wizard read the wrong "is something running" flag (2026-07-21) **No new agent coupling — MinAgent stays 0.90.0.** Fixes a defect shipped in v0.154.0 and found by the operator on the first live click-through, plus the dead phase-strip label from the same release. **The bug.** `backup.Manager` carries two different booleans and v0.154.0 read the wrong one: | flag | read by | set by | covers the verification restore? | |---|---|---|---| | `running` | `IsRunning()` | `acquireRunning()`, **inside** the goroutine | **no — `RestoreOffboxScratch` never acquires it at all** | | `opRunning` | `RestoreStatus()` | `BeginRestoreOp()`, in the handler, synchronously | yes, all four offsite actions | The wizard sourced `OpRunning` from `IsRunning()`. For „Ellenőrzés" and the full-restore preparation — the wizard's two most-used actions, and the long ones, since they stream from restic — that flag is false for the *entire* operation. So the execution step was unreachable: the page kept offering all three intents with live buttons while a restore was downloading, and the progress banner (which polls the op status) contradicted the phase strip on the same screen. Any button pressed there would have been refused by the handler — which is exactly the "offering a control guaranteed to fail" dishonesty R-48 exists to remove. **The fix** is one line of behaviour behind a named seam: `restoreOpInFlight(st)` takes the `RestoreOpStatus` the handler already reads once, and its doc comment states which flag is which and why. The handler now takes a single `RestoreStatus()` read, so the strip, the suppression decision and the running-op name can no longer disagree with each other. **Why the v0.154.0 tests missed it.** The Scenario-E table proved `deriveWizardStep` behaves correctly *given* `OpRunning=true`; nothing proved the handler ever computes `true`. Hollow at exactly that seam. `TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` now drives a real `Manager` through `BeginRestoreOp` and asserts the wizard suppresses every form — red-proofed against the v0.154.0 shape. **„Eredmény" is now reachable.** The fourth phase label never lit up in v0.154.0. The strip's highlight is now its own derived value (`Phase`), separate from `Step`: a finished restore is back on the intent step — everything is available again — while the strip rightly reads „Eredmény" and an outcome card shows the result. Bounded by `restoreResultWindow` (10 min) so a stale result cannot claim to be fresh, and bound to the app, so a finished bookstack restore does not light up immich's page with bookstack's message. The card survives a reload, which the redirect flash does not. ### v0.154.0 — one restore entry per app, and the intent is a described choice (2026-07-21) Closes **R-48**. **No new agent coupling — MinAgent stays 0.90.0.** This is a UI-layer change: `internal/backup`, `internal/appbackup` and `internal/selfupdate` are untouched, and the release adds **no mutation endpoint** — every action still posts to the `/backup/offbox/*` handler it always did, with the same field names and the same gates. **The defect.** The „Ellenőrző visszaállítás a távoli tárolóból" list rendered up to five inline `