# CONTEXT.md — Project Memory > This file serves as persistent project memory across Claude Code sessions. > It replaces the auto-generated "Memory" from the claude.ai Project. > **Update this file at the end of each working session** with current state, > recent decisions, and anything the next session needs to know. > > Ask Claude Code: "Please update CONTEXT.md with what we did today" Last updated: 2026-08-02 (v0.190.0 — R-157 A · R-170 · R-171: boot recovery finished) > **2026-08-02 — v0.190.0 (R-157 mechanism A · R-170 · R-171).** Three items, one live validation > cycle, because all three are boot behaviour and all three are proven by hard-resetting the box. > > **DIAGNOSE BEFORE THEORISING — and the first diagnosis was a FALSE NEGATIVE.** A hole was reasoned > out of the v0.189.0 diff (a drive-gate-stopped app has zero containers and `desired_state: running`, > so it now reads as a boot orphan) and confirmed on hardware BEFORE any fix was written. **Attempt 1 > produced `no boot-orphaned apps` and would have been reported as a disproof.** It was a race: > unmounting only the parent bind is healed by the agent within ~60 s, so the drive gate's startup > reconcile restarted the apps **one second before** the sweep looked. Holding the drive genuinely > absent reproduced the defect immediately. **"It didn't happen this time" is not a mechanism.** > > **The confirmation moved the severity in BOTH directions.** The write hazard did not materialise — > compose failed `mkdir …/userdata: permission denied` because the unbound mountpoint is > host-root-owned and the guest is unprivileged. **That protection is ACCIDENTAL**: no code chose it, > no test pinned it, and it is one `chown` or one privileged guest away from gone. But the harm that > DID occur was not in the hypothesis and is real on every box: two wasted attempts and a **false > dead-app alarm for an app the drive gate is deliberately holding**. > > **The fix already existed one path over.** `startGatedByMissingDrive` (the API) refuses a customer's > start on an absent drive; the sweep bypassed it by calling `Manager.StartStack` directly. > **`StartStack` HAS NO GATE OF ITS OWN** — carry this: every caller that is not the customer must > decide for itself whether the app may run. New consumer-side `bootrecon.StartGate`, fail-safe > (cannot determine ⇒ do not start). > > **Widening a window makes previously-unreachable overlaps reachable — a design input, not an > afterthought.** The old T+5 s sweep never met a quiesce or an in-flight app-data operation; a 50 s > window can. All three holders answer ONE seam because they differ only in the reason string. > > **A TEST REJECTED MY FIRST CONSTANT, and the comment says so.** `settle + budget + one retry` must > fit inside `deadAppBootGrace`; 60 s gave 95 s against 90 s. The budget is 50 s **because a test said > so** — recorded in the code rather than presented as taste. Widening the grace was rejected: it > hides a late recovery instead of reporting one (`recordLateRecovery`). > > **THE FIX HAD ITS OWN DEFECT, FOUND LIVE AND NOT BY REVIEW.** The window sampled `GetStacks()` — the > Manager's map, refreshed by the scheduler every **10 s** — every 5 s, so two identical samples could > mean *the cache did not update*. Observed: a container removed ~5 s before the window closed was > still in the sampled fleet and the sweep logged `no boot-orphaned apps` for an app that had none. > `sampleBootFleet` now refreshes first. **Generalise: a settle detector is only as good as the > freshness of what it samples — if the source is cached, refresh it, or you are watching the cache > settle rather than the system.** > > **R-170:** `shouldRecreateOnBoot` reads intent with the identical three-way table; absent keeps the > old `hasContainers` behaviour exactly; `presentStable` untouched and still load-bearing. Its comment > argued at length FOR the count and was rewritten. Agreement pinned from BOTH sides against one > fixture table (an import cycle prevents testing the two gates together). > > **Live: 6/6 hard resets** (every app back; the customer-stopped app down all six), settle times > 10/40/10/10/15/15 s. Sharpest evidence: same app, same box — missed at 18:08:35, recovered at > 18:18:50. R-170 proven in one reboot (calibre-web recreated, immich left stopped). 27/27 packages; > 7 red-proofs. Detail: `REPORT.md`. Last updated: 2026-08-02 (v0.189.0 — R-166 / D-b: the box stops guessing what the customer wanted) > **2026-08-02 — v0.189.0 (R-166, operator decision D-b).** When an app was not running the box had > to work out *why*, and it did so **by counting containers**: zero meant "the customer stopped it", > some meant "something broke". A **power cut mid-compose** and an **interrupted deploy** also leave > zero containers, so both were read as deliberate stops and stranded **silently** (R-157 mechanism > B) — and a backup that stopped an app and died left it stopped with **nothing on disk** recording > that it was owed a restart. The settling fact — what the customer asked for — **was written down > nowhere**: `app.yaml` recorded *installed*, never *meant to be running*. > > **DECISION — one owner: the customer's action, and nothing else.** A census found **14 callers of > `StartStack`/`StopStack`, of which exactly 2 are the customer**; the rest are quiesce, the volume > dump, offbox reconstitution, app export/restore, the storage gate, migration and the boot > reconciler. So the primitives are deliberately **not** writers — intent there would make a nightly > backup indistinguishable from the customer pressing Stop. Writers: the API action switch, > `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, the `.fab` import. Intent is written > **BEFORE** the act and a failed write **REFUSES** the act. > > **DECISION — absent means UNKNOWN, never "running", and this is the whole safety property.** Every > `app.yaml` on every box predates the field, so absent is what the fleet reads on upgrade; reading > it as running would start every deliberately-stopped app on the first boot after the upgrade. The > legacy branch of `isBootOrphan` keeps the old container-count rule **byte-for-byte**, and its test > asserts BOTH legacy rows together because the safety property is the pair. Backfill is > **running-only** — "zero containers ⇒ stopped" IS the defect, so an ambiguous app stays ambiguous. > > **Part 2 — `backup.AppStopGuard`**, a persisted marker over every stop→work→start window (volume > dump, offbox reconstitute, `.fab` export), in its **own** file (one file, one writer). Written > before the stop, cleared only after a restart that **succeeded**, kept when one fails. `Recover` > **returns** its outcome instead of using a notifier seam, because it must complete before the boot > reconciler (`main.go` ~236) while the notifier is not built until ~307 — a seam wired after the > fact is a seam that never fires. > > **THE TEST LESSON, and it is the one worth carrying:** Scenario E's first version called > `appStop.Begin` itself, and **survived the red-proof that deleted the production call**. It proved > the marker type, not that `DumpAppVolumesSafe` uses it. Rewritten to drive the real function with a > simulated hard abort (an unwind that skips the restart statement, since a `defer` is not > crash-safety — Campaign 8 fault 10). **A test that constructs the thing it is meant to prove the > caller constructs is hollow, and its red-proof will say so if you run it.** > > **FOUND EN ROUTE — `SaveAppConfig` rebuilt `AppConfig` field-by-field**, the R-100 shape (v0.181.0 > shipped two live instances). The literal named five fields, so `desired_state` would have been > dropped on **every** save across nine call sites — a customer's Stop erased by the next unrelated > `app.yaml` write. Copy-and-overlay (`saveCfg := *cfg`) is safe by construction. **Generalise it: > treat any field-by-field struct rebuild in a save path as a defect on sight.** Measured, not > assumed: `app.yaml` does NOT round-trip YAML keys the struct does not model (pinned by test). > > **R-157: mechanism B closed, mechanism A untouched** (the sweep observes ~5 s after start and never > re-checks) — and B's fix makes A cost more, since the sweep now has more it could recover. > **NEW R-170:** `shouldRecreateOnBoot` (`internal/web/intermediary.go:131`) still infers a Stop from > `hasContainers` — the same defect one gate over, for drive-backed apps. Left deliberately. > > **Live on 9201, three flows** (stop survives a restart; a zero-container `running` app recovered by > name; a legacy app.yaml skipped and never inferred stopped). The **interrupted-operation half is > IMPLEMENTED, not PROVEN-LIVE** — nobody killed the controller mid-backup on metal. 27/27 packages; > 7 red-proofs observed FAIL then restored. Detail: `REPORT.md`. Last updated: 2026-07-28 (v0.182.0 — R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy) > **2026-07-28 — v0.182.0 (R-101 + F-DIAG).** `Tier2LastRun` is the ATTEMPT clock (written on failure) > and was rendered as „Legutóbbi másolat" in the **restore confirm dialog** — misinformation at a > decision point: the restore fills in MISSING files, so a customer with a failing Tier-2 restored and > silently got OLDER files. New `CrossDriveBackup.LastSuccess` + **`SuccessTracked`**; the marker is > load-bearing because **all 7 fleet rows were pre-anchor at deploy** — without it every customer sees > „Még nincs sikeres másolat" at once. Legacy rows migrate on first touch (`ok` adopts its time, > `error` seeds nothing). **PART 2 — the three `record*` helpers rebuilt the WHOLE struct with only 2 > fields carried over; the naive fix would have had `recordTier2Failure` CLEAR the anchor.** Replaced > by `tier2Update` (copy-and-overlay = safe by construction). New `fmtTimeStr` → Budapest-local dates > in the dialog instead of raw UTC RFC3339. **F-DIAG:** 6 classes incl. an honest `unknown`, and the > notification no longer passes `err.Error()` through raw — **LESSON: my first sanitiser was regex-only > and leaked a bare hostname; its own test caught it. Redact KNOWN values, don't guess at shapes.** > Live on demo-hp: rendered dialog read in the failed, healthy AND legacy states. F-OPS documented at > `felhom.eu/documentation/runbooks/RUNBOOK-manual-guest-restore.md`. > **2026-07-28 — v0.181.0 (R-100).** `OffboxTarget.LastSuccess` + wire field `last_success`; the hub > (v0.80.0) anchors offsite staleness on it. **`LastRun` is written unconditionally on every run > INCLUDING failures** — it records an ATTEMPT — so the hub's "how long since LastRun" verdict read a > nightly-failing tier as perfectly fresh forever. The rule is the pure `offboxAnchorAfterRun(prev, at, > runErr)`: a failure neither ADVANCES nor CLEARS the anchor (both are distinct bugs; clearing it would > make one bad night look like never-succeeded). `LastStatus == "error" ⇒ stale` was rejected — it pages > on every blip, the F-A1 noise mode. **TWO SILENT-WIPE SITES CLOSED** (`offboxConfigHandler` and > `ApplyOffsiteTarget` both rebuild the target and copy runtime status field-by-field — omitting > LastSuccess would erase the anchor on any settings save or hub re-apply). **LESSON: my first test > modelled the rule in a local closure and stayed GREEN when production was mutated — hollow; the > extraction to a pure function is what made the red-proof bite.** Live on demo-hp: failing run advanced > `last_run` to 11:25:48Z while `last_success` HELD at 11:24:20Z; demo-felhom healthy → advanced. The > settings-save preservation was proven live too. Detail: `REPORT.md` + `felhom.eu/REPORT-r100.md`. > **2026-07-28 — v0.180.0 (F-OBS).** Source: `audits/CAMPAIGN-8-backup-restore-2026-07-27.md`. > On a default `logging.level: info` box there was **no positive observable that `deadapp-check` had > run**: its per-cycle line goes through `Scheduler.dbg()`, gated on `level==debug`, so on a default > box it was never *produced* and could not even reach the always-DEBUG ring. "No alarms" was > therefore indistinguishable from "the detector never ran" — standing rule 3's exact fallacy, and it > undermines F-CRIT-1's fix, which is a fix to **this same detector**. > `noteDeadAppScan()` now emits an INFO line every **20th** scan (10 min at the 30 s cadence) carrying > scans-since-boot / evaluated / currently-down. It reports **what it saw**, not that it ran, and it > summarises rather than floods — one line per run is 2880/day, which is what made silence attractive > in the first place. Both bounds are pinned by test in the direction that would break them. > **The same shape then turned up in the agent's brand-new guest-power watchdog** (v0.107.0, shipped > hours earlier): it logged only at startup and when it acted. Fixed in agent v0.109.0 with the same > pattern. The anti-pattern reproduces itself — which is the argument for not having dropped this part. > Live on demo-hp at INFO on a default-level box; deployed on both boxes. Detail: `REPORT.md`. > **2026-07-26 — v0.173.0 (R-77).** Source: `audits/DIAG-agent-channel-2026-07-26.md`. > > **UNRESOLVED AND DELIBERATELY DEFERRED — which file is authoritative for `local_api`?** R-77 ships > DETECTION ONLY. `controller.yaml` and `bootstrap.json` can disagree; the controller dials > `controller.yaml`. The obvious "fix" — reconcile from `bootstrap.json` on every boot — has a failure > mode **as severe as the bug it fixes**: on a guest whose `controller.yaml` is correct and whose > `bootstrap.json` is stale (a re-provision that half-completed, a hand-repaired guest, a > setup-wizard box), auto-reconcile would clobber a WORKING channel on the next restart — fleet-wide, > silently, at the moment of a routine deploy. R-77's position is that **naming the drift is enough**: > it would have converted the 17.5 h outage into a specific alert on the first health cycle. The > authority ruling is **R-78** and needs its own spike — do not resolve it opportunistically. > > Corollary for anyone editing `bootstrap.MaybeIngest`/`ensureLocalAPI`: `ensureLocalAPI` is the ONLY > writer, it fires only when the endpoint is EMPTY, and `DetectEndpointDrift` must stay write-free. > Scenario A's test asserts `controller.yaml` is byte-identical after the check, and its red-proof > covers the auto-correcting variant precisely because that is the tempting wrong turn. > > **Also settled here:** the samba protected-set must mirror EVERY early return in > `reconcileSambaAt` (currently two: `!smb.Enabled`, `!smb.UserSet`). A third would need the same > mirror, and the doc comment above `EffectiveProtected` must be updated with it. > **2026-07-26 — v0.172.0 (R-75).** Spike `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`; > feature doc `felhom.eu/documentation/controller/import-and-data-paths.md`. > > **RULING — the import root is CANONICAL on the system drive, overriding the spike's Fork-1 > recommendation of per-drive roots.** The spike weighed sidebar clutter and per-app link ambiguity and > concluded per-drive; the operator overruled it on an argument the spike missed: each drop-zone app has > exactly ONE ingest bind, so on a two-drive box every import folder except the app's own would look like > a drop-zone and silently do nothing — and because `import/*` is `class: excluded`, files stranded there > are never backed up either. A canonical root is the only shape with no dead drop-zone. Recorded as a > deliberate deviation, not an oversight. > > **Phase-0 probe changed the shape of Part 6.** The system drive is NOT a registered `StoragePath` on > either demo box (`/mnt/felhom-drives/hdd_1` on demo-felhom; `nvme-1tb` + `Felhom-Share` on demo-hp), > so `sharingResolvePath` REFUSES `/userdata/import` — verified against the real guard with a > passing control. Registering the drive was rejected (it would make the 50 GB volume holding the > recovery units a customer-visible drive, deploy target and wipe candidate, and `SharingDeniedRoots` > would then deny the namespace-consistent shape anyway). **Chosen: leave it unregistered and have the > controller write the `beolvasas` share directly** — the picker guard validates CUSTOMER-supplied paths, > a controller-generated constant is a different trust class. No guard was weakened. > > Also note: `withUserdataPath` computes `USERDATA_PATH` as `/userdata`, NOT > `NamespaceRoot(hdd)/userdata`. For an app on the system drive those disagree > (`/mnt/sys_drive/userdata` vs the `felhom-data` namespace). Latent — no app with a userdata bind has > ever been deployed there — but it is a real inconsistency, left untouched here. > > The other three forks followed the spike unchanged: all-apps skeleton / deployed-only in the UI; > unknown role fails OPEN while a malformed path whole-block rejects; drop-zone copy driven by the > derived backup class. > **2026-07-24 — v0.169.0 (disk-health card + degradation alert).** Consumes the agent's new `smart` > field (agent v0.94.0; MinAgent floor unchanged — feature-detect by presence). **Rulings:** (1) ONE > pure verdict fn `agentapi.DiskVerdictFor` is the shared truth for the card chip AND the 6h check — they > can never disagree. Thresholds: FAILING→Hiba; PASSED + any(reallocated>0/pending>0/offline_unc>0/ > critical_warning>0/media_errors>0/percentage_used **≥90**)→Figyelmeztetés; PASSED clean→Rendben; > nil/UNKNOWN→Nincs adat (never alarms). (2) **No global alert banner** — the card + email carry disk > health; banner fatigue is a real cost, so this is deliberately NOT wired into the dead-app/alert-banner > machinery. (3) Degradation-only notification with an in-memory baseline: first run baselines silently, > recovery never notifies, **UNKNOWN excluded both directions** (a transient blip neither fires nor erases > history). (4) **Controller restart re-baselines silently** (in-memory baseline lost on restart) — an > accepted trade consistent with the health-change pattern (a real post-restart degradation still fires on > the following 6h check once a baseline exists). (5) A **60s TTL cache** wraps the card's /disks call so > dashboard refresh-spam can't smartctl-storm the host; the 6h check fetches FRESH (cache-independent). > Pairs with hub +1 (allowlist `disk_health_degraded`). No new smartctl load — serialization only. Last updated: 2026-07-24 (v0.168.0 — customer-configurable backup window "Mentési időablak") > **2026-07-24 — v0.168.0 (customer-configurable backup window).** ONE customer setting — the window > start W ("Mentési időablak kezdete") — drives every nightly leg at FIXED, never-stored offsets so > misordering is impossible: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). **Design > rulings:** offsets are DERIVED and computed everywhere, never persisted and never exposed in the UI; > precedence is settings > controller.yaml `db_dump_schedule` > "02:30" (mirrors PasswordHash); a change > applies WITHOUT restart via the new scheduler seam `UpdateDaily` (per-daily-job buffered `resched` > chan + a select case in `runDailyJob`). New pure package `internal/backupwindow` holds all the time > math (ParseHHMM/FmtHHMM/LegTimes/GateWindow/EffectiveWindow). **Disk-tier (whole-guest PBS/vzdump) > gate:** the quiesce loop's SCHEDULED cycles run only inside [W+2h, W+6h) (wall-clock Europe/Budapest), > with a safety valve — last successful backup older than cadence+24h (or none) runs regardless, so a > box only ever on outside its window never starves. **Manual "Mentés most"/TriggerNow is NEVER gated** > (bypasses runOnce). The `quiesce.Backend.Due` seam now also returns the backup age (from the agent's > own `/backup/due`); the agent, its cadence, and `/backup/due` are untouched. Window read fresh each > poll (WindowStartFn) so runtime changes take effect. Cadence defaults to 24h controller-side (the > response carries no cadence). Backup page gets a "Mentési időablak" card (time input + derived rows + > the "kb. W+2h–W+6h között" rendszermentés line); POST /backups/window (RequireAuth+CsrfProtect). > **2026-07-24 — v0.167.0 (outlined logo + favicon — Part 4 unblocked).** Viktor pushed the > text-outlined `logo.svg` to felhom.eu `main` (`be9edb4`); the wordmark is now 17 real `` > glyphs. `FelhomLogoSVG` swapped to it; Inkscape's leftover **empty `` shells + font-* leftovers > on the paths** were stripped via an lxml DOM pass (glyphs untouched — CC did NOT do text-to-path), > editor `` dropped. `FelhomFaviconSVG` vestigial `` removed. Both constants: > **0 ` Inkscape "Object→Path" leaves empty `` shells AND copies `style="…font-family:…"` onto the > resulting ``s — a search for `svg:text` misses them (elements are ``, no prefix); grep > ` first** (`assetsSyncer.Resolve`) and fall back to the constant only if none is on disk — on 9201 the > constant is what's live (verified). Still open (separate follow-up): website + hub serve their own > non-outlined logo copies; login.html stylesheet link still unversioned. > **2026-07-24 — v0.166.0 (mobile nav = off-canvas drawer; sidebar cleanup; ?v= on logo/favicon).** > Mobile nav was broken: the ≤768px block predated the v0.146.0 accordion and flattened `.nav-links` > into a horizontal `overflow-x` strip, clipping the accordion's nested sub-lists (they share the > `.nav-links` class). **Decision: mobile nav = a sticky top bar + off-canvas left drawer that REUSES > the vertical sidebar (Option A).** The accordion handler is untouched and works inside the drawer; > a `no-js` html-class fallback renders the sidebar static inline so nothing dead-ends without JS. > Options B (separate mobile menu) and C (exclude nested lists from the strip) were rejected. z-index > ladder topbar 800 < backdrop 900 < drawer 950 < modal 1000; `100dvh`; reduced-motion disables the > slide; focus-trap deliberately omitted (navigations reset state). **Sidebar customer-name removed** > (logo only); `{{.CustomerName}}` stays in base data + login subtitle. **Logo policy decision: the > wordmark must be OUTLINED paths, never live ``** — under `` secure static mode only > locally-installed fonts resolve, so `font-family` in the SVG renders a fallback font everywhere. > **Part 4 (swap `FelhomLogoSVG`/`FelhomFaviconSVG` to the outlined master) is GATED OUT** — §3a check > against live felhom.eu `main` (`be9edb44`) found `website/assets/logo.svg` still has ``/ > `font-family`; the outlined master is Viktor's manual Inkscape push, still pending. Only the `?v=` > cache-bust (logo/favicon/login-logo, Cloudflare 4h edge-cache — the 0.126.1 failure mode) shipped > from the logo work. Follow-up: when Viktor pushes the outlined asset, ship Part 4 (swap constants + > clean the favicon's vestigial `` nodes). Separately, the website + hub still serve their own > non-outlined logo copies — propagation is a distinct follow-up. > **2026-07-24 — v0.165.1 (native "Megosztás…" in the share modal, Web Share API).** The share modal > gains a feature-detected `navigator.share` button (OS share sheet → Messenger/WhatsApp/email), > sending **title + text + URL only**. Hidden unless supported; "Link másolása" stays the universal > fallback (and catches the non-cancel rejection); `AbortError` (user cancel) is silent. **Ruling: the > QR is NOT attached** (no Web Share Level-2 `files:`) — file-share support is narrow and several > targets drop the URL when handed file+URL, leaving an unscannable QR picture in a chat; the QR's job > (physical cross-device scanning) is already served by the modal image (mobile long-press). Template > JS + tests only; the OS sheet interaction is an operator manual check (not endpoint-testable). > **2026-07-24 — v0.165.0 (Indítópult megosztása — guest launcher via capability URL).** The admin > launcher gets an "Indítópult megosztása" button that mints a **capability URL** > (`https:///s/`, 160-bit `crypto/rand` token) serving a standalone, read-only guest > launcher — same tiles, opens apps in new tabs — with **no account and no admin session**. **Security > ruling: the link grants INFORMATION ONLY, ZERO CONTROL** — app names + public URLs; every privilege > stays behind each app's own auth and the controller admin password. The token IS the secret (160-bit > entropy is the whole defence for the GET — never rate-limited, never logged, `subtle.ConstantTimeCompare` > only; an empty stored token = sharing OFF, matches nothing, so a wrong/disabled token is byte-identical > to the mux default 404). Optional per-share password is a SEPARATE credential (own bcrypt hash, own > attempt map — NEVER the admin ones); one pass mints a cookie = HMAC(`token|passwordHash`) keyed with > the persisted `web.session_secret`, so rotate-token OR change-password invalidates all cookies for free. > **Part-2 secret decision: REUSED `web.session_secret`** (persisted + box-scoped + stable — the SAME > secret the claim pre-auth CSRF already trusts; not per-boot, not claim-generation-scoped → the reuse > branch), so no `ShareCookieSecret` field was added. **Design rulings recorded:** member accounts are > **superseded** by this capability-URL model; **per-member tile visibility is PARKED under the SSO arc.** > Guest state labels ride the v0.164.0 invariants: `StateStopped` ⇒ "A tulajdonos leállította"; any > other non-clickable state ⇒ "Átmenetileg nem elérhető" (guests never see stopped/exited/degraded/ > unhealthy). Accepted residuals (documented, no code action): link-preview crawlers fetch once and see > app names (noindex prevents indexing); reverse-proxy/CF access logs may hold the path (ops-tier); the > modal link carries the request Host, so a LAN-IP admin session yields a LAN-IP link. New dep: > `github.com/skip2/go-qrcode`. Tests: Groups A–G (14 tests) + 3 red-proofs verified red. > **2026-07-24 — v0.164.0 (stopped ≠ fault).** Operator finding on 9201: a UI stop (Leállítás) raised > the global "Telepített alkalmazás nem fut: … (stopped)" banner on every page AND fired the > `app_start_failed` email. RULING: **a deliberate user action must not alarm anywhere.** One-line > filter at the single fix-3 derivation point — `scanDeployedAppRunStates`'s pure core extracted to > `classifyRunStates([]stacks.Stack)`, down predicate now > `stacks.IsDownState(st.State) && st.State != stacks.StateStopped`. `StateStopped` is dropped from > BOTH the banner dead-list and the notifier Down-set (⇒ no banner, no event, clean tracker). Rests on > **two invariants that MUST both hold for this suppression to be correct:** **I1** — the UI stop path > `Manager.StopStack` runs `docker compose down` → containers removed → a deployed stack with zero > containers aggregates to `StateStopped` (refreshStatusLocked). **I2** — the P2 restart-policy census > (2026-07-21, 53 templates / 78 services) found every catalog service on `unless-stopped`, so a crash > never rests at `stopped` — faults surface as `exited`/`degraded`/`restarting`/`unhealthy`. **If > either invariant changes, revisit this suppression.** `IsDownState` UNCHANGED (other callers rely on > stopped=down). Out-of-band `docker compose stop` (containers remain → `StateExited`) still alerts — > correct, tampering is reportable. The `stopped_by_user` intent flag was considered and PARKED (only > adds value against out-of-band stops, which should keep alerting). Tests +4 (notify 3→4, main 4→7), > both red-proofs verified. No template/funcmap/notifier/counter/copy change. > **2026-07-24 — v0.163.1 (launcher polish).** Two v0.163.0 live findings fixed. RULE recorded: > **every app-logo surface ends in a visible placeholder** (`SVG → PNG → /static/app-placeholder.svg`, > infra rows → `infra-logo.svg`) — the four sibling `onerror` chains (`backups_apps`, `stacks`, > `app_info` hero, `deploy`) now match `app_row.html`; `app_info` screenshots deliberately still > vanish on error. And the **launcher monogram is launcher-only AND failure-only**: hidden by default, > revealed when the tile's img chain fails (`onerror` adds `.launch-tile--noimg`) — it was bleeding > through every transparent white glyph. Template/CSS only; no handler/funcmap change. 5 tests + 2 > red-proofs. [[launcher-v0163-2026-07-24]] > **2026-07-24 — v0.163.0 (Indítópult app launcher + universal placeholder icon).** New > customer-facing `/launcher` page: the FIRST sidebar item (above Vezérlőpult), a grid of large > tappable tiles for openable deployed apps. `/` stays the Vezérlőpult — the launcher is ADDITIVE. > Design rulings recorded here: > - **(a) The felhom brand mark is NEVER an app placeholder** — brand = platform identity only. The > logo-less fallback everywhere is the new generic `AppPlaceholderSVG` (a 2×2 app-grid glyph, > `/static/app-placeholder.svg`), now the DEFAULT `FallbackIcon` on `app_list_row` (was > `visibility:hidden`). On the launcher tile the fallback is the **monogram**, not the placeholder. > - **(b) A launcher tile exists ⟺ a „Megnyitás" button would** — subdomain presence (env `SUBDOMAIN` > > `.felhom.yml` subdomain > `protectedStackSubdomains`) is the single openability criterion. The > controller stack is excluded by name. The subdomain assembly was extracted to > `Server.subdomainMap` (3 callers: dashboard, Alkalmazások, launcher; priority byte-unchanged). > - **(c) Colored-tile + mono-glyph design.** `tileColor` = validated `.felhom.yml` `brand_color` > (`#rgb`/`#rrggbb`, new `Metadata.BrandColor`, omitempty) OR a deterministic FNV-1a-of-slug HSL > (fixed S/L, hue per app). Invalid `brand_color` silently falls back to the hash color (the one > §8 exception to no-silent-failure — cosmetic). `tileColor` returns `template.CSS` (we > validate/compute in Go; html/template's CSS filter mangles a legit `hsl()` from a func pipeline). > - **(d) `/` remains the Vezérlőpult.** No role/auth gating — member-role gating is a future arc > (ROADMAP: member role → launcher becomes the member landing page). No catalog app sets > `brand_color` yet (curation parked). > No agent coupling; MinAgent unchanged. 10 new test functions + 4 red-proofs (all observed FAIL then > restored). Gates green (app_row_dedup / template_id / emoji). > **2026-07-24 — v0.162.0 (R-71a), SHIPPED + deployed BOTH boxes (demo-felhom 9201 + demo-hp 9201 > via G1 break-glass), clean+healthy, settle-gate GO line captured on both.** B′ live note: both > above-floor boxes GOed correctly but NOT literally first-poll — the floor is in-memory (not > persisted), unknown at t=0, so the gate logged `awaiting floor knowledge` then GOed ~10 s later the > instant the report ACK landed (report-ACK latency = exactly what the 90 s sub-bound is sized to; > zero-wait-when-floor-known is unit-proven, test E). The gate correctly did NOT burn the one-time > password before the update picture was clear. > The structural fix for the F10 day-0 race (DIAG-f10): the apply-bridge no longer consumes the > single-use offsite password while a managed floor-update is in flight or imminent (below floor). > New seam `offsiteapply.SettleProvider.SettleState()` + `SettleFunc` adapter over the updater's own > `GetFloor()`/`IsUpdateRunning()` (no second floor path); `Bridge.AwaitSettle` polls 10 s BEFORE the > 3-min Reconcile ctx (deferral never eats the reconcile budget), bounds 90 s floor sub-bound / 5 min > overall (both GO+WARN — the "hub that can't serve a floor can't serve a consume → no burn" argument, > R-71c is the belt). At/above floor → GO first poll, zero wait (B′). Bridge goroutine MOVED after the > updater in main.go; wired only when an updater exists. **Ordering-only** — consume/persist/404 > contract untouched; R-71(b) rejected-by-design. **FINDING:** the floor is in-memory > (report-ACK-derived ~5–10 s), NOT persisted → unknown on any restart until the first ACK (sized the > 90 s sub-bound to that). 5 test scenarios (A–E) + nil-provider + cancelled-gate; **4 red-proofs all > observed FAIL then restored** (gate/updateRunning/sub-bound/overall-bound). Deferral paths NOT > live-fired (precondition now structurally prevented by the v1.25.0 build gate). **Layering: gate > prevents, (a) defers, (c) heals.** ROADMAP R-71 → SHIPPED (a)+(c). Live leg = the B′ first-poll GO > line on both above-floor boxes. > **2026-07-23 — v0.161.0 (R-70 controller leg), SHIPPED + deployed BOTH boxes.** When > `offsite.enabled` is in controller.yaml but no `offbox` target exists (pre-apply window / burned > credential — the F10 shape), Távoli mentés now shows „Felhom offsite tárhely kiépítve — a > beállítás automatikus, folyamatban…" on BOTH empty surfaces (status card + target line) instead > of „igényelhető" / „Még nincs beállítva". Data key `OffsiteHubEnabled` (from `Server.cfg`, no new > wiring); render tests per gate branch; banner leg is unit-proven/live-pending (no healthy box > occupies the window; next fresh onboarding is the natural live leg). Hub sibling v0.72.0 carries > the detector + `offsite_delivery_stuck` + the R-71c self-heal. Origin + rulings: > `felhom.eu/documentation/audits/DIAG-f10-demo-hp-offsite-2026-07-23.md`. > **2026-07-22 — v0.160.0 (R-67), SHIPPED + deployed BOTH boxes, full live leg on demo-hp.** > Network shares now bind their share ROOT into FileBrowser (`…/:/srv/:rslave`) — no > skeleton/userdata toward the NAS, ever. Pure assembly = `buildFileBrowserPaths` + `fbPathDeps` > (handlers.go), returning mounts AND config sources together so they can't disagree. > > **DECISION — two classes, two gates:** drives keep the drive-absent gate (byte-identical, > tested + observed live: demo-felhom logged a no-op sync); network shares use the STUB classifier > gate instead (stub ⇒ excluded from both lists + WARN — an exposed stub swallows uploads the real > mount later shadows; idle autofs is HEALTHY and included; unknown fails open). Never force-wake > in the sync (doctrine). > > **Phase-0 probe = GO:** in-container access through an rslave bind WAKES an idle autofs trigger > (proved on demo-hp against the real Felhom-Share). Live leg: upload from demo-hp's filebrowser > container (uid 1000) landed on demo-felhom's share dir and deleted clean; dead-NAS gave > `Host is down` in seconds (no hang) and recovered unaided after samba restart. RESIDUAL for the > operator: the FileBrowser HTTP click-through — its admin credential is customer-held (CC got 401 > on admin/admin and the demo password; by design). ROADMAP R-67 SHIPPED (coupled to R-64). > **2026-07-22 — v0.159.0 (R-66), SHIPPED + deployed to BOTH boxes.** Three legs: „Hálózat" card on > Beállítások → Rendszer (Helyi cím / Hálózati név only-while-Megosztás / Átjáró; „—" fallback), > `network` section in the Debug dump (best-effort per item), and the NetBIOS trap named on the NAS > add form (Szerver helper text + a purely lexical hint on `unreachable` for single-label non-IP > names). > > **DECISION (the load-bearing one): all guest-net reads go through the samba netns door.** The > controller is bridge-netns'd, so `/proc/net/route`/resolv.conf/net.Interfaces in-process answer > for the CONTAINER (172.x / 127.0.0.11) — the S-2 trap. `internal/stacks/guestnet.go` docker-execs > into host-networked felhom-samba (one `guestNetExecFn` seam); Megosztás off ⇒ door closed ⇒ „—" / > in-place error strings, never a plausible-wrong substitute (S-5). Nothing stored anywhere. > > Deploy: 0.159.0 on demo-felhom 9201 (open-door path live: .104/.1/\\FELHOM) AND demo-hp 9201 via > G1 break-glass (closed-door path live: dashes, no name row, in-place dump errors; secret shredded). > demo-hp gotcha worth keeping: the controller 404s on direct container-IP probes without the > customer-domain Host header (`felhom.enkisfelhom.hu` there). Red-proofs A2 + C2 run and recorded. > ROADMAP: R-66 SHIPPED; R-64 (pairing blessed, drill = evidence leg) + R-65 (buddy-box replication, > post-alpha spike-first) minted. NAS doc gained the naming-caveat paragraph. > **2026-07-21 — v0.155.0.** v0.154.0's wizard sourced "is an op running" from `Manager.IsRunning()` > — the CONCURRENCY single-flight, acquired inside the goroutine, and **`RestoreOffboxScratch` never > acquires it**. So the execution step was unreachable for „Ellenőrzés" and the full-restore > preparation: live buttons while a restore downloaded, with the progress banner contradicting the > phase strip on the same screen. Found by the operator on the first live click-through. > > **DECISION: display reads `RestoreStatus()` (the `opRunning` flag), never `IsRunning()`**, through > the named `restoreOpInFlight` seam, and the handler reads the status ONCE per render so the strip, > the suppression and the running-op name cannot diverge. The lesson generalises: `opstatus.go` is the > DISPLAY surface and says so in its own header — the concurrency flag is not a substitute. > > **The test lesson:** a table test over a pure function proves the function, not the caller. Scenario > E passed throughout because it injected `OpRunning=true` directly. The new test drives a real > `Manager` through `BeginRestoreOp` and asserts the render. > > **DECISION: „Eredmény" earns its place.** The strip's highlight is now `Phase`, derived separately > from `Step`: a finished restore is back on the intent step while the strip reads „Eredmény" and an > outcome card shows the result — window-bounded (10 min) and app-bound. > **2026-07-21 — v0.154.0 (R-48).** Collapses the offsite restore controls to a single > „Visszaállítás…" entry per app row plus a per-app wizard at `GET /backups/restore/app?name=`. > The defect it closes is the CAUSE of the round-2 incident: the list rendered up to five inline > forms per row, two of which — the missing-only merge and the true reconstitution — were sibling > buttons whose difference is whether the data comes back. The rule it establishes: *two adjacent > controls whose difference is "your data comes back" vs "your data cannot come back" must never be > distinguishable only by layout.* > > **DECISION: the wizard is server-rendered on the EXISTING endpoints.** No new mutation endpoint, > no JSON state API, no client router. Every card is a real form POST to > `/backup/offbox/{restore,place,reconstitute}` with the same field names and gates, and the server > renders the next step — so it works with JavaScript disabled. `TestRestoreWizard_NoNewMutationEndpoints` > makes that structural: adding a form that posts somewhere new fails the suite by design. > > **DECISION: R-45 stays its own item.** The wizard polls the two existing status surfaces as-is; the > generalized job registry (and with it a real per-phase progress feed) is not built here. > > **DECISION: the step is derived, never requested.** `deriveWizardStep` is pure over (op running, > size-gate flash, scratch ready). Precedence is load-bearing — a running op outranks a stale > `?full_prep=` in the URL, or a commit button reappears mid-restore. While ANY op runs every > mutation form is suppressed server-side rather than offered and then refused with a 409. > > Latent bug found and fixed on the way: `offboxRedirectTo` hardcoded `"?"` when appending its flash, > which would have buried the flash inside `?name=`. **No agent coupling — MinAgent stays > 0.90.0.** 9 new tests + the Group-B red-proof; full suite green. > > **NOT live-validated at commit time by design:** v0.154.0 is published but deliberately NOT > hand-deployed — the operator's hub floor save (0.153.0 → 0.154.0) pulls it via the self-update > path, and that swap IS the R-23(a) single-fire validation (STOP-1). > **2026-07-20 — v0.153.0 (R-47).** Closes the H4 race on **BOTH** restore paths. The replay needs a > running DB container, so both paths started the WHOLE stack first — giving the application a window > to rebuild the schema objects the dump was about to create. Measured at 8 s on 2026-07-19 > (`DIAG-immich-restore-round2-2026-07-19`): immich-server rebuilt `clip_index` two seconds before > the dump's `CREATE INDEX`, the replay aborted `already exists` under `ON_ERROR_STOP=1`, and immich > then reported schema drift. The photos came back **by accident** — `pg_dump` emits COPY before > CREATE INDEX, so the abort landed after the rows; a collision earlier in the script would have left > a genuinely half-restored database, reported identically. > > **DECISION: the DB-only bring-up is done by compose SERVICE scoping**, not by container tricks — > `StartStackServices(name, []string{svc})` → `compose up -d `. Every catalog template's > dependency direction is app→db, so naming the DB starts the DB and nothing else. `docker start > ` was never an option: `StopStack` is `compose down`, so the containers no longer exist. > `RestartStack`/`RedeployFromEnv` are traps here — both end in a full `up -d`. > > **DECISION: fail-closed.** A `.sql` dump with no identifiable DB service refuses BEFORE the first > mutation, on both paths (one Hungarian string, shared). The alternative would be to start everything > and replay into the race. It should be structurally unreachable — `dbTypeForImage` is now shared by > `DiscoverDatabases` and `DBServiceNames`, and a dump can only exist because discovery matched the > container's image, which IS the compose `image:` value — so this is the belt for template drift. > > Enablers: `RedeployFromEnv` split into `PersistUnitRedeployConfig` (persist, starts nothing) + the > unchanged tail; `StackDataProvider.RecreateStackFromUnit` renamed to > `RecreateStackDefinitionFromUnit` because the old name promised less than the method did — the > hidden `up -d` inside it is what carried the defect on the local path. `StartStackServices` REFUSES > an empty list (argument-less `up -d` is a full start). **No agent coupling — MinAgent stays 0.90.0.** > 19 new tests, 3 red-proofs, 23/23 green. **NOT live-validated yet:** STOP-1 supervised reconstitute, > golden 0.153.0 bake (P3 registry-reachability probe from the vacation site is load-bearing), Viktor's > two hub saves, and his C6 customer-restore UI run. > **2026-07-20 — v0.152.0 + felhom-samba 1.1.0 (Megosztás on a Mac).** Closes **S-3**. **A capture > on the box overturned the earlier guess:** macOS DOES send a correct NBNS query for `<20>` and > nmbd DOES answer it correctly in 140 µs (flags `0x8580`, RCODE=0, right address) — macOS simply > never acts on it. NetBIOS there feeds legacy browsing, not `smb://` URL resolution, so **the bare > `smb://` can never work from a Mac** and nmbd was never the broken part (it is what serves > Windows). felhom-samba 1.1.0 adds **avahi + dbus**, templating `avahi-daemon.conf` and the > `_smb._tcp` service file from `FELHOM_SERVER_NAME` so a rename re-advertises; both daemons are > non-fatal on failure. v0.151.0's card had offered `smb://` for Mac — the one dead form — now > `smb://.local`; Windows keeps flat `\\`. Spiked live by hand and confirmed from the > operator's Mac BEFORE publishing the image (the operator's call, and it chose the design too). > **STILL OPEN: Finder-sidebar discovery is NOT shipped** — the record is published and answers > browse queries, but was never observed working; likely a Finder Settings → Sidebar toggle, but > unverified. **Windows was not retested.** Two test bugs fixed en route, neither a production > defect: `TestRenderSambaCompose` pinned a literal image tag, and `TestFabUpload_GCAndIdleTimeout` > asserted an async unlink synchronously (it passed alone, failed in the full package once the new > render tests made `web` heavier). 23/23 green twice; 2 red-proofs. > **2026-07-20 — v0.151.0 (Megosztás).** Closes **S-1/S-2/S-4-core/S-5** of > `felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md`; **S-3 (no mDNS/Bonjour) stays OPEN**, > awaiting Viktor's `smbutil lookup FELHOM` + `dns-sd -B _smb._tcp` from the Mac. **The `/sharing` > page had been reload-looping at ~1.2 s for every customer with sharing enabled since v0.147.0** — > `/sharing/status` coerced `idle`→`running` on the JOB phase channel, and the client answers a > terminal `running` with a one-shot `location.reload()`, so the first poll of every steady-state > page load re-armed it. The rule this leaves behind, now recorded against R-45 too: **a phase a > client answers with a one-shot action is an EDGE — never synthesise it from a level, and serve it > exactly once.** Both halves are server-side; `sharing.html`'s `