7.6 KiB
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.DeployStackcovers 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 sameEffectiveLifecycle, so they cannot disagree. - Lifecycle never reaches orphan detection.
getCatalogTemplateSlugskeys 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/<slug> 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
- I pushed a commit that does not build. A
git stash/stash popused to comparereuse_refs_check.pyoutput 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: nevergit stashmid-staging. REUSE.mdcross-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.- The
hiddenstate currently has no user in the catalog. It is implemented and tested, but onlyabandonedis exercised end to end.