# REPORT — app lifecycle states + R-55 live leg (TASK-F Parts 1 & 3), 2026-07-21 **Versions: v0.158.0 (lifecycle) + v0.158.1 (a defect I shipped in 0.158.0 and caught live — read §4). Baseline `a96226a` (v0.157.0), clean tree. Deployed to guest 9201 and live-verified.** ## 1. What shipped — app lifecycle `.felhom.yml` gains an optional top-level `lifecycle:`. Absent/empty ≡ `available`, so all existing templates are untouched. | state | offered for new installs | shown to someone already running it | |---|---|---| | `available` | yes | nothing | | `hidden` | **no** | nothing — "we stopped offering this" is not their problem | | `abandoned` | **no** | „Nem karbantartott" badge + a permanent notice on the app page | **A deployed instance keeps full function in every state.** That is the whole design: the earlier `retired/` directory move un-offered the app but would ALSO have made the orphan detector see the template as gone for anyone running it — flagging a working install `Elavult` and offering a Törlés button. Withdrawing an app must never take a working app away from a customer. Three design points worth carrying past this feature: - **The deploy gate is server-side and fail-CLOSED**, before any mutation, with the ruled message „Ez az alkalmazás jelenleg nem telepíthető." Hiding a button is not a gate — a stale link, a bookmarked deploy form and a direct POST must all be refused. A second check in `stacks.DeployStack` covers callers that do not route through the API. - **Unknown values fail OPEN** (→ `available` + one WARN) — deliberately the opposite posture. A typo, or a state from a catalog newer than this controller, must never silently pull a working app out of every customer's list. Both paths read the same `EffectiveLifecycle`, so they cannot disagree. - **Lifecycle never reaches orphan detection.** `getCatalogTemplateSlugs` keys on directory + compose presence only. Red-proofed. Badge plumbing is generic (`MetaBadge` + the `meta_badge` partial + a `lifecycleBadge` funcmap entry) so R-56's difficulty labels need only a sibling funcmap function — no new markup or CSS. ## 2. Live validation — endpoint-level, on 9201 The catalog change arrived through the **normal git-sync**, not a hand-copy: ``` $ grep -m1 '^lifecycle:' /opt/docker/stacks/plant-it/.felhom.yml lifecycle: abandoned ``` | check | result | |---|---| | plant-it on the Alkalmazások page | **0 occurrences** (control app `bookstack`: 10) | | direct `POST /api/stacks/plant-it/deploy` | **HTTP 409** `{"ok":false,"error":"Ez az alkalmazás jelenleg nem telepíthető."}` | | `/apps/plant-it` | 200, carries the notice, **no Telepítés button** | | `/apps/bookstack` (control) | 200, no badge, unaffected | Method: the exact endpoints the UI calls, authenticated as the customer (no browser on DooPlex). ## 3. Red-proofs — all five run | # | pre-fix shape restored | observed failure | |---|---|---| | 1 | remove the API deploy gate | wiring test: *"deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT"* | | 2 | drop `st.Deployed \|\|` from the catalog filter | `"plant-it-run" must be listed`, `"oldapp-run" must be listed` — a customer's running app vanishes from their own page | | 3 | remove the badge line from stacks.html | *"a deployed abandoned app must carry the Nem karbantartott badge"* | | 4 | make orphan detection lifecycle-aware | `catalog set = map[bookstack:true]` — both withdrawn apps read as orphans | | 5 | restore the pointer receivers (§4) | `can't evaluate field IsAbandoned in type interface {}` on every lifecycle value | 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`, since a gate that fires after the mutation is not fail-closed. ## 4. A defect I shipped in v0.158.0 and caught within the hour **`/apps/` returned HTTP 500 for EVERY app**, not only withdrawn ones. `EffectiveLifecycle` / `CanInstall` / `IsAbandoned` were declared with **pointer receivers**. `appDetailHandler` passes `data["Meta"] = found.Meta` — a `stacks.Metadata` VALUE inside a `map[string]interface{}` — and html/template cannot call a pointer-receiver method on a non-addressable value: ``` executing "app_info" at <.Meta.IsAbandoned>: can't evaluate field IsAbandoned in type interface {} ``` Fixed in v0.158.1 with value receivers and the reason recorded at the declaration so it is not "tidied" back. **Why every gate missed it, which is the transferable part.** It compiles. `go vet` is silent. All of v0.158.0's tests passed — because none of them rendered `app_info`. The catalog-page tests exercised the *funcmap* route (`lifecycleBadge .Meta`), which takes a value and works with either receiver. **A template method call is only ever checked when that template actually runs**, so a render test is not optional coverage for template-facing methods — it is the only coverage. `TestAppInfoRendersForEveryLifecycle` now renders the real template through the production tree with the handler's exact data shape — `"Meta"` as a value in a `map[string]interface{}`, deliberately not a pointer, because a pointer would hide the bug the test exists to catch. I found this while live-validating, not from the suite. That is the honest sequence. ## 5. Part 3 — R-55 live leg, PROVEN Operator-present, 2026-07-21. Fixture on 9201 (guest clock = UTC, host = CEST): | time (UTC) | event | |---|---| | 14:28:52 | **immich STOPPED through the real UI endpoint** `POST /api/stacks/immich/stop` → `compose down` | | — | pre-state verified: immich **0 containers**, calibre-web 1 running (drive-backed), bookstack 2 running (not drive-backed) | | 14:29:14 | `pct reboot 9201` | | 14:29:28 | `[gate] boot 1784525102-11906045: waiting (≤2m0s) for live drive bind(s) [/mnt/felhom-drives/hdd_1]` | | 14:29:28 | `[gate] … live bind confirmed — recreating drive-backed app calibre-web (state=stopped) onto /mnt/felhom-drives/hdd_1` | | 14:29:29 | `[gate] … 1 drive-backed app(s) left stopped — zero containers means the customer stopped them on purpose` | | 14:29:32 | `[bootrecon] Boot reconciliation: no boot-orphaned apps (nothing to start)` | End state: **immich 0 containers / stopped**, calibre-web 1 running, bookstack 2 running. **Zero alerts** (the only `app_start_failed|deadapp` grep hit was the scheduler registering its job). Reboot → steady state in **~15 seconds**. **immich is absent from the recreate list.** Hours earlier, the identical fixture brought it back RUNNING — that is the defect R-55 fixed, and this is the same experiment with the opposite result. The `left stopped` INFO line fired in production for the first time, so the honoured path is observable rather than silent. ## 6. Gates `go build ./... && go vet ./... && go test ./...` — **green** (both versions). All five template gates pass (`template_id`, `emoji`, `mojibake`, `app_row_dedup`, `native_confirm`). ## 7. Observations 1. **I pushed a commit that does not build.** A `git stash`/`stash pop` used to compare `reuse_refs_check.py` output silently discarded the staged index, so the first lifecycle commit landed only the new test files. Fixed by a follow-up commit rather than a force-push (rewriting pushed history is worse than one bad commit in the log). **Lesson: never `git stash` mid-staging.** 2. `REUSE.md` cross-repo citation drift is pre-existing and improved: 8 broken paths → **5**. I fixed the three that were plain prefix errors; the rest cite abbreviated paths the checker resolves against the repo root. 3. The `hidden` state currently has no user in the catalog. It is implemented and tested, but only `abandoned` is exercised end to end.