` branch.
- **Placeholder reached only the canonical row.** The `/static/app-placeholder.svg` default landed in
`app_list_row` only; four more sanctioned app-logo `onerror` chains still dead-ended in
hidden/none for logo-less apps (observed: Docmost with no icon on Biztonsági mentés →
Alkalmazások). Every app-logo surface now follows one grammar — **SVG → PNG → placeholder** (infra
rows → infra icon): `backups_apps.html` (the allowlisted aligned row), `stacks.html` (the
`data-fallback` is now always present: infra → `infra-logo.svg`, else `app-placeholder.svg`),
`app_info.html` (hero logo only — **screenshots deliberately still vanish on error**),
`deploy.html` (keeps its `.LogoURL`/`.LogoPNGURL` data source). No handler/funcmap changes.
### v0.163.0 — Indítópult (app launcher page) + universal app placeholder icon (2026-07-24)
No agent coupling; MinAgent unchanged. Adds a customer-facing **Indítópult** launcher grid and a
generic fallback icon for logo-less apps on every list surface.
**Indítópult (`/launcher`, new FIRST sidebar item, above Vezérlőpult):**
- A grid of large tappable tiles, one per openable deployed app. The rule is intentionally the same
one the „Megnyitás" button already uses: a tile exists **⟺** the stack has a subdomain (env
`SUBDOMAIN` > `.felhom.yml` subdomain > `protectedStackSubdomains`). The controller's own stack is
excluded by name. `/` still lands on the Vezérlőpult — the launcher is an ADDITIONAL page.
- Tiles are colored rounded squares: a deterministic per-app color (FNV-1a of the slug → HSL hue,
fixed S/L tuned for the dark theme), overridable with an optional `.felhom.yml` `brand_color`
(`#rgb`/`#rrggbb`; an invalid value silently falls back to the slug color). The existing white
monochrome logo renders on top; a logo-less app reveals the **monogram** initial underneath
(multibyte-safe — „Óra" → „Ó").
- Operational apps are a real `
` to the public URL (with
`open_path`); stopped/exited/degraded apps render a **greyed, unclickable** tile with the honest
Hungarian state badge — never a dead link. Empty state: „Még nincs telepített alkalmazás." + a link
to `/stacks`.
- New template funcs `tileColor` (returns a `template.CSS` — validated/computed in Go, because the
html/template CSS filter mangles a legitimate `hsl()` from a func pipeline) and `initial`.
**Universal app placeholder icon:**
- New embedded `AppPlaceholderSVG` (2×2 rounded-square app-grid glyph), served at
`/static/app-placeholder.svg`. The canonical `app_list_row` now DEFAULTS its fallback to it, so a
catalog app with a missing logo shows a generic placeholder on every list surface instead of the
old `visibility:hidden` dead-end. Infra rows still override with `/static/infra-logo.svg`.
- Design ruling: the felhom brand mark is NEVER an app placeholder (brand = platform identity only).
**Refactor (in-scope, single reason):** the subdomain-map assembly that lived inline twice
(dashboard + Alkalmazások) is extracted to `Server.subdomainMap`; both call sites plus the launcher
now share it (byte-for-byte priority unchanged).
**Metadata:** `stacks.Metadata` gains `BrandColor` (`brand_color`, omitempty). No catalog app sets it
yet (curation is a parked follow-up).
### v0.162.0 — R-71(a): the apply-bridge waits for the dust to settle (settle-gate) (2026-07-24)
No agent coupling; MinAgent unchanged. Origin:
`felhom.eu/documentation/audits/DIAG-f10-demo-hp-offsite-2026-07-23.md` — the day-0 race. A fresh box
boots below the operator floor (ISO 0.153.0 < floor 0.156.0), the apply-bridge consumes the
single-use offsite password, then ~35 s later the managed auto-floor update replaces the container
mid-install → the new process finds no installed key → consume → **404** → offsite dead until an
operator Re-issue. This recurs on **every** fresh onboarding whose ISO floor lags the managed floor;
demo-felhom escaped by timing alone. The v1.25.0 golden≥floor build gate PREVENTS the trigger for
fresh installs; R-71c (hub) HEALS a burn after the fact; this (a) removes the SYSTEMATIC trigger for
every restart shape.
**The change (ordering only — the bridge's consume/install/persist internals, the 404-no-oracle
contract, and the Consumer are UNTOUCHED; R-71(b) stays rejected-by-design):**
- New seam `offsiteapply.SettleProvider.SettleState() (version, floor string, updateRunning,
floorKnown bool)` — a thin adapter (`SettleFunc`) over the self-updater's OWN knowledge in main.go
(`GetFloor()`/`IsUpdateRunning()`); the bridge never fetches the floor a second way.
- `Bridge.AwaitSettle` polls every 10 s (bounds: 90 s floor-knowledge sub-bound, 5 min overall)
BEFORE the 3-minute Reconcile context is created (the deferral never eats the reconcile budget).
Releases: `updateRunning` → wait (the swap supersedes us); `floorKnown && version:/srv/:rslave` — `:rslave` is load-bearing (host-side
automount wake / idle-unmount events propagate into the running container);
- NO `EnsureUserdataSkeleton`, no userdata scoping — nothing is ever written toward the NAS;
- the drive-absent gate does NOT apply (an idle automount is healthy and would be skipped
forever); the gate is the `stub` classifier verdict instead — **the data-safety wrong case**:
exposing a local stub dir lets a customer upload files the real mount will later shadow, so a
stub share is excluded from mounts AND sources this pass with a WARN. autofs / network /
unknown / nil-classifier all include (fail open).
- Drive behavior is byte-identical (tested: the drive line with a share present equals the
drives-only render; drives always stay in the source list as before).
- NAS add-success (`runNetAdd` done) and remove (`handleNetStorageRemove`) now trigger
`SyncFileBrowserMounts()`; removal drops the source + mount on the next sync (F2 change
detection forces the recreate).
Tests: `filebrowser_network_test.go` scenarios A–D. Red-proofs recorded in REPORT.md: A (network
routed through the drive branch → the skeleton-call assertion fails with the NAS path recorded)
and B (stub gate dropped → the stub share leaks into mounts + sources).
### v0.159.0 — R-66: the box's own address becomes visible (2026-07-22)
No agent coupling; MinAgent unchanged. Controller-only, three XS legs with one theme: **the box
must be able to tell you where it is.** Origin: the Felhom↔Felhom NAS pairing drill — the serving
box's IP was findable only as a hint line buried on the OTHER box's Megosztás page, and the add
form's failure for a NetBIOS name („FELHOM") taught nothing.
**Leg A — „Hálózat" card** on Beállítások → Rendszer (between „Verzió és frissítés" and „Szerver
memória"): Helyi cím (LAN), Hálózati név (`\\`, rendered ONLY while Megosztás is
enabled — the NetBIOS name exists only while samba runs), Átjáró, and a muted footer asking the
customer to read the page aloud during remote troubleshooting. Everything is live-computed per
render and stored nowhere (S-5); an unavailable value renders „—" („nem állapítható meg").
**Leg B — `network` section in the Debug system dump** (`GET /api/debug/dump`): guest interfaces
(veth*/docker*/br-* plumbing skipped), default route + gateway + source interface, DNS servers
from the guest's resolv.conf, and the SAME `lan_address` value Leg A shows so a support session
can cross-check the two. Best-effort per item — a failed read yields that item's error string in
place, never aborts the dump.
**Leg C — the NetBIOS trap gets named**: helper text under the NAS add form's Szerver field, plus
one hint line appended to an `unreachable`-class add failure when the submitted server is a
single-label non-IP name („Tipp: a(z) »FELHOM« Windows-hálózati névnek tűnik…"). The detection is
purely lexical (`looksLikeFlatNetworkName`: non-empty, no dot, not `net.ParseIP`-able) — no
NetBIOS/mDNS resolution is attempted anywhere, and the agent's probe/taxonomy is untouched.
**The one design decision worth recording:** the spec sketched the gateway as a `/proc/net/route`
read, but the controller runs on a docker BRIDGE — every in-process answer (own routes, own
resolv.conf = 127.0.0.11, `net.Interfaces` = 172.x) is the S-2 wrong-kind-of-true trap that
already burned the setup wizard. All guest-net reads therefore go through the ONE guest-netns door
this process has: a docker-exec into the host-networked felhom-samba container
(`internal/stacks/guestnet.go`, single `guestNetExecFn` seam). Accepted consequence, by S-5's own
logic: with Megosztás off the door is closed and the card shows „—" rather than a plausible wrong
172.x answer.
Tests: `guestnet_test.go` (pure parsers pinned: default route, interface merge, resolv.conf;
fail-quiet contracts; B1 best-effort with a scripted per-argv exec fake) + `network_card_test.go`
(A1 all rows, A2 name-row absent when sharing off, A3 „—" fallback, per-render freshness counter,
B1 dump shape with in-place error, C1/C2/C3 hint lexicon). Red-proofs run and recorded in
REPORT.md: A2 (enabled-gate dropped → `\\FELHOM` rendered while sharing is off → FAIL) and C2
(lexical check inverted → the hint nags an IP user → FAIL).
### v0.158.1 — fix: the lifecycle methods broke every app detail page (2026-07-21)
**Defect shipped in v0.158.0 and caught live within the hour. `/apps/` returned HTTP 500 for
EVERY app**, not just withdrawn ones.
`EffectiveLifecycle` / `CanInstall` / `IsAbandoned` were declared with POINTER receivers.
`appDetailHandler` puts `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. So `{{if .Meta.IsAbandoned}}` failed at RENDER time:
```
executing "app_info" at <.Meta.IsAbandoned>: can't evaluate field IsAbandoned in type interface {}
```
Switched to value receivers, with the reason recorded at the declaration so it is not "tidied" back.
**Why the tests missed it, which is the more useful lesson:** it compiles, `go vet` is silent, and
every v0.158.0 test passed — because none of them rendered `app_info`. The catalog-page tests
exercised the funcmap route (`lifecycleBadge .Meta`), which takes a value and works either way. A
template method call is only ever checked when the template actually runs.
Added `TestAppInfoRendersForEveryLifecycle`, which renders the real `app_info` 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 the pointer is what hides the bug.
Red-proof: restoring the pointer receiver reproduces the 500 for every lifecycle value including the
empty one.
### 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
`