## Changelog ### UNRELEASED — E-2 Part 1: the backup-target role (foundation; NOT yet wired to a UI) **Status: foundation only. No version bump — nothing customer-visible changes yet.** The field is written by `SetBackupTarget` and read by `BackupTargetPath`/`BackupTargetAssigned`, and by nothing else. **The offer UI (Part 3), the degraded banner (Part 4) and the absent-target signal's controller half (Part 5) are NOT in this commit** — tracked as E-2 in `OPEN-ITEMS.md` so this cannot become a sixth "seam built but never wired" (the fifth, `NotifyStorageDisconnected`, was found by E-2's own Phase 0 and is one of the things still to wire). `StoragePath` gains `BackupTarget bool` — the sibling role to `Schedulable`/`IsDefault`/`Kind`, marking the drive the whole-guest vzdump is written to. **It is INTENT, not truth.** The authority is the agent's `backup.local_backup_target`; this records what the customer ASSIGNED so the controller can render the state, notice the drive going absent, and detect drift. Truth comes from the agent's `GET /backup/tiers`. Invariants, each pinned by a test asserting the CONSEQUENCE rather than the mechanism: - **A drive never acquires the role by appearing.** Registration does not set it; only an explicit customer choice through `SetBackupTarget` does. Red-proofed: adding auto-elevation to `AddStoragePath` fails `TestRegisteringDrivesNeverAssignsTheBackupTarget` with `registering drives assigned the backup target "/mnt/hdd_1"`. - **Exactly one carrier** — assigning moves the role rather than duplicating it. - **Sticky** — a new, bigger, faster drive appearing does not steal an assigned target. - **An absent target stays assigned.** Clearing on disconnect would be a silent retarget by omission: the box would read "no target configured" instead of "your target drive is missing". - **A network share is refused** — the role exists to survive a LOCAL disk failure, and a remote, credential-bound share mounted at its own root (R-108) is a different risk model. Attributes may suggest and may refuse the absurd; they may never select. The reference hardware settles it: demo-felhom's backup drive is an external **USB HDD**, and **both** demo boxes' drives report `removable=0` — a transport rule would disqualify the reference drive, a removable rule would find no candidate at all. Green gate: `go build` + `go vet` + `go test ./internal/{settings,web,quiesce}` all rc=0, run separately from the commit. ### v0.183.0 — C9-F1 + C9-F2: a restore that restored nothing, and a crash loop nobody saw (2026-07-28) Both are the same shape — the system reporting healthy while the customer is not — and both were found by Campaign 9 on live hardware. **C9-F1 (HIGH).** Tier-2 writes TWO things on every run: the capture legs (`hdd/`, `userdata/`) and, always, a full `recovery-unit/` — the app's DB dumps and named-volume tarballs. `RestoreTier2Files` reads **only the two legs** (`tier2_restore.go:101-104`) and has never opened `recovery-unit/`. For an app whose data lives entirely in named volumes that is its ENTIRE dataset, so pressing „Fájlok visszaállítása" stopped the app, restored 0 files, restarted it, and reported „Nincs hiányzó fájl — minden fájl megvan a helyén." — at the exact moment the customer pressed it BECAUSE files were missing, while 156 MB of BookStack's data sat unread in the same copy. **Phase 0 enumerated all 53 catalog templates** (cross-checked against both demo boxes' actual copies): **43 apps** have no readable subtree at all — the restore is a guaranteed no-op for them, forever — **9** have file legs but never their database or volumes, and 1 is stateless. Four apps (`plex`, `jellyfin`, `emby`, `navidrome`) are in the 43 only because their single bind is a `:ro` media mount, which `ClassifyBinds` correctly excludes. Fixed on the honesty axis (completeness is filed as C9-F1b, see below): - a **pre-flight coverage check refuses UP FRONT** — no op begun, and the app is **not stopped**; - the refusal **names the action that works** instead of dead-ending 81% of the catalog: „Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."; - where the restore DOES run it now claims only what it **examined** — „Minden vizsgált fájl megvan a helyén." — plus, whenever a unit is present, „Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba." That second string closes the QUIET half: immich's 1.3 GB Postgres unit is not covered, so the old blanket sentence was a clean bill of health over data the operation never opened. New seam: `Manager.Tier2RestoreCoverage` + `Tier2Coverage{Legs, HasUnit}`, computed from the RECORDED copy on disk rather than the catalog, so an app whose template changed is judged by what it actually has. **C9-F2 (HIGH).** `IsDownState` excludes `restarting` as "self-recovering", but with the catalog's standard `restart: unless-stopped` Docker retries forever — so a crash loop was counted as working. Campaign 9 watched docmost loop for nine minutes (restartcount 18) while F-OBS's heartbeat printed „180 scans since boot, 4 deployed app(s) evaluated, **0 currently down**". No banner, no `app_start_failed`, no email, no hub event, indefinitely. `StateRestarting` is deliberately **NOT** added to `IsDownState` — that would alarm on every deploy and update fleet-wide, the over-correction F-A1 nearly cost us. Instead a sustained restarting run becomes down after `crashLoopAfter = 5m`, justified against three numbers already in this codebase: the deploy flow's **120 s** health timeout, Mealie's **60 s** `start_period` (the slowest catalog healthcheck), and R-97b's **180 s** quiesce grace — which the threshold must exceed so the two windows compose into one bounded delay instead of leaving a gap. Docker's own backoff caps at 60 s, so a real crash loop registers ≥4 attempts inside the window. New `Stack.RestartingSince` (not persisted, same reasoning as the R-88 breaker) + `Stack.CrashLooping(now)`, used by BOTH the alarm and the dashboard counter — which previously counted `restarting` as running, contradicting the alarm on the same screen. Red-proofs, all observed: crash-loop term removed → A fails; **StateRestarting naively added to IsDownState → B fails** („every deploy and update would page the operator"); quiesce term removed → C fails; coverage guard removed → D fails with the app STOPPED; guard made unconditional → E fails (the paperless regression guard); old blanket message restored → F fails. **Filed, not fixed:** **C9-F1b** (route class-B apps to the Tier-1 unit restore — it puts a destructive operation behind a button reached via a non-destructive one, so the confirm copy has to carry that difference) and **C9-F4** (`backups/secondary//recovery-unit/` is written by every Tier-2 run and read by NOTHING — `RecoveryUnitPath` resolves to `backups/primary/`, so the second local copy that exists precisely for drive loss is unreachable by any customer action). ### v0.182.0 — R-101 + F-DIAG: the customer must not be told a failed backup is a copy (2026-07-28) **R-101.** `Tier2LastRun` is the ATTEMPT clock — `recordTier2Failure` writes it too — and it was rendered as „Legutóbbi másolat" in the **restore confirm dialog**. That is misinformation at a decision point, not an alarm bug: the restore it guards fills in MISSING files without touching existing ones, so a customer whose Tier-2 had been failing was told a copy existed from last night, restored, and silently received **older** files while believing they were recent. `CrossDriveBackup` gains `LastSuccess` (same rule and shape as the offsite anchor) plus `SuccessTracked`, which distinguishes "this row predates the anchor" from "this row has one and it is empty". Without that marker the two are indistinguishable and **all 7 Tier-2 rows on the fleet** would have flipped to „Még nincs sikeres másolat" on deploy. Legacy rows are migrated truthfully on first touch: a row whose last known state was `ok` adopts that time; a row whose last state was `error` seeds nothing, because the old data evidences no success. Shipped strings: „Legutóbbi sikeres másolat: {dátum}" · „…Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek." · „Utolsó sikeres: {relatív}" · „Még nincs sikeres másolat" with the restore replaced by „Még nincs sikeres másolat, amiből vissza lehetne állítani." The dialog also stops printing a raw UTC RFC3339 stamp — new `fmtTimeStr` renders Budapest-local `2026-07-25 03:30`. **Part 2 — the copy-site hazard, and it was in the path.** The three `record*` helpers each built a WHOLE `CrossDriveBackup` literal with a helper re-applying exactly two fields; everything else was zeroed on every status write. Adding `LastSuccess` to that shape would have had `recordTier2Failure` **clear** it — the mirror image of the defect, firing on the first failure. Replaced with `tier2Update`, which copies the existing row and overlays the outcome: **safe by construction**, a new field carries over unless deliberately overwritten. Sweep: `SetTier2Preference` mutates in place (safe); `SetCrossDriveConfig(name, nil)` is a deliberate delete. **F-DIAG.** The offsite failure notification was `"…: " + err.Error()` — one string for every cause AND a raw passthrough. `ClassifyOffsiteFailure` now returns quota / orphaned / no_repo / no_units / transport / **unknown** (unclassifiable says so rather than being folded into a neighbour), each with its own Hungarian message. **The secrets half caught a bug in my own first attempt.** The initial sanitiser regex-matched `sftp:…` and `user@host` and looked complete; its own test caught it leaking on `ssh: connect to host port 23: Connection refused`, a bare hostname in neither shape. It now redacts the target's **actual** host/user/repo-path literally, with the regex kept only as a backstop — guessing at what a secret looks like fails exactly where it matters. Red-proofs, all observed failing: dialog back on the attempt clock → `the dialog does not name the last SUCCESSFUL copy`; gate the restore on `LastRun` → `a tier that has NEVER succeeded still offers a restore`; make the caution unconditional → `a HEALTHY tier shows the failed-attempt caution`; clear the anchor on failure → `a FAILED run wiped the success anchor`; raw sanitiser → `the repo reference reached the message ("sftp:" leaked)`. ### v0.181.0 — R-100: record the last SUCCESS, not just the last attempt (2026-07-28) The producer half of R-100. `OffboxTarget` gains **`LastSuccess`** (RFC3339), carried to the hub on the offsite report as `last_success`. The hub's staleness verdict counts from it (hub v0.80.0). **Why a new field rather than reading `LastStatus`.** `LastRun` is written unconditionally at the end of every run, failures included — it records an **attempt**. "How long since `LastRun`" therefore answers "how long since we last TRIED", which is not the question a freshness verdict asks. The alternative — "`LastStatus == error` ⇒ stale" — turns every transient blip into an immediate alarm, which is the F-A1 noise failure mode. Anchoring on last success tolerates one bad night and catches a persistent one, using the threshold that already exists. The rule is a pure function, `offboxAnchorAfterRun(prev, at, runErr)`, called unconditionally beside the `LastRun` write. Both directions are bugs if got wrong and both are pinned: - a failure must not **advance** it → or the original defect survives; - a failure must not **clear** it → or one bad night makes an established tier read as never-succeeded (the mirror-image over-correction, and on the hub side the newborn-box path). **Two silent-wipe sites found and closed**, both of the "seam built but never wired" shape — the field exists, the writer sets it, and an unrelated routine path zeroes it: - `offboxConfigHandler` rebuilds the target from the form and copies runtime status field by field, so an ordinary settings save (edit the host, edit the path) would have erased the anchor; - `ApplyOffsiteTarget` does the same on a hub re-apply — an established tier reset to "never succeeded" every time the hub re-pushed its descriptor. Neither would have surfaced until the hub's verdict changed, days later. **A hollow test of my own, caught by red-proofing it.** The first version of `TestOffboxLastSuccess_OnlyAdvancesOnSuccess` re-implemented the rule in a local closure: mutating the production code left it **green**. That is what the extraction to `offboxAnchorAfterRun` is for — the test now calls the real rule, and the red-proof bites. Red-proofs, all observed failing: drop the `runErr` guard → `a FAILED run advanced LastSuccess to "2026-07-21T02:15:00Z" — that is the R-100 defect in mirror image`; always return `prev` → `a successful run did not advance the anchor`; drop the wire field → `OffboxReportStatus dropped LastSuccess — the hub would degrade forever on a controller that has it`; drop the handler preservation → `a settings save erased LastSuccess`. ### v0.180.0 — F-OBS: the dead-app check gets a positive observable (2026-07-28) On a default `logging.level: info` box there was **no way to tell whether `deadapp-check` had run**. Its per-cycle scheduler line goes through `Scheduler.dbg()`, which is gated on `s.debug` — so on an info-level box the line is never *produced*, not merely filtered, and therefore cannot reach the always-DEBUG ring either. A 30 s interval also puts the job on the scheduler's quiet path (`quiet := job.Interval <= 30*time.Second`). So "no alarms" was indistinguishable from "the detector never ran" — the exact fallacy this project now has a standing rule against, and it directly undermines confidence in the **F-CRIT-1** fix in the field: that fix's whole value is that a genuinely dead app now alarms, and an operator had no way to confirm the thing that alarms is alive. **A periodic summary, not a line per run.** At 30 s a per-run line is 2880 lines/day, which is precisely why the original author chose silence — so a fix that floods is not a fix. Every 20th scan (≈10 minutes) emits one INFO carrying the scan count, how many deployed apps were evaluated, and how many are currently down. An operator can answer "is it running, and what does it see?" from a default box, and a STALLED detector shows up as the heartbeat stopping. 10 minutes is chosen to stay useful as a liveness signal: it is well inside the 180 s alarm grace this check feeds, and a test pins the cadence so nobody can widen it to hours and quietly make the observable useless again. ### Also Corrected the comment claiming the quiesce unquiesce is "guaranteed by defer". Campaign 8 fault 10 established that a SIGKILL runs no deferred function — the guarantee is the crash MARKER plus `Recover()`, which brought the stacks back 1 s after restart. The `defer` covers only the graceful exits. Files: `cmd/controller/main.go`, `internal/quiesce/quiesce.go` (comment), `cmd/controller/deadapp_observable_test.go` (new). ### v0.179.0 — F-CRIT-1 + F-A1: one alarm that never fired, one that fired wrongly (2026-07-28) Both Campaign 8 findings live in `internal/quiesce` and its `classifyRunStates` consumer, and both are "the alarm is wrong" — one missing, one spurious. Fixed together, one pass over the same code. #### F-CRIT-1 — an app that failed to restart after a quiesce NEVER alarmed. **Two independent causes; either alone kept it dead.** *Cause 1 — the outcome was thrown away.* `restartAll` returned nothing; a failed `StartStack` was logged and dropped on the spot, so no caller could learn a customer's app had not come back. It now **returns the stacks that failed**, and both call sites (the cycle's unquiesce and crash recovery) record the result. *Cause 2 — a documented invariant the quiesce path had made false.* `classifyRunStates` whitelists `StateStopped` because v0.164.0 (correctly) refused to alarm on deliberate user stops, resting on I1: *"StateStopped means deployed, deliberately stopped by the user."* **The quiesce loop stops stacks by the same `docker compose down` path**, so a stack it stopped and then failed to restart is also `StateStopped` — byte-identical to a user stop on the Docker side — and was whitelisted into total silence. Campaign 8 watched a customer app sit dead indefinitely with no banner, no event and no email while the dead-app scanner ran over it 11 times. No state test can separate the two; they *are* the same state. The distinguishing fact is that the loop **tried to restart it and could not**, which it now reports via `Loop.FailedRestarts()`. That set is the only thing that lifts the whitelist, so genuine user stops stay silent (pinned by `TestClassifyRunStates_UserStopStillSilent`). *Why the existing tests missed it:* R-97b's Scenario F asserted that **suppression expires**. It never asserted that **an alarm follows**. The suppression lifted correctly and the whitelist ate the alarm one layer down — a green, red-proofed suite over a production path broken two ways. #### F-A1 — a correct refusal reported as a failure HTTP 409 from `POST /backup` is the agent's R-85 single-flight gate refusing while a restore-test holds it. The start path had no 409 branch, so it called `noteTierFailure`: the R-88 breaker armed and `whole_guest_backup_failed` was emailed. Campaign 8 saw it on both boxes in the same minute. At real cadences a ~12-minute restore-test against a daily backup collides roughly once per 420 guest-days — about **every 4 days on a 100-guest fleet, forever** — which trains the operator to ignore the alarm and quietly undoes R-97a. 409 is now **contention, not failure**: `agentapi` returns a typed `*StatusError` on POST, the adapter maps 409 → `quiesce.ErrTierBusy` (the same seam that maps 404 → `ErrTiersUnsupported`), and the loop defers instead of failing. No breaker, no event, no email; the tier stays **DUE**. **Two traps this deliberately avoids.** *Silence:* "just ignore 409" would let a wedged restore-test block backups forever with nobody told — so contention lasting past `contentionAlarmAfter` (**3h**) raises its own signal, headlined **BLOCKED**, not FAILED. The bound is set by the agent's own ceiling, not taste: its PBS restore-test task is capped at 120 minutes, so contention outliving that is a stuck gate rather than a busy one; 3h adds margin and is 15× the longest contention actually observed (12m01s). *App thrash:* removing the failure treatment also removes the breaker's deferral, which had been (accidentally) preventing a re-quiesce every 5 minutes. Without a replacement the customer's apps would be stopped and restarted on **every poll** for the whole restore-test — worse than the bug. A contended tier is therefore dropped from the due set **before anything stops**, on a `contentionRetryAfter` of 15m (the longest observed restore-test is 12m01s; the agent's local restore-test wait is 10m). #### Comments corrected (three of the six) `classifyRunStates`' I1 now states what `StateStopped` actually means and names the quiesce path; `quiesce.go`'s "would record a spurious failure" says that this was not hypothetical until now; and the agent's `inflight.go` "a caller that cannot acquire DEFERS" records that this was true of the restore-test caller and not the backup caller. A standing rule was added to both copies of `CLAUDE.md`: **a comment asserting an invariant needs a test pinning it, or it is a wish.** Files: `internal/quiesce/{quiesce.go,suppress.go,contention.go (new)}`, `internal/agentapi/client.go`, `cmd/controller/main.go`, plus new tests `internal/quiesce/{failed_restart_test.go,contention_test.go}` and `cmd/controller/failed_restart_classify_test.go`. No wire/contract change; no agent behaviour change. ### v0.178.0 — R-88 Part 2 (controller) + R-97c comment fix (2026-07-27) — **MinAgent: 0.105.0** for the age_state semantics **The safety valve now needs a licence.** `scheduledRunAllowed` fired on ANY nil age — "no recorded backup yet, never withhold the first one". With agent v0.105.0 the age carries a STATE, and only a POSITIVE claim licenses the bypass: | `age_state` | licenses the valve? | why | |---|---|---| | `absent` | **yes** | the agent looked; there is genuinely nothing there | | `unknown` | **no** | unreadable storage — this is the whole fix | | `known` | n/a | a real age; the age comparison decides | | *(empty)* | **yes** | pre-v0.105.0 agent — see below | **A missing field means LEGACY, not unknown, and that is deliberate.** Reading an old agent's silence as "unknown" looks safer and regresses Scenario D: the valve would stop firing on every un-upgraded box, so a genuinely new box would never take its first backup outside its window and nobody would notice for weeks. Preserving the KNOWN behaviour is correct; the MinAgent floor drives the upgrade. The degrade is logged **once** per process, the `logTierDegradeOnce` shape. An unrecognised FUTURE value also maps to legacy — a newer agent inventing a fourth state must not inherit "unknown" semantics from a controller that has never heard of it. **Caught while doing it, and worth naming:** `TieredBackend` is satisfied by a RUNTIME type assertion in `resolveDueTiers`, so when `DueFor`'s signature changed the whole repo still built and vetted clean while `quiesceBackend` silently stopped satisfying the interface — which would have degraded every box to the untargeted single-tier path, losing R-82's multi-tier backups entirely, with no error anywhere. `TestQuiesceBackendSatisfiesTieredBackend` is now the compile-time witness. Sixth instance of the inert-seam class. **R-97c follow-through:** the comment in `internal/notify` claiming these event types are operator-only "because they have no customerMessages entry" was **wrong** and is corrected — the hub falls back to the raw message when the entry is missing, and the only customer gate is `prefs.EnabledEvents`. Enforcement is hub-side `operatorOnlyEvents` (hub >= v0.79.0). Unchanged: the R-88 Part 1 breaker and its timings, the window bounds, `dropBackedOffTiers`, and `TriggerNow` (still ungated by everything). Tests +7 (6 age-state + 1 interface witness); 27 packages ok. Red-proofs observed for Scenarios A, B and C. ### v0.177.0 — R-97: a failing backup is HEARD, and stops blaming the apps (2026-07-27) — MinAgent unchanged; requires hub >= v0.78.0 **R-97a — the whole-guest tier had no route to the hub.** `internal/quiesce` did not import `internal/notify` at all, so on 2026-07-27 three failed whole-guest backups and twelve app-stack stop/starts produced **zero** events. `NotifyBackupFailed` existed and the hub allowlisted `backup_failed`; only the wiring was missing — the inert-seam shape this project has now hit five times. This got MORE urgent when R-88 shipped, not less. Before the breaker a failing backup retried every 5 minutes: harmful, but loud enough to notice. Now it backs off to 4h and goes quiet, leaving the hub's deadline monitor as the only signal — **~26h for local, ~8 days for PBS**, a full cycle of the weekly tier. This trades that delay for an immediate one. `quiesce.TierNotifier` is a seam, not an import (same reason `windowStartFn` is injected), wired by the init-only `SetTierNotifier` because main.go builds the notifier *after* the loop. It is **edge-triggered**: `BackupFailed` fires when the breaker ARMS — the first failure of a run, never the retries behind it — and `BackupRecovered` on `recordSuccess`'s existing bool, so an operator told a tier broke is also told it healed. **New OPERATOR-ONLY event types**, `whole_guest_backup_failed` / `_recovered` (hub v0.78.0). Deliberately NOT `backup_failed`: that type carries a customer-facing Hungarian template **and** sits in demo-felhom's live `enabled_events`, so reusing it would have emailed the CUSTOMER „A biztonsági mentés sikertelen" while the backup was still retrying. The tier travels in `WholeGuestBackupDetails.Tier`, which is load-bearing — the hub keys its per-tier operator cooldown on it, so `local` failing is not swallowed by `felhom-pbs` having failed within the hour. **R-97b — stop telling the customer their app is broken when WE stopped it.** During the loop the only customer-visible output was `app_start_failed — „Telepített alkalmazás nem fut: BookStack"`: customer channel, Hungarian, during an outage the backup system itself caused, with no indication why. **v0.164.0's filter does not cover this.** That predicate is state-based (`IsDownState(st.State) && st.State != StateStopped`) and suppresses *deliberately stopped* apps. BookStack alarmed because the third cycle caught it **mid-restart** — starting, or up but not yet healthy — which is not `StateStopped`. No state classification can tell "restarting because a backup stopped me" from "restarting because I keep crashing"; the distinguishing fact is that *we* stopped it, and we know we did. So the fix is a **suppression window keyed to the cycle**, consumed at the same single derivation point (`classifyRunStates`) that already computes both the banner dead-list and the notifier Down-set — still one place. **The grace window is 180 s**, derived rather than picked round: the deploy flow already allows **120 s** for a stack to come up healthy, and the slowest catalog healthcheck start_period is Mealie's **60 s**, after which a couple of check intervals must still elapse. It **expires** — an app that genuinely fails to come back alarms on the first scan after the window closes. Permanent suppression would trade a loud false alarm for a silent real one, which is R-88's Scenario D in a new costume. Tests +9 (8 quiesce + 1 wiring reachability). Red-proofs observed for Scenarios C, E and F. ### v0.176.0 — R-88 Part 1: a failing backup stops re-quiescing (2026-07-27) **The apps were being stopped and restarted every five minutes for a backup that could not succeed.** Observed live on demo-felhom 2026-07-27: three full quiesce cycles at 09:02:57, 09:07:58 and 09:12:57 Budapest — each stopping and restarting all four customer app stacks (`bookstack calibre-web docmost immich`, ~19 s down per cycle) against a PBS tier that was unreachable. It stopped after three only because PBS came back, **not** because anything gave up: `internal/quiesce` had no consecutive-failure counter, no backoff and no circuit breaker of any kind, and the driver is a plain 5-minute ticker. Had the outage lasted, so would the loop. **The failure breaker** (`internal/quiesce/breaker.go`). Consecutive failures are tracked **per target**; a tier inside its backoff is dropped from the due set **before any stack is stopped** — the gate is on the QUIESCE, not the backup, because the harm was never the failing backup but the outage taken to attempt it. Backoff is `15m → 30m → 1h → 2h → 4h`, then 4h forever. The cap is picked against two real constants rather than taste: 4h sits well inside the shortest tier cadence (local = 24h), so a recovered tier still gets several attempts within its own cadence; and it equals the width of the backup window gate `[W+2h, W+6h)`, so a tier at maximum backoff still gets at least one attempt inside any given night's window instead of stepping over it. Deliberately bounded in four ways, each with a test: - **Never permanent.** The cap bounds the retry INTERVAL; it never stops retrying. A latched breaker is a silent backup outage — strictly worse than the loop, which at least announced itself. - **Never global.** One broken tier cannot suppress a healthy one. - **Never gates `TriggerNow`.** A human pressing „Mentés most" is not deferred by a scheduler's safety net. Manual runs still RECORD their outcome, so a manual success clears the backoff. - **`stillRunning` is not a failure.** A first full offsite snapshot legitimately runs for hours. State is **in-memory on purpose** — a restart forgets the backoff and re-attempts, which is the cheap direction to fail; persisting it could carry a stale "this tier is broken" verdict across the very restart that fixed it. **The invariant, written where it will be read** (`scheduledRunAllowed`). A missing value means UNKNOWN — not zero, not "never". Only a POSITIVE determination of "never backed up" may fire the safety valve. This is the **fourth** instance of the same class (hub v0.12.0, hub v0.73.0, R-81, and this), so the comment names all four and `TestContract_NeverBackedUp_RunsOutsideTheWindow` pins the half that a careless fix would break. **NOT fixed here, and deliberately so — R-88 Part 2 (agent-side).** The nil branch still fires the valve, because the controller *cannot tell the two apart*: the agent's `/backup/due` returns byte-identical responses for "the storage read errored" and "there has genuinely never been a backup" — same `Due: true`, same `Reason: "no successful backup recorded yet"`, same nil `AgeSecs`. Root cause is `localapi/server.go`'s `newestArchiveOn`, whose comment promises errors "degrade to unknown, never to no-backup" while its `(time.Time, bool)` signature cannot represent unknown. Splitting them needs a wire change plus a compat rule in both directions → its own task. Until then the breaker bounds the damage: an unknown-driven cycle may still run once outside the window, but it can no longer repeat. Tests +11 (7 breaker, 4 contract). Red-proofs observed for Scenarios A, D and F. ### v0.175.0 — R-82: a tier that overruns the quiesce bound defers the rest (2026-07-26) Operator ruling 2026-07-26: *"let the first backup run as long as needed; other backups shouldn't start until finished."* A first FULL offsite snapshot legitimately runs for **hours** — far past `max_quiesce`. When that bound elapses the app resumes (correct, and unchanged), but `quiesceAndPollTiers` then moved on and started the NEXT tier while the first was still uploading. That is now a `break`: the remaining tiers are deferred to a later poll. Why it matters: vzdump still holds the guest lock, so the second start would be **refused by the agent (409, v0.99.0)** or fail on the lock — and a failed backup never satisfies a cadence, so the tier would stay permanently due and retry into the same wall every poll. `pollTier` now returns `(phase, stillRunning, err)`; `stillRunning` means the bound elapsed with the backup still going. Nothing else changed — the app still resumes exactly once, on the same guard. **Tests:** `TestTierOverrunsQuiesceBound_RemainingTiersDeferred`. Red-proof observed: dropping the `break` starts the second tier and the test fails with `the second tier MUST NOT start while the first is still running; started=[local felhom-pbs]`. Restored; full suite green. ### v0.174.0 — R-82 Slice B: one quiesce window, two backup tiers (2026-07-26) **MinAgent UNCHANGED — deliberately.** This release degrades gracefully against ANY older agent; it does not require v0.97.0. Against a pre-R-82 agent it uses the untargeted single-tier path exactly as before, logs the degrade once, and **still takes the backup**. The agent gained per-target backup tiers in v0.97.0 ("local daily + PBS weekly"). The **controller** owns quiescing, so the multi-tier schedule has to be reconciled here: on the weekly night both tiers come due at once, and two quiesce cycles would mean **two app outages for one night's work** — undoing the entire argument for weekly-over-daily. ### The dedup rule (specified, not emergent) | local due | PBS due | result | |---|---|---| | yes | no | one quiesce, local backup | | no | yes | one quiesce, PBS backup | | **yes** | **yes** | **ONE quiesce window, BOTH backups inside it — never two cycles** | | no | no | no quiesce | ### Added - **`quiesce.TieredBackend`** (optional extension to `Backend`) + `quiesce.BackupTier`, `ErrTiersUnsupported`. A backend that does not implement it — or whose `Tiers` returns `ErrTiersUnsupported` — drives the pre-R-82 single-tier path unchanged. - **`agentapi` per-tier client**: `BackupTiers`, `BackupDueFor`, `StartBackupFor`, `BackupStatusFor` (`internal/agentapi/backup_tiers.go`). `targetQuery("")` yields an EMPTY suffix, so an untargeted call hits the untargeted route byte-for-byte. - **`Loop.resolveDueTiers`** — the dedup rule in one place, returning due tiers in AGENT ORDER. - **`Loop.quiesceAndPollTiers` + `pollTier`** — one marker, one stop, N sequential backups, one resume, tail polled to completion. ### Capability detection `GET /backup/tiers` 404 ⇒ pre-R-82 agent. This is the project's documented ROUTE-PROBE mechanism (`internal/agentapi/features.go`: "a route that shipped together with the coupled semantics either answers (2xx ⇒ supported) or 404s"). It is **not** registered in the `featureProbes` table on purpose: that table answers a yes/no at a UI entry point, whereas the loop needs the tier LIST itself, so a table row would be a second probe of the same route for no gain. The degrade is logged **exactly once per process** — once because it is a steady state during a rollout, never zero times because a silent degrade is indistinguishable from multi-tier working. ### Two decisions worth stating plainly **The app stays quiesced until the LAST tier snapshots.** Resuming after tier 1's snapshot would leave the following tier capturing a RUNNING app — losing app-consistency on exactly the DR tier we most want it on. **Consequence, user-visible:** on the both-due night downtime is *(first tier's full backup)* + *(last tier's snapshot)*, not one snapshot. Tiers must therefore run **fast-first**: vzdump holds a guest lock so they are necessarily sequential, and the agent advertises primary (local) first — local-then-PBS makes downtime ≈ local backup + PBS snapshot, whereas the reverse would be ≈ PBS backup + local snapshot, far worse. **A manual "Mentés most" covers EVERY tier**, in one window, due-ness ignored. A manual run that silently skipped the DR tier would be the same applied-and-empty fault in a different costume. ### Resilience (unchanged guarantees, extended per tier) - Marker written BEFORE anything stops; unquiesce guaranteed by `defer` and fires **exactly once** no matter which tier fails; a crash between two backups leaves the marker and `Recover()` restarts the stacks at startup. - One tier failing to START does not prevent the other tier's backup, and the app still resumes once. - One tier's due-check erroring does not drop the other tier's backup. - An agent advertising ZERO tiers falls back to the untargeted path — never "nothing to do". - The window gate's safety valve now evaluates the OLDEST (most overdue) due tier, so a stale DR tier cannot be starved by a fresher local one (`oldestAge`; a never-backed-up tier wins outright). ### Tests +11 in `internal/quiesce/tiers_test.go`; full suite green. Red-proofs observed and restored: - **#3 both-due night** — a per-tier cycle instead of one window fails with `want EXACTLY 1 stop and 1 start, got stops=2 starts=2`. The COUNT is the assertion; asserting only "both backups ran" would pass against a double-quiesce implementation. - **#2 new controller ↔ old agent** — treating `ErrTiersUnsupported` as "nothing due" fails with `OLD AGENT: a backup MUST still be taken via the untargeted path; got started=[]`. The hollow version of this test asserts only "no error", which passes while silently skipping the backup. ### v0.173.0 — R-77: endpoint-drift detection, samba protected-set gate, channel log honesty (2026-07-26) Source: `felhom.eu/documentation/audits/DIAG-agent-channel-2026-07-26.md`. **Operational repair first (Part 0).** Both production controllers had been dialling their pre-island LAN address since 2026-07-25 12:44 — the island migration rewrote `bootstrap.json` and `controller.yaml` was never updated. `local_api.endpoint` corrected to `169.254.253.1:8443` on demo-felhom and demo-hp (backups at `controller.yaml.pre-r77.bak`); **fingerprint and token agreed on both boxes**, so only the address moved. Channel healthy since: zero `[channel]` lines and zero `agent_channel_*` hub events after restart. **Endpoint-drift detection — DETECT AND NAME, never write** (`bootstrap.DetectEndpointDrift`). When `controller.yaml` and `bootstrap.json` both carry a complete `local_api` block and their endpoints disagree, the controller emits one ERROR naming **both values and both paths**, raises a **new, dedicated event type `local_api_endpoint_drift`** (error severity — drift never self-heals), and shows its own Hungarian banner *above* the channel banner, because drift is the CAUSE and "agent unreachable" the symptom. It **does not reconcile the files**: the mirror-image failure — clobbering a correct `controller.yaml` from a stale `bootstrap.json` — is just as bad, fleet-wide. That authority ruling is **R-78**. Fail-safe to silence on an absent/unparseable/incomplete bootstrap (an unprovisioned guest is not drifted) and on an empty endpoint (that is `ensureLocalAPI`'s fill-if-missing path, untouched). The fingerprint is compared and reported as a **boolean only**; the token is never compared, logged or exposed. **Hub allowlist (`felhom.eu` hub v0.74.0) — required, not optional.** `allowedEventTypes` 400s an unknown `event_type`, so without the one-line entry the new alert would have been silently inert — the exact seam-wiring failure this project has hit four times. Shipped with the controller. **Samba protected-set gate.** `EffectiveProtected` now requires `smb.Enabled && smb.UserSet`, mirroring **both** of `reconcileSambaAt`'s early returns. Sharing enabled without a household password means the controller deliberately does not deploy samba, yet the health monitor reported `fail` for it — demo-hp reported `health=fail` to the hub from the moment sharing was switched on. **The doc comment was corrected in the same change**: it claimed "detection and deployment agree in both directions" while citing only `!smb.Enabled`, an assertion that became false when the `!smb.UserSet` return was added — a comment documenting a guarantee the code no longer provides is how the bug returns. Not over-suppressed: sharing on **with** a password and a dead container still alarms. `TestEffectiveProtectedTracksSharingToggle` was updated — its old fixture asserted the buggy behaviour. **Channel log honesty.** The debounce branch seeded an unseeded state to `"up"`, so a **born-down** channel logged `up->down:` and `orUnseeded` was dead code. On 2026-07-25 that implied a working channel degrading when neither controller had *ever* reached its agent, and it misdirected the first read of the incident. The placeholder is now `stateUnconfirmed`, rendered `unseeded`. **Logging only** — the placeholder is still matched in the re-arm condition, so F2 born-down alerting is byte-for-byte unchanged; the Scenario-F test asserts sink call **count and arguments**, not just the string, and all nine pre-existing channelhealth tests still pass. Tests 951 → 959, all green. Three red-proofs (A, E, F) recorded in REPORT.md — Scenario A in **both** failure directions: no-detection, and the auto-correcting variant that trips the byte-identical assertion. **MinAgent unchanged; felhom-agent untouched** (DIAG refuted H1 — the island is healthy). ### v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces (2026-07-26) Spike: `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`. **The drop-zone is now ONE canonical location on the system drive.** New `${IMPORT_PATH}` = `/userdata/import`, injected at BOTH compose-env builders (`withUserdataPath` → `withPathVars`, `deploy.go` + `manager.go`) — the initial-deploy path missing `USERDATA_PATH` once bound a bogus root-owned dir at the container root, and `IMPORT_PATH` has the identical failure mode. It is derived from the SYSTEM drive, never from `HDD_PATH`, and has **no per-drive fallback**: an unresolvable root leaves the variable UNSET so compose fails loudly instead of quietly building a second, non-functional drop-zone. *Operator ruling, overriding the spike's Fork-1 recommendation:* each drop-zone app has exactly one ingest bind, so a per-drive `import/` would put a folder that LOOKS like a drop-zone on every drive while only one works — and since import paths are `class: excluded`, files stranded in a dead one are never backed up either. **Third `BindRoot` + the whole-block regression it prevents.** `RootImport` / `${IMPORT_PATH}` in `composeVarRoots`, an `Import []BindSpec` list in `BackupSpec`, and `ValidateBackupSpec` / `ClassifyBinds` extended. This is load-bearing: `ValidateBackupSpec` rejects an entry matching no compose bind and the rejection is WHOLE-BLOCK, so moving paperless's ingest bind while leaving `userdata: import/paperless` in place would have discarded the entire block — taking `hdd: appdata/paperless/media class: mandatory` with it and silently degrading the customer's document originals to legacy handling. `TestScenarioB_*` is the gate. **Exhaustive-root audit — `resolveAbs` was the sharp one.** An import bind resolved against `hddPath` would name a directory on the WRONG DRIVE. `resolveAbs`, `structuralGuard`, `ComputeCaptureSet` and `ComputeFabBuckets` now take `importRoot` explicitly (compile-forced at all 4 call sites), and an unresolvable root is refused LOUDLY into `Skipped` (`reasonNoImportRoot`) rather than joined onto "". `GetImportRoot()` added to both provider interfaces + both adapters. `fabplan`/`tier2DestRel`/ `export.go`/`appbackup_bridge.go` audited and recorded in REPORT.md. **Catalog-derived skeleton, deterministic by construction.** `UserdataSkeleton()` → `UserdataSkeletonCarry()` (the v0.171.0 list verbatim, retained forever) + `BuildUserdataSkeleton()`, which merges it with `DeriveUserdataDirs(stacksDir)` and **sorts**. The carry-list makes zero-removals true by construction — `documents` is implied by no catalog app yet exists on both demo boxes — and doubles as the fresh-box floor. The sort is not tidiness: the spike measured the naive map-order derivation at **20 distinct outputs from 20 identical runs**, and `fbNeedsRecreate` force-recreates on any byte difference across ~14 `SyncFileBrowserMounts` call sites — a fleet-wide FileBrowser restart loop. `TestScenarioC_SkeletonDeterminism` pins 20/20. The catalog sync is deliberately **still not** wired to `SyncFileBrowserMounts`. The canonical import root is excluded from per-app migration (`appDataSkipSet`) so it never moves with an app. **One authoritative compose parser.** `ParseComposeUserdataMounts` is now a thin resolver over `ParseComposeClassifiableBinds`. The classifier won because it is the richer of the two byte-identical scanners (it keeps the root and the `:ro` flag). One deliberate behaviour drop, recorded not hidden: the old textual replace also accepted a LITERAL absolute path under `userdataPath`; no catalog template has ever used that form and such a compose would be pinned to one machine's drive layout. The deploy belt now handles both roots, gated differently — the drive-absent gate applies to the app's data drive and must NOT suppress a system-drive import dir. **Surfaces.** FileBrowser gains a separate `/srv/beolvasas` bind + a „Beolvasás" sidebar source (separate, not nested — a nested source is indexed twice). New app-page block **„Hova tegyem a fájlokat?"** for DEPLOYED apps declaring `data_paths`, with a deep link built from the shipped Quantum router template, `url.PathEscape` per segment (**never `QueryEscape`** — it encodes space as `+`, a literal plus in a path), the system-drive free space on import rows, and a **class-driven** consequence line so the UI can never promise a backup the engines do not make. Copy does not promise one click: a cold deep link goes through the FileBrowser login. **`data_paths:` annotation** (`stacks.Metadata.DataPaths`) — role + Hungarian label over paths that must ALREADY exist as compose binds; it can never declare one. Fork-3 asymmetry, deliberate: a malformed PATH is a whole-block reject (data handling; reuses `ValidateBackupSpec`'s refusal set via the extracted `appbackup.ValidateRelPath` — no second validator), an unknown ROLE fails OPEN with one WARN (presentation; the `Lifecycle` precedent). Catalog: paperless-ngx, calibre-web, romm. **System-owned import share.** `SMBShare.System`; a `beolvasas` share auto-created when sharing is ENABLED (never before — deploying an app must not put SMB on the household LAN), `Offsite: false` because the data is `class: excluded`. Deletion refused **server-side at both the handler and the store**, and the button omitted in the template — three checks proving different things (the v0.70.1 ghost-delete lesson: a render gate is not enforcement, a handler test is not reachability). The share is written directly rather than through `sharingResolvePath`: that guard validates CUSTOMER-supplied picker paths, and the system drive is deliberately not a registered StoragePath. **A latent 500 caught on the way:** the sharing template's row struct was function-local, so adding `{{if .System}}` would have failed at render for every share. `ShareRow` is now package-level and the render test constructs the exact type the handler passes. **Caught during the live legs and fixed in the same version (two things):** 1. The carry-list initially kept `import`, `import/paperless` and `import/calibre`, so the skeleton would RE-CREATE a per-drive drop-zone on every drive forever — the exact dead lookalike this arc removes, and one that is never backed up. Dropped from the carry-list. This is not a removal: nothing deletes the dirs an existing box has (both demo boxes' old drop-zones were verified to hold **zero files** first); they stop being maintained and stop appearing on fresh boxes. `TestSkeletonNeverCreatesAPerDriveDropZone` pins it, and `TestUserdataSkeleton_List` was updated to assert their absence. 2. `EnsureImportRoot` ensured only the leaf, so `MkdirAll`'s intermediates left `/userdata` at `755 root:root` — the one userdata root on the box outside the 2775/gid-1000 convention. Both the parent and the import dir now carry it (`TestEnsureImportRoot_ParentCarriesTheConvention`). Tests 915 → 951, all green. Red-proofs recorded in REPORT.md for Scenario B (classification), C (determinism) and E (server-side share refusal). No destructive filesystem call was added anywhere in this arc. **MinAgent unchanged.** ### v0.171.0 — Disk-health card: device-model label (pairs with agent v0.95.0) (2026-07-25) `agentapi.SmartSummary` gains `ModelName` (mirrors the agent v0.95.0 `model_name`); the "Lemezek állapota" card row label now prefers the device model ("TOSHIBA MQ04ABF100") over the raw storage name/UUID, falling back to Name (+ speed hint) on an older agent or a modelless disk. With agent v0.95.0 the system SSD and the USB drive now carry real SMART, so the card shows real verdicts (Rendben) with human labels instead of "Nincs adat" on a raw UUID. Additive; old-agent payloads render exactly as before. Test `TestDiskDisplayLabel_PrefersModel` (red-proof: drop the fallback → A4 fails). ### v0.170.0 — Root → Indítópult; gofmt normalization; stale-note fix (2026-07-25) - **`/` is now the Indítópult** (operator ruling, reversing the v0.163.0 landing choice). `GET /` 302s to `/launcher` (ONE canonical URL per page — the launcher body is never served at `/`); the Vezérlőpult keeps its own URL **`/dashboard`** and its nav slot. Nav: Indítópult active on `/launcher`, Vezérlőpult `href="/dashboard"` active there — never both. Post-login (default `/`) and the mobile-topbar logo (`/`) both flow through the redirect to the launcher; the login redirect target is unchanged. Tests: the 302 (target + status), `/dashboard` 200, nav hrefs/active; red-proof: fold `/` back into the dashboard case → the 302 test fails. Two dashboard-card tests repointed `/`→`/dashboard`. - **gofmt normalization** shipped as a **separate, style-only prior commit** (`gofmt -w` across the controller tree, **46 files**, `gofmt -l` now empty) — disarms the formatting landmine where a targeted edit + an accidental `gofmt -w` swept ~46 unrelated files. Pure formatting (whitespace + optional-semicolon removal in reflowed inline closures); one doc comment reworded to avoid gofmt's Go-1.19 `''`→curly-quote doc-comment substitution. - Repo `CLAUDE.md`: corrected the stale "vacation — agent DOWN at a remote site" note — felhom-pve is back on the home LAN and the agent is up at `192.168.0.162:8443` (Tailscale alias still available). ### v0.169.1 — Disk-health card: exclude logical/network storage (2026-07-24) Live QA follow-up to v0.169.0: the agent defaults SMART to UNKNOWN on non-physical targets (PBS, LVM-thin), so they appeared in the "Lemezek állapota" card as spurious "Nincs adat" rows. `isPhysicalDisk` now excludes `pbs`/`lvmthin`/`nfs`/`cifs` by type (applies to both the card and the 6h check). Test strengthened: a PBS/LVM fixture carrying UNKNOWN SMART must still be excluded. ### v0.169.0 — Disk-health card + degradation notification ("Lemezek állapota") (2026-07-24) Consumes the agent's new `smart` payload field (agent **v0.94.0**); **MinAgent floor unchanged** — the feature detects by payload presence (nil → "Nincs adat", never alarms). Pairs with the hub allowlist bump (adds `disk_health_degraded`). No new smartctl load anywhere — the agent serializes already-computed SMART; the controller only reads it. - **`agentapi`:** `SmartSummary` extended to the full counter set (SATA reallocated/pending/offline + NVMe critical/media/percentage_used + power-on-hours); `DiskInfo` gains `Smart *SmartSummary`; new pure `DiskVerdictFor(*SmartSummary) DiskVerdict` (the SINGLE source of truth for card + check) with `Label()` (Rendben / Figyelmeztetés / Hiba / Nincs adat) + `DegradedAttributes`. Mapping: FAILING → Hiba; PASSED with any of reallocated>0 / pending>0 / offline_uncorrectable>0 / critical_warning>0 / media_errors>0 / percentage_used ≥ 90 → Figyelmeztetés; PASSED clean → Rendben; nil/UNKNOWN/empty → Nincs adat (never alarms). - **Dashboard "Lemezek állapota" card:** one row per PHYSICAL disk (label + colored verdict chip + temperature). Fed by a **60 s in-process TTL cache** around `/disks` so dashboard refresh-spam cannot smartctl-storm the host. An unreachable agent renders "Nincs adat" — the page never blocks. - **6-hourly `disk-health-check`:** compares each physical disk's verdict against an in-memory baseline and emits `disk_health_degraded` **only on a degradation** (verdict worsened). First run baselines silently; recovery/improvement notifies nothing; **UNKNOWN is excluded both directions** (a transient UNKNOWN blip never fires and never erases history); multiple attributes on one disk → ONE event. Severity: warn (Figyelmeztetés) / critical (Hiba). The hub applies its own per-event-type cooldown. - **Deliberately no global alert banner** (CONTEXT ruling) — the card + email carry it; banner fatigue is a real cost. Not wired into the dead-app/alert-banner machinery. Controller restart re-baselines silently (accepted, consistent with the health-change pattern). Tests: verdict table (+ ≥90 boundary red-proof); notifier emit (type/severity/subject); check first-run-silent (red-proof: disable the guard → first run notifies), degradation-once, recovery-silent, UNKNOWN-excluded, FAILING→critical, nil-smart card graceful, TTL cache. ### v0.168.0 — Customer-configurable backup window ("Mentési időablak") (2026-07-24) No agent coupling; MinAgent unchanged (the disk-tier gate is controller-side; the agent's cadence-based `/backup/due` is untouched). New pure package `internal/backupwindow`; touches scheduler, settings, quiesce, the backup page, and main.go wiring. **One setting drives every nightly leg.** A single customer control — **"Mentési időablak kezdete"** (default = the effective DB-dump time, historically "02:30") — from which every leg derives at FIXED, never-stored offsets, so misordering is impossible: DB dump at **W**, tier-2 mirror at **W+60m**, off-box at **W+105m** (wrap-safe across midnight). Precedence: settings > controller.yaml `db_dump_schedule` > "02:30". - **Scheduler seam `UpdateDaily(name, timeStr) bool`** (+ a per-daily-job buffered `resched` channel and a new select case in `runDailyJob`): a saved window fans out to all three legs and takes effect at the next scheduling pass **without a restart**. Unknown/non-daily name or invalid time → WARN + false, job untouched. - **Disk-tier (whole-guest PBS/vzdump) window gate.** The quiesce loop's scheduled cycles now run only inside **[W+2h, W+6h)** (wrap-safe, Europe/Budapest wall-clock), with a **safety valve**: if the newest successful backup is older than cadence+24h (or none exists), the cycle runs regardless of the window — a box powered on only outside its window never starves. Gate denials log at DEBUG with the window. **Manual triggers ("Mentés most" / `TriggerNow`) are NEVER gated** (they bypass `runOnce`). The `Backend.Due` seam now also returns the backup age (from the agent's own `/backup/due` answer) for the valve; the agent, its cadence, and `/backup/due` semantics are unchanged. - **Backup page (Áttekintés):** a compact "Mentési időablak" card — time input (value = effective window) + "Mentés" button, and the derived rows (adatbázis-mentés / helyi másolat / távoli mentés times, and the "teljes rendszermentés kb. W+2h–W+6h között" line). POST `/backups/window` validates → saves → `UpdateDaily`×3 → PRG redirect with a Hungarian flash. Behind RequireAuth + CsrfProtect like its siblings. - Derived leg/gate times are **computed, never persisted**; no per-leg settings; the offsets are not exposed in the UI. Tests (5 groups, all red-proofed): `LegTimes`/`GateWindow` incl. midnight wrap + invalid-rejected; `EffectiveWindow` precedence table; `UpdateDaily` mutate+signal + unknown/invalid + goroutine consumes the reschedule; `scheduledRunAllowed` truth table (inside/outside/valve/wrap/nil-age) + Loop integration (defer outside / run inside / valve runs / manual never gated); handler valid-save + invalid-rejected. ### v0.167.1 — Center the sidebar logo (2026-07-24) CSS one-liner + test. `.sidebar-logo` gains `margin: 0 auto` so the 140px logo is horizontally centered within the header instead of left-aligned — applies to both the desktop sidebar and the mobile drawer (same element). Pin: `TestSidebarLogo_Centered` (red-proof verified). ### v0.167.0 — Outlined logo + favicon (Part 4, the v0.166.0 gated follow-up) (2026-07-24) No agent coupling; MinAgent unchanged. Embedded-asset constants + one test only — no backend, no routes, no template/CSS behavior change. Completes Part 4 that v0.166.0 deferred at the §3a gate. Viktor pushed the text-outlined `website/assets/logo.svg` to felhom.eu `main` (`be9edb4`): the wordmark is now **17 real `` glyphs** (Inkscape Object→Path) instead of live `` with `font-family:'Vremena Grotesk'`/`'M+ 2c'`. Under `` secure static mode only locally-installed fonts resolve, so the old constants rendered the wordmark in a fallback font on every device without those fonts — now fixed. - **`FelhomLogoSVG`** body replaced with the outlined master. Inkscape left behind **2 empty `` shells + font-* style leftovers on the paths** (inert, but they carried the font names); these were stripped via a DOM pass (lxml) — **glyphs untouched, no text-to-path conversion done by CC**. Also dropped the editor-only ``. `viewBox` **unchanged** (`0 0 645.30703 408.36403`); full palette preserved (white glyphs `#ffffff`, blue `.eu` `#008ddf`, navy `#051343`, all 14 gradients, the cloud/house/server/lock artwork). - **`FelhomFaviconSVG`** vestigial empty `` nodes + their `font-family` removed (cloud icon only; `viewBox` unchanged `0 0 437.307 296.36403`, 11 paths). - Both constants now contain **zero `768px) is unchanged. **Mobile navigation rework (Option A — off-canvas drawer).** The `@media(max-width:768px)` block predated the v0.146.0 accordion: it flattened `.nav-links` into a horizontal `overflow-x` strip, and because the accordion's nested sub-lists share the `.nav-links` class, sub-items laid out horizontally inside an `overflow:hidden` grid row — everything past the first sub-item was clipped. The strip is **deleted** (not patched — Option C was rejected) and replaced by: - a sticky **top bar** (`.mobile-topbar`, logo → `/`, single hamburger `.nav-burger` with `aria-expanded`/`aria-controls="sidebar"`, new `#i-menu` icon); - the existing vertical sidebar reused as an **off-canvas left drawer** (`.js .sidebar`, `transform:translateX(-100%)`→`is-open`), a `.nav-backdrop`, body scroll-lock (`body.nav-open`); drawer JS toggles on burger, closes on backdrop click or Escape. **The accordion handler is untouched** — it works identically inside the drawer (all sub-items stack vertically, nothing clipped). - a **no-JS fallback**: `` (swapped to `js` by an early head script); when JS is off the sidebar renders static inline above the content and the burger is hidden, so no destination dead-ends. - z-index ladder: top bar 800 < backdrop 900 < drawer 950 < `.modal-overlay` 1000 (modals stay on top); `height:100dvh`; drawer transition disabled under `prefers-reduced-motion`. **Sidebar customer-name removed.** The `` and its dead CSS rule are gone from the sidebar header (logo only). `{{.CustomerName}}` stays in base data and on the **login page** subtitle (identifies the box owner). **Cache-bust on logo/favicon.** `/static/felhom-logo.svg` and `/static/favicon.svg` now carry `?v={{.Version}}` (sidebar logo, head favicon, login logo) — Cloudflare edge-caches `/static/*` for 4h, so unversioned asset URLs kept serving the previous release's copy after a deploy (the 0.126.1 CSS failure mode). `renderLogin` now passes `Version`. **Part 4 (outlined-logo swap) GATED OUT — not shipped.** The §3a precondition failed: live felhom.eu `main` (`be9edb44`) still serves a `website/assets/logo.svg` with live ``/`font-family` (the text-outlined master is Viktor's manual Inkscape push, still pending). The `FelhomLogoSVG` / `FelhomFaviconSVG` constants are therefore **unchanged** and still contain live `` — the wordmark renders in a fallback font under `` secure static mode until the outlined asset lands and Part 4 ships. Only the `?v=` cache-bust portion of the logo work is in this release. - Tests (5 new, all through the real layout/CSS): topbar+drawer markup, CSS strip-removed/drawer-present (scoped to the 768px block), sidebar-no-customer-name, login-customer-name-kept, versioned asset URLs. RED-proofs recorded pre-change (strip present, no drawer, customer-name present, no `?v=`, and the gated logo-constant proof). `nav_accordion_test.go` invariants pass unchanged. Focus-trap on the drawer deliberately omitted (page navigations reset state). Android drawer feel + desktop pixel-parity are Viktor's visual acceptance step. ### v0.165.1 — Native "Megosztás…" button in the share modal (Web Share API) (2026-07-24) No agent coupling; MinAgent unchanged. Template JS + tests only — no backend, no routes, no settings, no dependency changes. The "Indítópult megosztása" modal gains a **"Megosztás…"** button that opens the OS share sheet via `navigator.share` (Messenger / WhatsApp / email / anything installed), sending the share **title + text + URL only**. Feature-detected: the button is `display:none` in the markup and revealed only when `navigator.share` exists; the universal **"Link másolása"** stays as the fallback and is never demoted. A user cancel (`AbortError`) is silent; any other rejection falls back to `copyShareLink()` so the user still keeps the link on the clipboard. - **The QR is deliberately NOT attached** to the share payload (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 covers "send the picture" with zero code). - Share copy (user-to-user, deliberately conjugation-free): title `Indítópult — `, text "Az otthoni alkalmazások egy helyen.". - Tests: Group A (button hidden-by-default + feature-detect reveal + title/text/url-only payload, no `files:`) + Group B (AbortError-silent + non-abort fallback to copy); 2 red-proofs verified red. ### v0.165.0 — Indítópult megosztása: guest launcher via capability URL (2026-07-24) No agent coupling; MinAgent unchanged. New dependency: `github.com/skip2/go-qrcode` (v0.0.0-20200617195104-da1b6568686e, MIT, pure Go, zero transitive deps) for the modal QR code. The admin launcher gains an **"Indítópult megosztása"** button that mints a **capability URL** (`https:///s/`, 160-bit token) serving a standalone, read-only guest launcher — same tiles, opens apps in new tabs — with **no accounts and no admin session**. 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. - **Capability-URL serving.** `/s/` is added to the RequireAuth pre-auth allowlist (AFTER the claim-gate block, so the claim gate stays supreme) and exempted from session CSRF (guests carry their own pre-auth HMAC CSRF, like the claim POST). Token comparison is `subtle.ConstantTimeCompare`; an empty stored token matches nothing, so a wrong/disabled token is **byte-identical to the mux default 404** — nothing distinguishes it from an unknown route. Guest responses set `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`. - **Optional per-share password.** A SEPARATE credential — its own bcrypt hash (`settings.LauncherSharePasswordHash`, never the admin hash), its own per-IP 5/1-min attempt map (never the admin login map). Passing it once mints a signed cookie = HMAC-SHA256 over `token|passwordHash` (keyed with the persisted, box-scoped `web.session_secret`), so **rotating the token OR changing the password invalidates every outstanding cookie** with zero bookkeeping. - **Modal (admin):** copy-link, a QR code (`/launcher/share/qr.png`, ~256px, admin-authed), "Jelszó beállítása/törlése", "Új link készítése" (rotation), "Megosztás kikapcsolása". POSTs under `/launcher/share/*` ride the normal admin session + session CSRF. - **Guest state labels ride the v0.164.0 ruling:** `StateStopped` ⇒ "A tulajdonos leállította"; any other non-clickable state ⇒ "Átmenetileg nem elérhető"; guests never see internal state vocabulary (stopped/exited/degraded/unhealthy). Clickable ⇔ operational AND its public route is published (`isOperationalState && !routeUnpublished`), so a guest tap never dead-ends on a 404. - **Token is a secret:** never logged (the ServeHTTP debug line and the 404 WARN redact `/s/` paths to `/s/`), never written to CHANGELOG/REPORT/CONTEXT, constant-time comparison only. - **Refactors:** `launcherApps()` extracted from `launcherHandler` (shared with the guest handler); the tile visual extracted into a `launch_tile` partial (single markup source for admin + guest); `isOperationalState` promoted to a package predicate (single source for the funcmap + guest rule). - New files: `internal/web/share.go` (pure core), `internal/web/share_handlers.go` (HTTP surface), `internal/web/share_test.go` (Groups A–G + 3 red-proofs verified red), templates `launcher_shared.html` + `launcher_share_password.html`. - Design rulings (CONTEXT): member accounts are superseded by this capability-URL model; per-member tile visibility is parked under the SSO arc. ### v0.164.0 — Deliberately stopped apps no longer alarm (banner + email) (2026-07-24) No agent coupling; MinAgent unchanged. Operator finding on 9201: stopping an app via the UI (Leállítás) raised the global warning banner "Telepített alkalmazás nem fut: … (stopped)" on every page — including the launcher, where the tile already shows the greyed state — and fired the `app_start_failed` notification event on the running→down transition. A deliberate user action is not a fault; it must not alarm the user anywhere. Genuine faults keep alerting exactly as before. - **The fix is a one-line filter at the single fix-3 derivation point.** `scanDeployedAppRunStates` (cmd/controller/main.go) is the only place both the banner dead-list and the notifier Down-set are computed. Its pure core was extracted to `classifyRunStates([]stacks.Stack)` (testable without a live Manager), and the down predicate changed from `stacks.IsDownState(st.State)` to `stacks.IsDownState(st.State) && st.State != stacks.StateStopped`. `StateStopped` is therefore suppressed from BOTH surfaces: no banner on any page (launcher included) and `Down=false` fed to the notifier ⇒ no `app_start_failed` event and a clean transition tracker. - **Why `StateStopped` ⇒ deliberate (two invariants, recorded at the seam and in CONTEXT.md):** (I1) the UI stop path `Manager.StopStack` runs `docker compose down` → containers are removed, and 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 crashing app never comes to rest at `stopped` — faults surface as `restarting` / `unhealthy` / `exited` / `degraded`. **If either invariant changes, revisit this suppression.** An out-of-band `docker compose stop` leaves containers present → `StateExited` → still alerts (out-of-band tampering is reportable — acceptable). - **`IsDownState` deliberately UNCHANGED** — other callers (e.g. `CommittedMemory`, bootrecon) rely on stopped counting as down. The suppression lives ONLY at the scan; no template, funcmap, notifier, dashboard-counter, or Hungarian-copy change. The launcher tile still shows greyed + "Leállítva"; the monitoring page and dashboard RunningCount/StoppedCount are unchanged (factual display is not an alarm). A pre-existing banner self-clears on the next health cycle (state-based). - **Tests +4** (notify 3→4, main 4→7): Group A — `classifyRunStates` over [running, stopped, exited, degraded] yields dead={exited,degraded} and Down flags {false,false,true,true} (red-proof: revert the filter → both assertions fail, verified). Group B — fault parity: exited+degraded both in the dead list, both Down=true, raw state string carried through. Group C — stop→start→crash drives `NotifyAppStartFailures` to exactly ONE event for the crash and zero for the stop (red-proof: mark the stop Down=true → the zero-for-stop assertion fails, verified). Plus a skip test for deploying/undeployed. ### v0.163.1 — Launcher polish: monogram reveal-on-failure + placeholder on every icon surface (2026-07-24) No agent coupling; MinAgent unchanged. Two live findings from the v0.163.0 operator browser pass on 9201. - **Monogram bled through every tile.** The launcher rendered `.launch-mono` unconditionally UNDER the logo ``; app logos are white monochrome SVGs with transparent backgrounds, so the big white letter showed through the glyph gaps on EVERY tile. The monogram is now hidden by default (`.launch-mono { display: none }`) and revealed ONLY when the img chain fails — the final `onerror` step adds `.launch-tile--noimg` to the tile, which flips the monogram back on. Applies to both the operational `` and the stopped `
` 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 `
` blocks per app row: verify, prepare, the revealed size-gated commit, the missing-only merge, and the true reconstitution. Two of them sat next to each other as sibling buttons — - „Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" — additive; **cannot** bring deleted content back, and - „Teljes visszaállítás (fájlok + adatbázis)" — the real restore — and the difference between them is whether the customer's data comes back at all. This is not theoretical: it caused the round-2 incident. An operator who had *read the source* pressed the missing-only button, and the controller log shows `/backup/offbox/reconstitute` was never hit (`felhom.eu/documentation/audits/DIAG-immich-restore-round2-2026-07-19.md`, finding 1). The second half of the trap was that the decisive „Teljes visszaállítás indítása" appeared **only after** „…előkészítése" had been pressed, with nothing signposting that a second step existed or that the first one had done nothing to live data. **The rule this establishes,** worth stating once and applying past this page: *two adjacent controls whose difference is "your data comes back" vs "your data cannot come back" must never be distinguishable only by layout.* **The change.** Each app row on `/backups/restore` now carries exactly **one** control — „Visszaállítás…" — linking to a per-app wizard at `GET /backups/restore/app?name=`, built on the `backups_escrow.html` precedent: - **Three intent CARDS**, each with its own consequence sentence rather than a label alone: ellenőrzés külön mappába (live data untouched) · hiányzó fájlok visszahozása (additive, no database, deleted content does not reappear) · teljes visszaállítás (files + database, danger styling, the R-43 double-confirm carried over **verbatim** with its pair-honesty facts). - **A visible phase strip** — Előkészítés · Megerősítés · Végrehajtás · Eredmény — so the sequence is legible before the first click instead of after it. - **Server-derived steps.** `deriveWizardStep` is a pure function of (op running, size-gate flash, scratch ready); the step is never accepted from the request. Precedence is strict: a running op outranks a stale `?full_prep=` in the URL, so no commit button can reappear mid-restore. - **Mutation forms are suppressed server-side while any op runs** — the backup manager's single-flight is process-wide, so a restore for app X now suppresses app Y's controls instead of offering a button guaranteed to 409. - **No JavaScript requirement.** Every step is a real form POST and the server renders the next one. **Redirect retargeting.** The app-scoped `/backup/offbox/{restore,place,reconstitute}` outcomes now land back on the wizard the customer acted from rather than on the list. Fixing that surfaced a latent bug in `offboxRedirectTo`, which hardcoded `"?"` when appending the flash — against a target that already carries a query (`?name=`) that would have buried the flash inside the `name` value. The separator is now chosen. **Deliberately NOT in scope:** the shares (`_shares`) entry, the local restore panel and the .fab block are untouched; the R-45 job registry is still its own item — the wizard polls the two existing status surfaces as-is. ### v0.153.0 — the database replay no longer races the application, on BOTH restore paths (2026-07-20) Closes **R-47**. **No new agent coupling — MinAgent stays 0.90.0.** Nothing in this release talks to the host agent; the whole change is inside the controller's own compose orchestration. **The defect, measured to the second.** On 2026-07-19 the offsite reconstitution was run deliberately and correctly (`felhom.eu/documentation/audits/DIAG-immich-restore-round2-2026-07-19.md`, finding **H4**). It executed its designed sequence — safety dump, stop, start, replay — and the replay aborted: ``` 10:58:25 controller: replaying DB dump into immich-postgres 10:58:33 immich-server: "Reindexing clip_index" -> "Reindexed clip_index" <- the app recreates it 10:58:35 controller: ERROR relation "clip_index" already exists - exit status 3 ``` The replay needs a running database container, so the code started the WHOLE stack first. That gave immich-server an eight-second window in which to rebuild the very schema objects the dump was about to create, and under `ON_ERROR_STOP=1` the collision aborted the script. The photos came back anyway **by accident**: `pg_dump` emits COPY data before CREATE INDEX, so the abort landed after the rows. A collision earlier in the script would have left a genuinely half-restored database and reported it identically. The operation reported failure and immich then reported schema drift. **The fix: a DB-only window.** After the files are placed, only the stack's database service(s) come up; the dump is replayed into them with the application still stopped; the rest of the stack starts only once the replay has exited 0. Nothing about the replay itself changed — `--clean --if-exists` and `ON_ERROR_STOP=1` were always correct. The bug was the window, not the flags. **This was a class defect and both paths carried it.** The local `RestoreFromRecoveryUnit` had the same start-then-replay shape, hidden inside `RecreateStackFromUnit` (which ended in a full `compose up -d`). Fixing only the offsite path would have left the identical race one button away. Both are re-sequenced here. **What changed** - `appbackup.DBServiceNames(composePath)` names the compose SERVICE(s) whose `image:` identifies a database — `docker compose up -d` takes service names, not container names. It is a yaml.v3 `services:` map parse, deliberately not a line scan: immich's real template carries top-level `immich_ml_cache:` and `immich_postgres_data:` volume keys that sit at exactly the indentation a service name does. - The image heuristic that `DiscoverDatabases` had inline is extracted to `dbTypeForImage` and shared by both. That sharing is what makes the safety argument hold: a `.sql` dump can only exist because discovery matched the running container's image, and the compose `image:` value IS that image string — so "a dump exists" and "a service can be named" are answered by one predicate. - `stacks.Manager.StartStackServices(name, services)` runs the scoped `up -d`. It **refuses an empty service list**: an argument-less `up -d` is a full start, which is precisely the behaviour the window exists to avoid, and a silent fall-through would have reintroduced the race at the one call site that most needs it not to. - `RedeployFromEnv` is split. Its persist half is now `PersistUnitRedeployConfig` (app.yaml, locked fields, in-memory flags — starting nothing); `RedeployFromEnv` is that plus its unchanged up-and-report tail, so its public behaviour is byte-identical. The split is what lets the restore path put the DB-only window between persisting the definition and starting the app. - `StackDataProvider.RecreateStackFromUnit` becomes `RecreateStackDefinitionFromUnit` (files + persist, no start), and gains `StartStackServices`. The rename is deliberate: the old name promised less than the method did, and the hidden `up -d` inside it is what carried the defect on the local path. **Fail-closed, both paths.** If a `.sql` dump exists but no database service can be identified in the compose, the restore **refuses before the first mutation** — no stop, no file overwrite, no volume restore. The alternative would be to start everything and replay into the race. Given the shared predicate this should be structurally unreachable; it is the belt for template drift, not an expected path. **Every exit from the window still starts the app.** A failed replay, or a failed DB-only start, is surfaced as before — but a best-effort full `StartStack` runs first. The DB-only state is a deliberate half-started one, and leaving a customer with a running database and no application would turn a failed restore into an outage. **Tests.** 19 new (Groups A–G): ordering plus **state-at-replay-time** on both paths (a recording provider captures whether the full stack was up at the moment the import fired — asserting "no error" would have passed on the pre-fix shape, which is how this shipped), the no-DB negatives, the zero-mutation fail-closed effects, the replay-failure bring-up, the compose-parser decoys built from the catalog's real immich template, and the empty-list refusal. Three companion red-proofs run and reverted: the pre-fix full start on the offsite path, the pre-fix full start on the local path, and deletion of both fail-closed gates — each failing on the intended assertion. 23/23 packages green. **Live-validated on the demo box, 2026-07-20 (operator present).** Endpoint-level, against the SAME snapshot (`49e7cb46`) that aborted in round 2: ``` 15:39:42 [stacks] Stopping stack: immich 15:39:43 [stacks] Starting stack immich services only: [immich-postgres] 15:39:43 [backup] Restore immich: replaying DB dump into immich-postgres (postgres) 15:40:03 [backup] Restore immich: replayed 1 DB dump(s) <- rc-0, no "already exists" 15:40:03 [stacks] Starting stack: immich 15:40:27 [offbox] reconstituted immich: 6 file(s) placed, 1 DB dump(s) replayed, skewed=false ``` The operation reported **success** (round 2 reported failure); immich's own DatabaseService logged **`No schema drift detected`** twice, where round 2 left it reporting drift; 11 assets `active`, all four containers healthy, 231 `public` indexes. Details in `REPORT.md` §4b. **Golden 0.153.0 baked + published the same day** (`build-golden.sh` v2.1.0, from the vacation site after a registry-reachability probe). First golden carrying **all four** infra images — the list came from `--print-infra-images` on the 0.153.0 binary itself, so the historical 3-image fallback never fired and `felhom-samba:1.1.0` is baked. Upload 201, anonymous GET byte-matches, ranged 206. ``` GOLDEN_VERSION=0.153.0 GOLDEN_SHA256=15fdd191f3c660a60dc8651111053dd84281aeebc6c4c0f9ecdd3a87cb45a9d0 ``` **Still outstanding:** the two password-gated hub saves (Day-0 manifest Golden → 0.153.0, then floor → v0.153.0 **last**; Agent 0.90.1 / MinAgent 0.90.0 unchanged), Viktor's C6 customer-restore UI run, and the immich timeline screenshot — all of which need the operator UI or a browser. ### v0.152.0 — Megosztás on a Mac: mDNS in the image, and the page stops giving Mac users a dead form (2026-07-20) Closes **S-3** of `felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md`, and fixes a copy defect v0.151.0 shipped the same day. **Pairs with felhom-samba 1.1.0** — the pin in `infra.SambaImage` moves with it, so `Images()` and the golden bake follow automatically. **The finding that redirected the fix — macOS asks, gets a correct answer, and ignores it.** The first theory was that modern macOS no longer does NetBIOS. A packet capture on the box disproved that: on a bare `smb://FELHOM` the Mac broadcasts a well-formed NBNS query for `FELHOM<20>` (the File Server Service suffix — exactly right for SMB), and nmbd answers in 140 microseconds with a textbook positive response — flags `0x8580` (response, authoritative, RCODE=0), ANCOUNT 1, unique B-node, the correct address. **macOS never opens a TCP connection.** Sixteen seconds later the same Mac connected through `smb://FELHOM.local` on the first try. NetBIOS on macOS feeds legacy browsing, not `smb://` URL resolution — so no change on our side can ever make the bare name work there, and nmbd is not the thing that was broken. (nmbd answers twice per broadcast, because it holds `0.0.0.0:137`, `:137` and `:137` and a broadcast lands on two of them. Standard Samba; investigated and dismissed — a duplicated correct answer is still a correct answer.) **felhom-samba 1.1.0 — avahi + dbus, so the Mac has a mechanism at all.** The image's discovery set was Windows-only: nmbd for flat-name resolution, wsdd for Explorer's Network view, and nothing whatsoever for Bonjour. It now runs avahi, with `avahi-daemon.conf` and an `_smb._tcp` service file **templated from `FELHOM_SERVER_NAME` in the entrypoint** — renaming the server in the UI re-advertises under the new name, where a baked name would leave the box answering to something the customer can no longer see anywhere. A static service file rather than smbd's own `multicast dns register`: it needs no line in `smb.conf` (bind-mounted READ-ONLY, owned by the controller's renderer) and it lets us publish `_device-info._tcp` for a sensible Finder icon. Both new daemons are non-fatal on failure — sharing over an address must not become an outage because a discovery daemon did not come up. Proven live from the operator's Mac before the image was built, then the built image smoke-tested with all five daemons up and avahi registered as `.local`. **The page no longer tells Mac users to do the one thing that cannot work.** v0.151.0's connect card offered `smb://` for Mac. That is precisely the dead form. It is now `smb://.local`; the Windows line stays the flat `\\`, which nmbd serves correctly and which this release must not disturb. Red-proofed: reverting the template to the bare name turns `TestSharingConnectCard_MacLineIsDotLocalNotBareName` red on both the missing `.local` and the present bare form, for two different configured names — and the same test asserts the Windows line neither disappears nor wrongly gains `.local`. **NOT claimed: automatic Finder-sidebar discovery.** The `_smb._tcp` record is published and answers browse queries on the wire, but the test Mac's sidebar stayed empty — it had no Network/Bonjour section shown at all, which is a Finder Settings toggle rather than something the box controls. This is recorded as OPEN in the DIAG, deliberately not as a shipped feature. **Two test bugs surfaced and fixed, neither a production defect.** `TestRenderSambaCompose` asserted the literal tag `felhom-samba:1.0.0`, so a routine image bump read as a renderer regression; it now derives from `SambaImage` and separately asserts what actually matters — that the tag is explicit and never `:latest`. And `TestFabUpload_GCAndIdleTimeout` raced: `expireIdleUpload` nils the slot, releases the mutex, and only then closes and unlinks the `.part`, so "the slot is free" does not yet mean "the file is gone" — the test stat-ed immediately and passed only by luck. It failed in the full package while passing in isolation once this release's new render tests made the `web` package heavier. Now it waits for the outcome it asserts, on the same 3 s deadline; red-proofed by removing the unlink from production, which still fails it. ### v0.151.0 — the Megosztás page stops reloading, and says how to connect (2026-07-20) Closes **S-1**, **S-2**, **S-5** and the core of **S-4** from `felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md`. **S-1 — `/sharing` reload-looped about once a second, for every customer with sharing enabled.** `GET /sharing/status` carries two things that mean different things to the client: `phase` (the ensure JOB — the page answers a terminal `running` with a one-shot `location.reload()`, because the „Állapot" badge is server-rendered) and `running` (the service LEVEL, straight from the liveness probe). v0.147.0 coerced `idle`→`running` on the PHASE channel so that a missing job could never contradict a live container. That duty was real, but it belongs to — and was already discharged by — the `running` field beside it; on the phase channel the same value reads as a fresh success edge. The poll's `tick()` runs synchronously at script end, so the FIRST poll of every steady-state page load reported a terminal job that had never run, scheduled a reload 1.2s later, and the new page did it again. The coercion is gone: no job, no edge. The defensive intent it was written for is now pinned by its own named regression test on the `running` field. **S-4 (core) — a REAL bring-up is now reported exactly once.** Without this the loop would return after every future image update: the finished job outlives the reload it triggered, so the next page load found `phase:"running"` waiting for it. `consumeIfRunning` serves a terminal `running` once and clears it — and only while the single-flight slot is free, since the job goroutine sets the phase before its deferred `release()` and eating it in that window would lose the success the customer is waiting on. `failed` and `needs_password` stay sticky (their client path stops the timer and shows a card with NO reload, so stickiness is informative and cannot loop), and in-flight phases are never consumed. Accepted cost, stated rather than hidden: with two tabs open during a bring-up only the first gets the success banner — both still show the true state, which comes from the level channel. The unified async-job feedback layer remains the ROADMAP item; this is the minimal contract fix. **S-2 + S-5 — the page now names both ways in.** It had only ever shown the configured NetBIOS name, so a customer whose network fails to resolve it had no fallback but a guess — and the guess that produced the diagnosis was the Proxmox HOST's address, which never ran smbd. New „Csatlakozás a megosztáshoz" card: the Windows form, the Mac form, and the direct `smb://`. The address comes from `stacks.SambaLANAddress()`, which reads the guest's netns through the SAMBA container (`network_mode: host`) — the controller is on a docker bridge and would answer `172.x`, the same trap `setup.DetectLocalIPs` needs `HOST_IP` for. Reading it there also makes it the right kind of true: it is the address smbd is bound to, not merely one the box owns. **Derived per render and cached nowhere** — the guest holds it by DHCP, so a stored copy eventually misdirects people (S-5) — and an underivable address omits the line, because no address beats a wrong address. `sharing.html`'s `