Files
felhom-controller/REPORT.md
T

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.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/<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/stopcompose 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.