## v0.198.0 — the four steps a customer would have hit alone: two of them closed (2026-08-05, R-204 items 1 & 3) The 2026-08-04 recovery drill (R-201) passed — and it only passed because a person was there. Four manual interventions stood between "the key is recoverable" and "the file is back". None of them is in any design document. Two of the three defects are in this repo. ### Item 1 — a freshly minted reset code now works on the first attempt `--print-reset-code` runs as a **separate process** (`docker exec`): it loads settings itself, mints a code, persists it and exits. The running server's cache was never told, so it kept validating against the previous hash. **The code the customer was told to type was refused until the controller restarted, and nothing said so.** During the drill that cost two failed attempts with an operator present; a customer alone stops there. `effectiveClaimCode` now READS THROUGH to the persisted state (`settings.ReloadClaimCode`) before applying the settings-vs-config precedence. **The precedence rule is unchanged and deliberate** — the defect was the freshness of the settings value, not which source wins. - **Read-through, not a watcher, a signal handler or a TTL.** A TTL is worse than the bug being fixed: it opens a window in which a SUPERSEDED code still works. That is the mutation `TestClaimCode_SupersededByASecondMint_RefusedImmediately` exists to kill, and its red-proof is exactly that TTL — demonstrated failing with "the SUPERSEDED code was accepted". - **Fail closed.** An unreadable persisted state keeps the gate up, refuses the claim and logs why. An ABSENT file is not an error (a box before its first save falls back to the controller.yaml bake). - Cost: one small file read per request **only while the box carries no password** — `claimGateActive` returns on `authEnabled()` before touching it, so a claimed box never reads. ### Item 3 — a restore now says what it did NOT restore The default restore (`mode=unit`) recovers the recovery unit: the app's definition, its configuration and its database dumps. It does **not** recover the customer's own files — `RestoreOffboxScratch` passes `--include `, and the userdata that is in the same snapshot is excluded by it. The old outcome was one sentence for both modes and named neither scope, so on the last step of a disaster recovery the customer was told „visszaállítva" after the thing they were looking for had not been. - `restoreScratchOutcomeMsg` (pure, unit-testable) now states, for a unit restore: what came back, that the customer's own files did NOT, and the next step that gets them. The full case says the files came with it — otherwise the absence of the warning would be the only difference, and an absence is not a statement. - The wizard's intent card 1 states its scope **before** the choice, not only in the outcome. - **The full-restore size gate is untouched** — still compute, reveal, confirm, re-check at execution. Pinned by `TestOffboxRestore_FullPathUnchanged`, which asserts no restore runs before the confirm. - **The default stays `unit`.** All three wizard forms set `mode` explicitly, so the `mode==""` fallback is reachable only by a hand-crafted POST: changing it would alter nothing the customer sees while silently changing that POST's behaviour. The defect was silence, and silence is what was fixed. `TestOffboxRestore_DefaultModeGetsTheScopedOutcome` pins the mode-less POST to the scoped wording. Item 2 of R-204 (a re-issue marking a healthy escrow stale) is the hub's half — felhom.eu v0.95.0. Item 4 (a rebuilt box cannot obtain an off-site credential unaided) is R-193 and remains open. ## v0.197.0 — the app and its backup look in the same place, and "ok" means it (2026-08-04, R-203) Found when the R-201 drill halted at its pre-wipe backup rather than wiping a machine: the run reported `ok` with three snapshots and the sentinel file was in none of them. ### Part 1 — one resolver, five callers `appbackup`'s path helpers take a **namespace root**; five call sites passed a bare **drive** path. On an enrolled drive the two coincide — which is why this survived. On the system-data fallback (a **supported, named** arrangement: *"the SSD-only system-data fallback"*, `paths.go:26`) they differ by exactly the `felhom-data` segment, so the app bound `/mnt/sys_drive/userdata/media/books` while the off-site capture set looked for `/mnt/sys_drive/felhom-data/userdata/media/books`. **The rule now has ONE expression** — `appbackup.NamespaceRootFor` / `IsEnrolledDrive`. There were already **two** copies and they differed: `backup.Manager.namespaceRoot` compared without `filepath.Clean`, `stacks.Manager.inGuest` with it, so a trailing slash from config would have flipped the mode in one package and not the other. Both now delegate. Routed through it: `stacks/deploy.go` `withPathVars` → `${USERDATA_PATH}` (the live defect); `appexport/fabplan.go` + `export.go` (via a new `GetStackNamespaceRoot` provider method); `web/handlers.go` FileBrowser mounts (**latent** — the system drive is deliberately never a registered `StoragePath`, so it is the identity today); and `stacks/delete.go`'s `ExportDataMounts`. `ComputeFabBuckets` now receives the namespace root, which is what `ComputeCaptureSet` has always received — so the export's classified paths and the backup's capture set describe the same directories by construction rather than by coincidence. **The census found FIVE sites, not the four the spec named.** The fifth is the FileBrowser mount builder — the customer's own file browser would have shown the wrong directory on a non-enrolled path. **`ExportDataMounts` lives in `delete.go` and is NOT a delete path.** Its only production caller is the `.fab` export adapter; nothing deletes on its result. The delete path's own guard, `ProtectedHDDPaths`, is layout-agnostic by construction — it protects **both** `/…` and `/felhom-data/…` — so deletion was never affected. That note is now in the function's doc comment, and the change shipped as its own commit anyway. ### Part 2 — a run that missed a mandatory directory is not a successful run The gap was already **detected**, and warned about, in Hungarian, naming the app and the folders — that warning is what stopped the drill. The defect was that the run still reported **`ok`** beside it, and a warning standing beside a success is read as a success. `last_status` gains **`incomplete`**: minted, because the existing vocabulary (`ok` | `error` | `running`) had nothing meaning *"it ran, and this app is not fully protected"*. **Not `error`** — the rest of the run worked and the data captured is real, so `SnapshotCount` and the `LastSuccess` anchor still record it. Half a backup is not no backup, and reporting it as none would be its own lie. It reaches the **operator** via the existing per-run digest (`backup_run_failures`), not only the page: a new event type would be a two-repo change and the hub drops anything outside `allowedEventTypes`. The Hungarian customer warning is unchanged. The stat-filter gains the `ClassMandatory` check Tier 2 already had (*"optional-missing is silent"*). **It is a no-op today** — `TierOffsite`'s `tierKeeps()` admits mandatory only — so **no customer-visible warning disappears**. Demonstrated: widening the tier filter alone keeps the tests green *because of this check*; widening it and removing the check makes an optional gap start reporting. ### Anticipated live effect **`calibre-web` on demo-hp has exactly this gap.** Its off-site status becomes `incomplete` and the operator digest fires. That is correct — it genuinely is not fully backed up — and it is the fix telling the truth for the first time, not a regression. ## v0.196.0 — the recovered key installs itself (2026-08-04, R-200 plumbing half) — MinAgent 0.125.0 `--recover-offsite-install` is the sibling of `--recover-offsite-check`: same fetch → unseal → extract through the agent, same STDIN discipline for R, but it **places** the recovered repository password via `InjectOffboxPassword` so a rebuilt box reopens the off-site history it inherited. **Why this is code and not a manual step.** The alternative is recovering the password, reading it off a terminal and pasting it into the injection endpoint by hand — which puts the offsite DATA key through a human's screen, clipboard and shell history. In-process, the value goes agent → this process → the 0600 file and is rendered nowhere. **The confirmation is a second invocation, on purpose.** Without `--confirm-install` it prints both hashes and writes nothing, so the operator sees the comparison before any write is possible. A single interactive prompt would have had to share stdin with R. **Three outcomes, named distinctly**, because "it did nothing" and "it refused" are different facts: **installed** (no local password — the rebuilt-box shape), **unchanged** (identical key already present, nothing written), **refused** (a DIFFERENT key present — installing would clobber the key the current repository is encrypted under, and which history to keep is not this command's decision; no force option is offered). Exit `2` for the refusal, distinct from `1` for a step that failed. The install re-reads the file afterwards rather than trusting the write — the observable is the file's state, not the call's return. **Red-proof observed:** removing the confirmation gate makes the dry run write the password and fails `TestRecoverAndInstall_InstallsOnABareBox`. The R-persistence test carries a **positive control** — a planted copy of the code is found by the sweep, then removed and not found — because an absence check is worth only what its sensitivity is. **Nothing customer-facing:** no page, no card, no form. `ensureOffboxRepo` and the orphan classifier are untouched. ## v0.195.0 — prove the offsite key comes back (2026-08-04, R-200 plumbing half) — MinAgent 0.125.0 **The question, answered for the first time: is the offsite repository password actually recoverable from the hub's sealed bundle?** Yesterday's hub v0.93.0 made the key SURVIVE a re-ceremony. Nothing handed it back. The chain's last stretch had no client at link 6, a `--selftest`-only caller at link 7, and nothing at all at link 8. **`--recover-offsite-check`** — a `docker exec` diagnostic in the shape of `--print-reset-code`: it reads the customer's recovery code from **STDIN**, asks the agent to fetch this host's sealed bundle and open it (agent ≥ v0.125.0, `POST /escrow/recover-offsite-password`), and reports whether the recovered key matches the one on disk — **by sha256**. It prints two hashes and a verdict. Never a password, never the recovery code, never a blob. docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /root/r.txt **R comes from stdin and not a flag** because a flag value is visible in `ps`, in shell history, in a container's command line and in any transcript of the session that ran it — and R is the one secret in this system that cannot be rotated, re-issued or recovered. **IT COMPARES; IT DOES NOT INSTALL.** The recovered password is never written to `offbox/repo_password`. Comparing proves recoverability; installing changes a live box on a path nobody has walked, and "the existing repository opens under a recovered key" is a separate link with a drill around it. A test asserts the on-disk password and the whole data dir are byte-unchanged after a check, and its red-proof — adding the install call — fails it. **Exit codes are load-bearing:** `0` match, `2` a clean MISMATCH, `1` a step failed. "It failed" and "it worked and disagreed" must never share a status, because only one of them is a finding about the system rather than about the run. A box with no local password is reported distinctly too — that is the rebuilt-box shape, where the next step is to install rather than to compare, and reading it as a mismatch would be wrong. **Deliberately NOT in this release: anything a customer can reach.** No card, no form, no preview, no wizard. Building an interface on top of a chain nobody has walked is how the preceding three weeks went wrong; the interface comes next, on proven ground, and so does the wipe-and-restore drill. ## Changelog ### v0.194.0 — one operator e-mail per backup run, and nothing dropped without a trace (2026-08-03, R-182) — MinAgent: none **The defect, measured rather than supposed.** On 2026-08-03 nine per-app `recovery_unit_capture_failed` events reached the hub and **two operator e-mails went out**. The hub's operator cooldown key is `customerID + ":" + eventType + tier-suffix`, and that event carries `app` but **no `tier`** — so the key held no app identifier. The first refused app took the hour's slot and **every other app's failure was discarded before anything was written down**, leaving no row on any channel. A machine deciding not to tell you and nothing happening at all looked identical. **The obvious fix was ruled against, and the reason is worth keeping.** Putting `app` into the key fixes the swallowing by producing **one e-mail per failing app**, which on a full disk is a dozen — the volume problem wearing the correctness problem's clothes. **What ships instead: ONE digest per run, and every failure recorded when it happens.** - **`internal/backup/runsummary.go`** — a per-run collector with exactly `admissionSet`'s lifetime (created where the run begins, cleared when it ends), fed by all three write legs. It emits `backup_run_failures` once at the end, **only when something failed**. A clean run emits nothing — not an empty digest. - **The per-app event stays and becomes the RECORD.** The hub now routes it *record-only*: stored and written to the notification log every time, never competing for an e-mail slot. The record and the notification are now different things, which is the durable half of this change. - **Deliberate skips are not failures.** A disconnected or decommissioned drive has its own alert and is excluded, because a nightly e-mail about an unplugged drive is one the operator learns to ignore. - **A manual run always reports**, even if the nightly one already wrote that hour: the digest carries a unique `run_id` that the hub's cooldown cannot collapse. Someone pressing the button is actively trying to get a backup. **THE PERIODIC SWEEP GETS A DIGEST TOO, and that is not symmetry for its own sake.** `GetFullStatus` captures units outside any run. With the per-app event now record-only, a capture failure found between runs would have been recorded and **never notified** — a new silence introduced while closing one. So that path emits a digest as well, deliberately with **no `run_id`**, so the ordinary 1-hour cooldown caps it exactly as before while the mail now lists *every* failing app instead of whichever one happened to be first. **A refusal is recorded ONCE, where the verdict is taken**, not at each of the three legs that consult it — R-181's contract is that one verdict covers all three. Noting it per leg listed a single refused app three times and produced counts like *"2 of 1 apps failed"*. **Found by the digest's own test, not in review.** **Why silence is safe, checked rather than assumed.** A digest is only safe if the absence of a mail cannot mean "the run never finished". It cannot: the hub's daily deadline check raises `expected_backup_missed` / `expected_dbdump_missed` (`hub/internal/monitor/deadline.go:396,417`) from the box's **report freshness and stored events**, independently of any mail this box chooses to send. **Tests: 7 new, plus 4 red-proofs demonstrated failing then restored.** One of them — the `main.go` seam walk — **did not fail on its first attempt**, because the AST test walked the backup package and not `main.go`; the test was fixed and the mutation re-run rather than the pass being recorded. ### v0.193.1 — the refusal's size estimate is rendered in bytes, not `0.00 GiB` (2026-08-03, R-181 follow-on) **Found by the live proof run for v0.193.0, not by review.** The refusal message printed the estimate fixed to two decimal GiB, so **every app under ~10 MB rendered as `estimated 0.00 GiB write`** — which reads as *"no estimate was available"* and is the exact opposite of what happened. Observed live on demo-hp at 08:59:46: opengist's real **178 KB** estimate printed as `0.00 GiB`. Shipped in the same session it was found because it is the same defect class the whole of R-181 is about — a message that an operator cannot rely on is worse than no message. **The arithmetic is unchanged and still in GiB** (the reserve's own unit, so the comparison against `FloorFreeGiB` reads directly); only the rendering moved to `humanizeBytes`. `estimatedWriteGiB` → `estimatedWriteBytes`, with the GiB conversion done once at the point of comparison. Verified live after redeploy: the same refusal now reads `estimated 178.0 KB write`. ### v0.193.0 — the reserve guards the write that fills the disk, and its promise is true (2026-08-03, R-181) — MinAgent: none **The defect, found on live hardware and not by review.** v0.192.0's capture floor (B2) shipped as the deliberate replacement for the bulkhead the `mp1` partition used to give, and it was consulted in **exactly one place** — `captureAllRecoveryUnits`, which writes a manifest and three compose files: a few KB. The two legs that write the **bulk** into the same `backups/primary/` tree — the database dump and the volume dump — ran **first** and **unguarded**. Measured on demo-hp 2026-08-03 06:40:03: opengist's volume dump wrote **2.0 GB with no check**, free fell to 1.0 GB, and the floor then refused the cheap write it had already lost the argument to. **Second limb: the refusal message asserted something the code did not provide.** It printed *"the previous unit is untouched and NOTHING was deleted"*. *Nothing was deleted* held. *Untouched* was **measured false** — that app's tar had gone 182,272 B → 2,147,666,432 B under a `manifest.json` whose `created_at` and `checksums` had not moved. **This is the sixth entry in `CLAUDE.md`'s table of shipped guarantees the code did not provide**, and the fourth of those found on live hardware. **The fix: ONE admission verdict per app per run, taken before that app's FIRST write, covering all three legs** (`internal/backup/admission.go`). The three write under one per-app root, which is exactly why one verdict can honestly cover them — and why the message may now claim what it claims. - **Decided lazily, at the app's first write — NOT once at the start of the run.** Space changes during a run: app A's 2 GB dump can put app B under the reserve, and a run-start verdict would wave B through on a reading that was true before the disk filled. That is the same class of mistake, moved one level up. - **Remembered for the run, never re-decided between an app's own legs.** Re-deciding reintroduces the split this closes (DB admitted → volume admitted → capture refused, with the bulk written). Reset per run: a set carried between runs answers tonight's question with last night's disk. - **Placed ahead of `DumpAppVolumesSafe`, which stops the stack as its first act** — a refusal decided inside it would already have bounced the app it is refusing to back up. It sits *after* the volume-less check, because an app with no named volumes has no first write in that leg to gate. - **Exactly one operator alert per refused app per run.** Three legs must not mean three emails. - **The leg order is unchanged** — volume dumps still precede the capture so the manifests enumerate the fresh tars (`backup.go`'s load-bearing comment). **The floor is now SIZE-AWARE, not merely headroom-aware.** It asks *would this app's write leave the filesystem below the reserve?*, not only *is it below the reserve now* — which is how an app was admitted at 96% used and then allowed to write 2 GB. The estimate is the app's **previous** `.sql` and `.tar` already on disk: free to read, and the next write is usually close. **No history → headroom-only**, deliberately, or the first backup would be the one that can never happen; the alert says so when that applies. Both post-write terms are evaluated, because a large write crosses the percentage bound on a small volume and the free-byte bound on a large one. **A container-based `du` per volume was measured and REJECTED, not assumed.** 66 timed runs on demo-hp's guest 9201: **median ~355 ms per volume** (341–404 ms) on volumes holding tens of KB — the cost is container start-up, not the walk, so it does not shrink for small apps and only grows for real ones. Decisive on top of that: `docker run` needs the writable layer, so the measurement mechanism can fail under exactly the disk pressure the reserve exists to handle. The previous-dump estimate also measures the **artifact** that will be written rather than the live volume, which is the truer predictor. The figure and the decision are recorded rather than left as a "we could do better". **THE MESSAGE WAS NOT WEAKENED — the behaviour was moved so the wording became true.** It still says the previous unit is untouched and nothing was deleted, and now adds *which* term bound (headroom or size) and the estimate that produced a size refusal. `TestAdmission_EveryClaimInTheRefusalMessageHoldsAgainstTheTree` checks **every** claim against a sha256 fingerprint of the tree it describes — not against the log line, because a log line is exactly what lied here. **IT STILL REFUSES AND NEVER DELETES.** Unchanged and load-bearing: nothing on this filesystem is generational, so "prune the oldest" could only mean destroying a **different** app's only local recovery unit. `pruneStalePrimaryDirs` is not a retention policy and must never be repurposed for headroom. **Tests (11 new, all through the production functions; 4 red-proofs demonstrated failing then restored).** The refusal assertions are **tree fingerprints before and after**, never log lines. The DB leg cannot run without Docker, so its gate is pinned by an **AST walk** of `backup.go` asserting `admitApp` precedes `DumpOne` — `strings.Contains` is insufficient, a commented-out call still contains the string. Red-proofs: both dump-leg gates removed (= v0.192.0) → Scenario A red, tree shown changing; the size term removed → Scenario D red; a prune injected into the refusal path → Scenario F red; the floor moved above the warning band → Scenario G red. ### v0.192.0 — the capture floor replaces the bulkhead (2026-08-03, R-165 · decision B2) — MinAgent: none **Ships BEFORE the disk-layout merge it exists for, and is harmless on a box that never gets it.** The `mp1`→`mp0` merge (R-165 / decision D-a) removes a wall that was quietly doing a second job: the 20 G backup partition kept a runaway recovery-unit capture from filling the space the container runtime itself needs, because `/var/lib/docker` was a **different filesystem**. After the merge it is the same one, and a full Docker data-root is a stopped box, not a slow one. Decision **B2** is that bulkhead, done deliberately instead of by accident. **The floor, in `captureAllRecoveryUnits`, checked BEFORE anything is written.** If the target filesystem is below the reserve, that **one app's** capture is refused, its previous unit is left **byte-identical**, the operator alert wired in v0.191.0 fires with the used/free figures, and the loop continues to the next app. **Two terms, whichever binds first — 97% used or 1 GiB free** — the same shape as `internal/fillwatch`, which proved live on 2026-08-02 that a percentage alone is not enough (its critical alert fired on the free-byte term at 91% used, where a percent-only rule stayed silent). **They sit deliberately BEYOND fillwatch's critical band (95% / 2 GiB), so the customer is ALWAYS warned before a refusal can happen.** A floor that fires before its own warning is a silent failure wearing a threshold; `TestFloorSitsBelowTheCriticalWarningBand` pins the whole ordering (warn → critical → refuse) on both terms, and a red-proof setting the floor equal to the critical band fails it. **IT IS ABOUT THE FILESYSTEM'S HEADROOM, NEVER THE UNIT'S SIZE.** A per-unit cap would be R-163 rebuilt inside one volume — the wall moved rather than removed — so a 120 GB app on a filesystem with 180 GB free is captured. A red-proof substituting `UsedGB > 20` for the headroom predicate fails two tests. **IT REFUSES; IT NEVER DELETES, and the reason is recorded because the question will be asked again.** Nothing on this filesystem is generational: a unit is ONE fixed path per app (`backups/primary/`) refreshed in place, and a DB dump is `-.sql`, also fixed. So "prune the oldest" could only mean deleting a **different** app's only local recovery unit to make room for this one. `pruneStalePrimaryDirs` is **not** a retention policy — it removes ORPHANED directories left when an app moves drives and has no notion of age — and must never be repurposed here. **A nil usage read neither refuses nor warns** (§8.4): an unreadable filesystem is the drive gate's business and already has its own alert, and refusing on it would block every capture on a box whose drive merely blipped. **Tests:** 1184 → **1191** (+7). New `unitSpaceFn` seam so a filesystem's occupancy is a test input rather than something a test must manufacture on a real disk. One fixture was **strengthened** during the red-proofs: `TestFloor_TheOld20GCeilingIsGone` originally sat at exactly 20 GB and therefore survived a literal `UsedGB > 20` cap — a hollow test that passed the very shape it forbids. Its figure is now 120 GB and the mutation fails it. ### v0.191.2 — a quiet fill check now says so (2026-08-02, R-167) — MinAgent: none **Earned during v0.191.1's own live validation, which is the strongest evidence it was needed.** After the customer had been warned about `/mnt/sys_drive`, the controller was restarted and produced **zero `fillwatch` log lines** — and that was read, correctly, as unusable: an absent line is equally consistent with *"the check ran and chose silence"* and *"the check never ran"*. Proving the checker was alive required deliberately crossing into the critical band. That ambiguity is **permanent, not rare, for this check specifically**: it is edge-triggered, so the HEALTHY STEADY STATE IS A QUIET RUN. Standing rule 3 aimed at the one place it costs most. `Check` now logs a per-RUN summary — `checked N filesystem(s), M unreadable/skipped, K notification(s); bands: …` — on every run, healthy or not. **Unreadable is counted separately from healthy**, so a drive that has quietly gone unreadable for weeks cannot read as "all fine". Two tests pin it, including that a second, equally quiet run logs again (the observable is per run, not per change). ### v0.191.1 — the fill check also runs at startup (2026-08-02, R-167) — MinAgent: none **Found while live-validating v0.191.0 on guest 9201: the fill check was reachable only on its daily schedule, and neither `sched.Daily` nor `sched.Every` fires on registration — both wait for their first tick.** So a box that BOOTS with a filesystem already over the line would have stayed silent for up to 24 hours. That is the R-100 shape — a real fault visible only after a deadline elapses — and the hub's own checkers handle exactly this case deliberately, leaving already-breached keys unseeded at init so their first `Check` emits (the F2 lesson, `monitor/storage_fill.go`). A daily-only schedule here would have been the same gap one component over. The watcher now also runs **once, 90 s after startup**. The delay lets mounts settle and the drive gate tick first, so a drive still coming back reads as unreadable and is skipped (§8.4) rather than warned about. It is safe to add because the check is edge-triggered against PERSISTED state: a filesystem the customer has already been warned about stays silent, so this adds a warning only where one is genuinely owed. Pinned by an AST assertion in `TestMainWiresTheFillWatcher` — the schedule registration alone no longer satisfies it. **Disclosure:** this also made the flow live-validatable at all. There is still no operator-triggerable "run the fill check now" path; that is recorded as an observation, not fixed here. ### v0.191.0 — warn before the wall comes down (2026-08-02, R-167 · R-158 · R-174) — MinAgent: none **Storage monitoring and backup alerts, decision D-c, landing BEFORE the `mp1`→`mp0` merge (D-a / R-165) rather than with it.** D-a's own condition (2) says the monitoring ships in the same step and never after, because the merge removes a wall that currently fails safely. Landing it first is strictly better and costs nothing: the warnings go in and get proven on hardware while the wall is still standing. **No disk layout is touched in this release.** **R-167 — the customer is warned BEFORE a filesystem fills, and the warning is the pair that already existed.** New `internal/fillwatch`. Nothing warned before this: the first sign of a full filesystem was a backup that did not happen, and the only related signal — the healthcheck's generic `health_degraded` at 90% — looked at REGISTERED STORAGE PATHS ONLY, so the docker area (`mp0`) and the system-data area (`mp1`, which holds every driveless app's retained recovery unit) were invisible, and it never reported free bytes or named a drive. **`disk_warning` / `disk_critical` WERE ALREADY A COMPLETE PIPELINE WITH NO PRODUCER** — allowlisted in the hub, carrying Hungarian copy, sitting in `settings.DefaultEnabledEvents`, with a UI checkbox (`event_disk_alerts`) — and `grep` across all four repos found **zero emitters**. The sixth "built but never wired" instance in this project. This release is their producer; minting a new near-duplicate type would have left the pair inert forever. - **Two threshold terms, whichever trips first** — used ≥ **85%** OR free < **5 GiB** (critical: 95% / 2 GiB). A percentage alone lies at both ends of this fleet's size range: 85% of a 20 G backup area leaves 3 G, less than one DB-backed app's recovery unit (up to ~2× its data — measured 21.1 GB → 40.2 GB, `07-backup-architecture.md` §7.5), while 85% of a 4 TB media drive leaves 600 G. - **Edge-triggered on ESCALATION ONLY**, state persisted across restarts. De-escalation is silent and re-arms. **Hysteresis dead zone** between clear (75% / 7 GiB) and warn holds the previous band, so a filesystem on the line does not flap; the gap is pinned by a test, because a warn and clear threshold that can be edited into equality is a flapping bug waiting to be introduced. - **The hub owns cooldown — no controller-side timer** (the `offboxEnlargeBlockedNotify` precedent). - **A nil usage read is NEVER a warning.** An absent, unmounted or unreadable filesystem is the drive gate's business and already has its own alert; calling it "full" would be a false alarm with a misleading cause. It also does not CLEAR an existing warning — a blipping drive must not silently retract a true alarm. - **Per FILESYSTEM, never per app** — one full disk holding ten apps would fire ten times, nine of them noise. Watched: the app-data volume, the system-data volume, and every registered non-decommissioned drive, de-duplicated by path and resolved at check time (a drive added between checks needs no restart). Daily at **03:30**, deliberately before the nightly app-data legs so a customer about to lose a backup to lack of space hears about it with a night's margin. **R-158 — the operator hears about a failed per-app backup.** `captureAllRecoveryUnits` logged `[WARN] Recovery unit capture failed for %s` and stopped there; the manager carried three notify seams and none for the unit capture. `/backups/apps` is the page a person opens to ask whether ONE app is backed up, and it was the one page that never said. New `unitNotify` seam + `SetUnitNotify`, fired **per app with the loop continuing** (one app's failure neither aborts nor silences its siblings), and carrying the target filesystem's used/free bytes at the moment of failure — the overwhelmingly likely cause is a full filesystem, and those numbers answer "why" without an operator logging in. `UnitSpace` is **nil when the filesystem is unreadable** and renders as *"unavailable"*, never as zeros: "0 GB free" and "we could not look" are opposite diagnoses. **It is OPERATOR-TIER (`recovery_unit_capture_failed`), deliberately NOT `backup_failed`.** That type carries a `customerMessages` entry AND sits in `DefaultEnabledEvents`, so reusing it — which is what R-158's own proposal said — would email the customer, in Hungarian, that their backup failed, about something they cannot act on. D-c routes it to the operator and overrides the proposal. Operator-only is enforced by the hub's `notify.operatorOnlyEvents` register, **not** by the absence of a `customerMessages` entry; v0.78.0 claimed the latter and was wrong, and there is a red-proof here demonstrating the customer receiving it when the register entry is removed. **R-174 — the app-stop guard stopped starting apps onto missing drives. A regression in v0.189.0 code, found by review on 2026-08-02 and closed the same session.** `appStopGuard.SetStarter(stackMgr)` handed `Recover` the RAW stack manager, whose `StartStack` has no drive gate. The guard runs **at startup** — exactly when an external drive may not have come back — so a backup that stopped an app, followed by a power cut and a drive that did not remount, ended with the app started onto a missing drive. **This is R-171 one path over**, and the rule is not new: the API's own `startGatedByMissingDrive` already refused this to the customer. - The starter is now wrapped in `gatedAppStopStarter`, using the **same** drive predicate the boot sweep uses. `bootDriveGate` could NOT be reused whole and the reason is recorded in the code: its holder #2 reads `bootAppStopGuard.HeldStacks()`, which during `Recover` is **the guard's own marker** — it would refuse every recovery it was meant to perform — and holders #1/#2 read package-level vars assigned *after* `Recover()` runs, so a whole-gate reuse would be correct only by accident of nil-safety. Holder #3 (the drive) is extracted into `driveStartGate`, which now has two callers and one implementation; a test pins that `bootDriveGate` keeps delegating to it. - **A refusal is not a failure.** New `ErrStartRefused` + an `AppStopRecovery.Refused` bucket. Both keep the marker — the operation is genuinely unfinished — but only `Failed` alarms. Collapsing them would push a deliberately-held app into `NotifyBackupFailed`, a customer-enabled type, producing exactly the R-171 false alarm this fix exists to prevent. `main.go` now guards the notify with `Alarming()` rather than `!= nil`, and the pre-existing seam test was **tightened** to require it. - Fail-safe per R-171's contract: cannot determine ⇒ do not start. The check stays per-caller and was deliberately NOT pushed into `Manager.StartStack` (fourteen callers, most legitimate). **Tests:** 1157 → **1184** (+27). Every red-proof demonstrated failing and restored — the gate removed from the guard's starter; the `unitNotify` call removed; the edge trigger removed (fires twice); the clear thresholds edited into equality; and `recovery_unit_capture_failed` removed from `operatorOnlyEvents`, which showed the customer receiving an operator event. Seam wirings are pinned by walking `main.go`'s **AST**, not `strings.Contains` — a red-proof that comments out `SetNotify` fails the test while the string is still in the file. ### v0.190.0 — the boot-recovery story finished, and a regression v0.189.0 opened (2026-08-02, R-157 A · R-170 · R-171) **R-171 — a regression introduced by v0.189.0, found by reading the diff and CONFIRMED on hardware before anything was written.** v0.189.0 correctly replaced `isBootOrphan`'s `len(Containers) > 0` term with the customer's recorded intent. But the **drive-absent gate** stops apps with `compose down` (zero containers) and never touches `desired_state`, because it is not the customer — so a gate-stopped app began reading as a boot orphan. Observed live on guest 9201 with the drive held unmounted: ``` [gate] drive ABSENT /mnt/felhom-drives/hdd_1 — stopped+blocked 2 app(s): [calibre-web immich] [bootrecon] Boot reconciliation: 1 boot-orphaned app(s) found: [calibre-web] — up to 2 attempt(s) [bootrecon] attempt 1/2: start "calibre-web" failed … attempt 2/2: … gave up [bootrecon] recovered=[] still down=[calibre-web] (the dead-app alarm now owns these) ``` The **write** hazard did not materialise: compose failed with `mkdir /mnt/felhom-drives/hdd_1/ userdata: permission denied`, because the unbound mountpoint is host-root-owned and the guest is unprivileged. **That protection is accidental** — no code chose it, no test pinned it, and it is one `chown` (or one privileged guest) away from gone. The harm that DID occur is real on every box: two wasted attempts and a **false dead-app alarm for an app the drive gate is deliberately holding**. The fix is not a new rule. The **API's own start path already refuses this** — `startGatedByMissingDrive` returns a Hungarian refusal to the customer — and the sweep bypassed it by calling `Manager.StartStack` directly. New consumer-side seam `bootrecon.StartGate`, wired in `main.go`, gives the sweep the same question to ask. Fail-safe by contract: **cannot determine ⇒ do not start.** New `Manager.DriveLive` reuses the userdata belt's own `isMountPoint` seam so the two cannot drift. Held apps are reported as `HeldByDrive`, deliberately **not** as `StillDown` — that is the alarm's bucket and putting them there is the false alarm being removed. Evidence: `felhom.eu/documentation/audits/DIAG-bootrecon-drive-absent-2026-08-02.md`. **R-157 mechanism A — the sweep that looked once.** `runBootReconcile` waited 5 s and swept exactly once, deriving its candidate set from a fleet docker was still restoring; measured failing on **three of six hard resets**. It is now a **settle-then-sweep window**: sample the fleet (name, state, container count) every **5 s**, call it settled after **3 identical samples**, and sweep **once**, at the end, on a settled fleet. The window terminates on whichever comes first — settled, or a **50 s budget** — and the log says which, because "settled and found nothing" and "ran out of time still churning" are different facts about the box. **The budget is 50 s and not 60 s because a test said so.** `bootReconcileSettle` (5 s) + budget + one `DefaultRetryDelay` (30 s) must stay under `deadAppBootGrace` (90 s) so a successful recovery is SILENT. 60 s was the first choice; `TestBootWindow_CommonCaseFitsInsideTheDeadAppGrace` rejected it at 95 s. Extending the grace to fit was rejected outright (§8.3) — that hides a late recovery instead of reporting it. A window that genuinely overruns now emits a **`LATE RECOVERY` WARN naming the apps**, so a stale alarm never stands without counter-evidence. **The sample REFRESHES first, and that was found by live validation rather than review.** `GetStacks()` returns the Manager's in-memory map, which the scheduler refreshes on its own **10 s** cadence — so sampling it every 5 s without refreshing means two consecutive samples can be identical because *the cache did not update*, not because the fleet settled. Observed on 9201: a container removed ~5 s before the window closed was still in the sampled fleet, and the sweep logged `no boot-orphaned apps` for an app that had none. `sampleBootFleet` now calls `RefreshStatus()` first (≈10 extra cheap `docker ps` calls per boot); a refresh error degrades rather than aborting. **Sampling is otherwise read-only and there is still exactly ONE sweep.** Sweeping per sample was rejected: the sweep's own `StartStack` changes the fleet, so it would never observe a settled one. The per-app attempt bound is untouched — this widens a bounded window, it does not remove the bound. **Widening the window made two more holders reachable (§8.2), so the gate covers all three.** The old T+5 s sweep never overlapped a **quiesce** (starting an app mid-backup defeats the point of quiescing) or a **running app-data operation** (restarting an app under its own tar). Both are now refused through the same seam, reusing `quiesce.SuppressedStacks()` and a new read-only `AppStopGuard.HeldStacks()` rather than second implementations. **R-170 — the second boot gate stops guessing.** `shouldRecreateOnBoot` still ended in `&& hasContainers`, so the two boot gates disagreed about the same question. It now reads `desired_state` with the identical three-way table: `stopped` → never; `running` → recreate whatever the container count; **absent → exactly the pre-v0.190.0 `hasContainers` behaviour**. Its comment argued at length *for* the container count and has been rewritten — a correct implementation under a comment arguing the opposite is worse than either alone. **`presentStable` is untouched and still load-bearing**: an app whose drive is absent is never recreated here, which is the very term the boot sweep was missing. The agreement between the gates is pinned from **both sides** against an identical fixture table (`TestBothBootGatesAgreeOnIntent` / `TestShouldRecreateOnBoot_AgreesWithBootrecon`), because the two cannot be called from one package without an import cycle. **Tests: +25 across 3 packages (27/27 packages green).** Timing is tested by shrinking the window constants, never by sleeping. Red-proofs, each observed FAIL then restored: A (restore the single-sweep shape), B (remove the budget → the test **hangs**, the unbounded shape), C (drop the `desired_state: stopped` branch → the customer-stopped app is started **twice** by the widened window), D (restore `&& hasContainers`), G (remove the start-gate check → the drive-absent app is started), H (comment out `SetDriveGate` → fails while the string is **still present**, which is what the AST walk is for). No hub change, no agent coupling, no user-visible string, no backup/restore/catalogue change. ### v0.189.0 — the box stops guessing what the customer wanted (2026-08-02, R-166 / decision D-b) **The defect.** When an app was not running, the controller had to work out *why*, and it worked it out by **counting containers**: zero containers meant "the customer stopped it" (leave alone), some containers meant "something broke" (recover). That inference is wrong in two ways, and both were silent: - a **power cut mid-compose** or an **interrupted deploy** also leaves an app with zero containers — read as a deliberate stop, so the app simply stayed gone until a human noticed (**R-157 mechanism B**); - a **backup that stops an app** to copy it safely, then dies, leaves it stopped with **nothing on disk** recording that a backup stopped it or that it was owed a restart. Neither is a guess the controller should be making, because the one fact that settles it — what the customer actually asked for — **was written down nowhere**. `app.yaml` recorded that an app was *installed*; it never recorded whether it was meant to be *running*. **Part 1 — desired state, owned by the customer's action.** `AppConfig` gains `desired_state`, a **tri-state** `""` / `running` / `stopped` (`yaml:"desired_state,omitempty"`), with named constants. `Manager.SetDesiredState` is the only writer, and its callers are the only places a human's decision enters the system: the `/api/stacks/{name}/{action}` switch (`start`/`restart`/`update` → running, `stop` → stopped), `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` import adapter. Intent is written **BEFORE** the act, and an action whose intent cannot be recorded is **REFUSED** — proceeding would recreate the ambiguity being removed. **`StartStack`/`StopStack` are deliberately NOT writers.** A census on 2026-08-02 found **14 call sites, of which exactly 2 are the customer**; the other twelve are machines (quiesce, the backup volume dump, offbox reconstitution, app export/restore, the storage drive-absent gate, the migration engine, the boot reconciler itself). Recording intent in the primitive would make a nightly backup indistinguishable from the customer pressing Stop — the exact confusion this release ends. **ABSENT MEANS UNKNOWN, NEVER "running" — the single most important line in the change.** Every `app.yaml` on every existing box predates the field, so absent is what the whole fleet reads on upgrade. Treating it as running would start, on the first boot after the upgrade, every app its owner had deliberately stopped. Where intent is unknown the boot reconciler falls back to the **old container-count rule, byte-for-byte**, rather than inventing an answer. **The boot-orphan decision (`bootrecon.isBootOrphan`), replacing the container-count term:** | `desired_state` | containers | result | |---|---|---| | `stopped` | any | never an orphan | | `running` | 0 | **ORPHAN** — the R-157 case, invisible before this release | | `running` | >0 + down | ORPHAN (unchanged) | | `running` | >0 + up | not an orphan | | absent | 0 | not an orphan — **exactly** the pre-v0.189.0 behaviour | | absent | >0 + down | ORPHAN — **exactly** the pre-v0.189.0 behaviour | `Protected` and `Deploying` guards unchanged. A **running-only** startup backfill converges apps that are deployed AND observed up; `stopped` is **never** backfilled, from any signal — inferring it from zero containers is the defect itself, so an ambiguous app stays ambiguous and keeps legacy behaviour until the customer next presses a button. **Part 2 — the app-stop crash marker (`backup.AppStopGuard`).** `/appstop-state.json`, atomic (tmp + **fsync** + rename, 0600), modelled on the quiesce marker and deliberately **its own file** — same shape, different owner, different lifetime; sharing would give one file two writers. Written **before** the stop, cleared only after a restart that **succeeded**; a FAILED restart keeps it so the next startup retries. `Recover()` runs at startup and **completes before** the boot-reconcile goroutine is launched, so an app the marker explains is not also reported as an unexplained boot orphan. A corrupt marker is quarantined loudly, never silently skipped. **A `defer` is not the mechanism, and the code says so.** Campaign 8 fault 10 established on live hardware that a SIGKILL runs no deferred function; the marker is what covers the hard crash. Its test simulates a real abort (an unwind that skips the restart statement) rather than a graceful return — an earlier version of that test called `Begin` itself and **survived the red-proof that deleted the production call**, which is exactly the hollowness §10 exists to catch. **All three stop-and-restart sites are covered**, with no uncovered sibling to imply the class is handled: `DumpAppVolumesSafe`, `offbox_reconstitute.go` (all four bring-up paths, via one `restartStack` closure so the success path cannot silently skip the clear), and `appexport`'s export — the last through a two-method consumer-side seam so the exporter shares the ONE marker file instead of opening a second. The reason string lives only in `backup`; the adapter in `main.go` supplies it. **Also fixed, and it would have silently eaten this feature: `SaveAppConfig` rebuilt `AppConfig` field-by-field.** That is the R-100 shape (v0.181.0 shipped with two live instances of it). The literal named five fields, so the sixth — `desired_state` — would have been **dropped on every save**, and nine call sites share that path: a customer's Stop would have been erased by the next unrelated `app.yaml` write. Replaced with copy-and-overlay (`saveCfg := *cfg`), safe by construction. Measured and documented: `app.yaml` does **not** round-trip keys the struct does not model (the trip goes through the struct), pinned by `TestSaveAppConfig_UnknownYAMLKeysAreDropped`. **Operator visibility (§2.4).** An interrupted operation rides the **existing** `backup_failed` event type. A new type would need the hub's `allowedEventTypes` + `customerMessages` pair changed — a wire change, and this release ships **no hub change and no hub version bump**. `Recover()` **returns** its outcome rather than pushing it through a notifier seam, because it must complete before the boot reconciler (`main.go:~236`) while the notifier is not constructed until `~307`; a seam wired after the fact is a seam that never fires. **No user-visible string changed** — N/A for UI work. No template, funcmap, notifier-type, event-type, backup-content, retention, tier or restore change. **No agent coupling; MinAgent unchanged.** **Tests: +37 across 5 packages (27/27 packages green).** Red-proofs, each observed FAIL then restored: B (restore `len(Containers) > 0`), C (treat absent as running — the fleet-wide upgrade regression), D (drop the up-state guard from the backfill), E (delete the production `Begin` call), H (restore the field-by-field `SaveAppConfig` literal), §8.2 (move the intent write below the action switch), and the seam test I — which fails while the commented-out call **is still present as a substring**, the distinction that made the controller's first version of that test pass its own red-proof in 2026-07-21. ### CI — the gate entry point runs on every push (2026-08-02, R-168) — NO VERSION BUMP **No version bump, no build, no deploy** — this adds a workflow file only. Stated explicitly so the omission reads as a decision rather than a miss. **`.gitea/workflows/gates.yml` (new).** Triggers on `push`, `runs-on: felhom-gates`, obtains the source with a shallow `git fetch` of the **exact pushed SHA** from the in-cluster Gitea Service, and runs this repo's entry point with `--fast` — nothing else. **No `uses:` step anywhere**: JavaScript actions need a node runtime the host-mode runner does not have, and probe P3 measured a plain `git fetch` as sufficient. No `|| true`; the entry point's exit code IS the job's result. **It REPORTS, it cannot REFUSE**, and the workflow header says so: this repo pushes straight to `main` with no pull request, so there is no merge for a status check to stand at. The refusing half is `.githooks/pre-push`, which is per-clone and `--no-verify`-able; this half notices when that was skipped. Making CI blocking needs branch protection plus a PR workflow → felhom.eu `OPEN-ITEMS.md` R-169, an operator decision. **A failed run emails the operator** via Resend and prints the provider's accepted id, because probe P5 measured that Gitea itself sends nothing at all on a failed run. Demonstrated end to end on a real red run (`RESEND-ACCEPTED id=…`), not assumed. Full detail: `felhom.eu/documentation/audits/SPIKE-ci-runner-2026-08-02.md`. **CI reproduces the workspace's SIBLING LAYOUT on purpose.** This repo's entry point invokes the shared `reuse_refs_check.py` that lives in the `felhom.eu` clone next door and is deliberately never copied here, and this repo's `REUSE.md` cites `wgsync/reconciler.go`, which lives in the hub. The workflow therefore clones `felhom.eu` as a sibling; without it the gate fails **closed** with `gate is MISSING` — correctly, but for the wrong reason. Verified that CI and the local hook then agree exactly: 133 cited paths, 126 exact / 6 suffix / 1 cross-repo, 0 failures. ### Gate enforcement — one entry point + pre-push hook (2026-08-02) — NO VERSION BUMP **Deliberately no version bump, and no build or deploy.** Nothing compiled changed: this touches `controller/scripts/` and `.githooks/` only, so no behaviour on any box moves. Stated explicitly so the omission reads as a decision rather than a miss. **`controller/scripts/docker_run_volume_path_gate.py` — one allowlist entry, in its own commit (`c432f70`).** The gate was RED, flagging `internal/appexport/estimate.go:179`. The finding is benign: `realVolumeSize` mounts a **named Docker volume** read-only into a throwaway alpine to `du` it from a container view — no host path is involved, the daemon resolves the volume name daemon-side, and it is structurally identical to the already-allowlisted `internal/backup/backup.go` entry. The gate was right to demand review; that diff **is** the review, on its own, because burying an allowlist widening inside a feature commit is how an allowlist stops meaning anything. `realVolumeSize` was not touched — the code is correct; the allowlist was incomplete. **`controller/scripts/controller_gates.py` (new) — THE entry point.** A census of all thirteen gate scripts across the four felhom repos found that every check a `CLAUDE.md` names was passing and two of the four nobody is told to run were failing. This repo had seven gates and `CLAUDE.md` named two; four more were reachable only through a line in `REUSE.md`, and the docker-`-v` gate through one line in `REUSE.md` and nothing else — while RED. The runner invokes all seven plus `reuse_refs_check` on the repo root, streams each gate's own output, and exits worst-wins non-zero. `--fast` selects the gates that touch no network and no container runtime; today that is all eight. **The shared checker is never copied here.** `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is invoked across the workspace at `/../felhom.eu/scripts/`. A copy would recreate exactly the drift it exists to detect. If the sibling clone is absent the gate **FAILS** and prints the path tried — fail-closed. On this repo it now resolves 133 cited paths: 126 exact, 6 by suffix, and `wgsync/reconciler.go` cross-repo into the hub. **`.githooks/pre-push` (new)** — runs `controller_gates.py --fast` and refuses the push. Per-clone (`git config core.hooksPath .githooks`; a manual run WARNS when the clone is unarmed) and `--no-verify`-able on purpose; both limits are written into the hook. CI is the unbypassable half and is owed — `felhom.eu` `OPEN-ITEMS.md` R-168. **`controller/scripts/test_controller_gates.py` (new, 4 tests)** — a SEAM test asserting each member gate's own distinctive stdout, never the runner's summary line, which an inert runner prints while calling nothing. Red-proofed: replacing `run_gate`'s body with `return 0` still prints "all controller gates OK" and exits 0, and turns the seam test red. ### v0.188.0 — D5: an app restore works from the drive alone (2026-07-30) — MinAgent 0.113.0 (unchanged) **Tier-1/Tier-2 no longer depend on the whole-guest tier.** Until now the recovery unit on the customer's drive was secret-free, which made the two-lane split *look* independent while it was not: the app's files sat safely on the drive and could not be brought back, because the secrets that make them readable went down with the guest. After this, restoring an app needs **the drive and nothing else** — not the server, not the operator, not the offsite copy. **Part 0 first: the brief's own recommendation did not survive the test it asked for.** It proposed that only `data_key`-flagged secrets travel. Two findings overturned it, both evidenced before any code: 1. **The `data_key` flag is not a trustworthy classification.** Only 5 fields across 4 apps carry it, yet the catalog's own Hungarian labels contradict the flag elsewhere: `n8n/N8N_ENCRYPTION_KEY` („Titkosítási kulcs"), `wanderer/POCKETBASE_ENCRYPTION_KEY` („Adatbázis titkosítási kulcs"), `calcom/CALENDSO_ENCRYPTION_KEY`, `bookstack/APP_KEY` — same label as `adventurelog/SECRET_KEY`, opposite flag. Travelling "only data keys" would omit real data keys, and the fail-closed gate would not fire for them → a restore that succeeds onto unreadable data. Filed **R-127**. 2. **A DB password is not resettable in practice — proven, not argued.** `DumpAppVolumes` dumps every compose named volume with no DB exclusion, so `immich_postgres_data.tar` is captured and restored. Probe on `postgres:16-alpine` (seed → drop container, keep volume → redeploy with a regenerated password): the **replay succeeded** (`docker exec psql`, no password — verbatim what `ImportDump` does, and the image's local socket is `trust`), the **app path failed** over the compose network (`FATAL: password authentication failed`), and the **old** password still worked — `POSTGRES_PASSWORD` is ignored once PGDATA is non-empty. So the restore reports success, the dump replays, the rows are there, and the application cannot reach them. 18 DB/root-password fields affected. MariaDB fails louder: `getMariaDBPassword` reads the regenerated value from container env against a datadir holding the old hash, so the replay itself gets Access denied (`nextcloud`, `romm`). **The rulings (operator, 2026-07-30).** `type: secret` travels; `type: password` never does; minus a code register. Plaintext, as the data already is. - **TRAVELS (45 fields):** the 5 declared data keys, 18 DB/root passwords, 22 internal signing/encryption secrets. Every one decrypts data on the SAME drive or authenticates to a container on an internal compose network with no external listener — possessing it adds nothing to possessing the drive, which is exactly D2's argument for plaintext DATA. - **WITHHELD (8):** the 7 `type: password` admin/UI logins + `vaultwarden/ADMIN_TOKEN` via the `nonPortableSecrets` register. These authenticate against published services, so their reach is NOT bounded by the drive. **Excluding this class is what licenses the plaintext ruling; the two are coupled and must not be relaxed independently.** The register is code, not a catalog flag — a boundary a catalog push can silently move is not a boundary (cf. R-97a). **What a customer must possess to complete a Tier-1/2 restore after this change: the drive.** **Implementation** — one place per side, no parallel path. `stacks.PortableSecretEnvVars` is the whole boundary; `GetStackRecoveryInfo` decrypts the portable class through the SAME `LoadAppConfigDecrypted` the restore side uses; `buildUnitAppYaml` (was `buildStrippedAppYaml`) writes it at **0600** and names the withheld class in the header so an operator can see WHY a credential is absent rather than suspect a capture bug; `readUnitEnv` splits it back using the **manifest's** portable names, never guessed from key names. `reconcileRestoreSecrets` stays a pure function — the new source arrives as an **argument**. Manifest → **schema 2** + `portable_secret_env_vars` (NAMES only; the manifest is 0644). **Precedence: the UNIT WINS.** Not "newest wins". The unit's secrets are captured in the same run as the dumps beside them (`runVolumeDumps` → `captureAllRecoveryUnits`), so the unit's value is the one that matches the data about to be restored; the guest's is merely the most recent. A rotated data key does not decrypt data encrypted with the old one, and a rotated DB password does not match the hash in the restored data directory. Pinned in both directions — an undefined precedence between two sources of a decryption key is a data-loss bug waiting for its first disagreement. **The fail-closed gate is unchanged and still fail-closed:** a data key in NEITHER source refuses outright. D5 makes it normally present; "normally" is not a reason to soften a gate. **Three comments that asserted invariants D5 makes false were corrected, not left to read as settled** (`CaptureRecoveryUnit` "NEVER writes a secret value", `RestoreFromRecoveryUnit` "no secret is read from the unit", `appbackup/paths.go` + `appdata.go` "secret-free"), and the O4 WARN that claimed "stored data is unaffected" for every non-data-key secret now says what is true — finding 2 disproves it for DB passwords. **Backward compatible.** A schema-1 unit carries no secrets and still restores from the guest; the next capture rewrites it (the app.yaml checksum changes). No existing backup changes, no data moves, and the escrow / whole-guest / offsite tiers are untouched in code — the offsite copy simply carries the secrets inside the unit it already pushed, encrypted at rest under the customer-owned restic password. **Tests** — `TestRestoreFromRecoveryUnitWithGuestAbsent` is D5's claim as a test rather than a description; plus fail-closed-with-both-sources-absent, precedence both directions, the schema-1 no-regression case, `readUnitEnv` splitting, and the wrong-outcome check that the withheld class appears NOWHERE in the unit. Fixtures come from a unit written by the **real** `CaptureRecoveryUnit`, so the two sides meet at real bytes. Seam: `Manager.stackProvider` only. **Four red-proofs, each verified to have landed:** drop the portable merge → the consequence test fails; neuter the gate → 4 failures; flip precedence → the unit-wins test fails; widen the class to `type: password` → the boundary test fails. **R-120's gate does not apply to this task** — it sits in `hub/internal/web/configs.go` `handleSetArtifacts`, the golden-**vouch** form, and never runs on a controller image deploy. Re-baking the golden is a follow-on so that FRESH installs get D5; it is not a prerequisite here. ### v0.187.0 — R-108: network storage may not host an app's data namespace (2026-07-30) — MinAgent 0.113.0 (unchanged) **This is D5's precondition, and it is now met.** D5 moves app secrets into the local recovery unit so Tier-1/Tier-2 restore stop needing the guest; that is safe only once no browsing surface can reach the backup tree. One could. **The chain, confirmed at source end to end.** `namespaceRoot(drivePath)` returns any non-system drive path AS-IS (`internal/backup/backup.go:262`), so an app's namespace root IS its `HDD_PATH`. Its recovery unit therefore lands at `/backups/primary//` (`appbackup.RecoveryUnitPath`). Put an app on a NAS and that directory sits inside the share, which FileBrowser binds **whole** — share ROOT, `:rslave`, `download: true`. Live on demo-hp, the asymmetry visible in one glance: - /mnt/felhom-drives/nvme-1tb/userdata:/srv/nvme-1tb <- drive: userdata-SCOPED - /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave <- share: ROOT **Why the bind was NOT narrowed** (this was the real finding, and it inverted the fix). The share-root `:rslave` bind is **load-bearing**, not an oversight: a Phase-0 probe (2026-07-22) proved an in-container access through it wakes the idle automount trigger, so narrowing it breaks NAS access itself. And scoping is not even definable — apps on a share store at `/`, there is no `userdata/` layer, and creating one would write Felhom's directory convention onto a customer's own NAS, which R-67 forbids outright. So the browsing surface cannot be narrowed, and the backup tree must therefore never be placed under it. **Operator ruling 2026-07-30: refuse the placement, keep the browse bind.** Tier 2 already refuses network targets for this same class of reason (`F-6C-1`); this closes the PRIMARY namespace, which was the last way a `backups/` tree could appear inside a share-root bind. **Nothing is stranded.** Verified across all six hub customers including Peti: zero apps on network storage. demo-hp's `Felhom-Share` holds only the customer's own files (no `backups/`). R-67's browse capability is untouched — same bind, same `:rslave`, byte-identical. **FIVE surfaces, not the four the register named.** `settings.RefuseAsAppNamespace` is the single predicate all of them consult: 1. **the deploy POST** (`internal/api/router.go`) — **this is the boundary.** The R-108 row says "the deploy dropdown has no `IsNetwork()` filter", which understates it: the dropdown is a UI list, and this endpoint accepts whatever `HDD_PATH` a caller supplies, with `DeployStack` validating only that it EXISTS (`os.Stat`, `internal/stacks/deploy.go`). Filtering the list alone would have left the surface open. 2. **per-app migrate targets** (`internal/web/handlers.go`) — dropped from the offered list. 3. **`handleStorageMigrateApp`** — refused before `MigrateApp`, so no job starts. 4. **`handleStorageDecommission` mode=migrate, the TARGET** — **not in the register.** The existing `refuseNetworkLifecycle` guards `req.Where`, the SOURCE; the target was unchecked, so a whole namespace could be decommissioned ONTO a NAS. Found by enumerating the set rather than trusting the four that were named. 5. **the FileBrowser bind** — deliberately unchanged, and now pinned by a test so it cannot drift. **FAIL CLOSED, and the non-obvious part is why this is a function and not an `IsNetwork()` call:** `/mnt/felhom-drives` holds BOTH kinds in-guest (`.../hdd_1` is a local drive, `.../Felhom-Share` is a NAS), so a path prefix cannot classify — `Kind` is the only discriminator and it exists only on a REGISTERED path. An unregistered path under that root is therefore un-classifiable, and un-classifiable refuses. Every share is registered under that root by construction, so the network set is completely covered without touching drives. **UI (§5): marked, not hidden.** A registered NAS stays in the deploy dropdown, `disabled`, labelled `(hálózati tárhely — alkalmazáshoz nem választható)`, and never pre-selected even when it is the registry default. A share the customer registered themselves, silently missing from the list they expect it in, reads as a bug and generates a support question; present-with-a-reason answers it in place. Follows the existing `(nem elérhető)` disabled-option precedent. Tests: 9 new, all asserting the **non-effect**. The refusal tests run against a Server with a deliberately **nil `stackMgr`**, so a guard that fails to fire reaches the mutation and PANICS rather than passing quietly. They assert no job id, no `started` flag, no `MigratedTo` written, and — for decommission — that the source was NOT soft-marked. Fixtures are demo-hp's real two-class storage set (both paths under the same mount root, which is the trap). Red-proofs: 4, each mutation asserted to have landed first — drop either migrate guard → panic; break fail-closed → 3 tests; userdata-scope the share → the R-67 regression guard fires, quoting the broken bind. Suite rc=0, 27 packages, 0 FAIL; `go vet` rc=0; `template_id_gate.py` + `emoji_gate.py` both OK. - `internal/settings/settings.go` — `RefuseAsAppNamespace` + the two Hungarian refusal reasons. - `internal/api/router.go` — the deploy-POST refusal. - `internal/web/storage_handlers.go` — `refuseAppNamespaceTarget`; wired into migrate-app + decommission. - `internal/web/handlers.go` — migrate-target list filter; `DeployStoragePath.NotAllowed`. - `internal/web/templates/deploy.html` — disabled option + reason; no pre-select of a disabled default. ### v0.186.0 — R-114 + R-112: tell the truth about the backup target, then show it (2026-07-29) — MinAgent 0.113.0 (unchanged) Two defects E-2d found on a real box, fixed in this order deliberately: the message is corrected BEFORE it is put on screen, because switching on a banner that lies is worse than a silent one. **R-114 — the third state.** `resolveBackupTargetState` had two outcomes: a disk claims the target (healthy), or nothing does (degraded, "the backup is on the system disk"). The state *configured, and its drive is gone* has no branch, so it fell into the second and inherited both its message and its offer. Live payload, target detached: `degraded:true, target:"felhom-backup"` **plus** the system-disk copy — false, the backup was on a drive that had vanished — **plus** `offer_path` naming that same vanished drive as the remedy (`felhom.eu` `audits/E2D-fresh-vm-2026-07-29.md` §5.3). New `BackupTargetState.TargetAbsent` discriminates. `Degraded` keeps its meaning ("is there a problem") so the wire contract is unchanged for every consumer; `TargetAbsent` answers "which problem", because the two have OPPOSITE remedies — attach any second drive, versus reconnect *that* one. Its copy is routed through `degradedMessageFor`, so there is still exactly one place that decides what a customer reads. **No offer in this state**, suppressed on the branch itself rather than left to `firstOfferableDrive`'s `Disconnected` skip: that flag is set by the agent-side gate in another repo (R-113), and this state must be correct independently of it. Belt here, braces there. The absent copy is **verbatim** the hub's `customerMessages["backup_target_absent"]`, so the banner and the email tell one story. It now lives in two repos with nothing binding them but a test — filed as a drift risk, not solved. **R-112 — the state finally has a consumer.** The endpoint was byte-correct and **nothing in the product ever asked for it**: templates fetch 18 distinct `/api/storage/*` endpoints, and `backup-target[/assign]` were the only two with zero references. Server-rendered on the backups page now (`backupsHandler` → `backupTargetView` → `backups.html`), following the existing `SingleCopyWarning` banner pattern — not a 19th JS fetch, because a banner that needs JavaScript to appear is one more thing that can silently not happen. `backupTargetView` returns **nil** for healthy and unknown, so those render nothing at all: no badge, no reassurance. The offer control POSTs to the existing assign endpoint behind the standard inline confirm, never auto-submits, and surfaces `restart_required` honestly instead of adding a self-restart. **Seam test (Scenario E)** drives `backupsHandler` over httptest and asserts the RENDERED HTML — handler → view → resolver → template. Deleting the one line that sets `data["BackupTarget"]` reproduces the R-112 state and fails every render assertion. Tests 326 → 338 (+12) in `internal/web`; three red-proofs run and reverted. **MinAgent unchanged at 0.113.0.** R-114 reads `BackupTarget`/`MountPath`/`GuestPath`/`Role`, none of which R-113 altered — it changed `BoundUnderParent`, which this code does not read. So demo-hp (agent 0.113.0) is not held. **NOT LIVE-VALIDATED.** Scenario C cannot occur on a healthy box; Session C proves it. ### v0.185.1 — E-2: the offer endpoints were mounted where nothing routed to them (2026-07-29) Registered as `/api/backup-target` inside `ServeStorageAPI`'s switch — which `main.go` mounts ONLY at `/api/storage/`. Live result: `{"ok":false,"error":"endpoint not found"}` while every unit test passed, because the tests called the handlers directly and never travelled the mount. Caught by the first live call, which is exactly why the live call is part of the procedure. Moved to `/api/storage/backup-target[/assign]`. `TestBackupTargetRoutesLiveUnderTheStorageAPIMount` now asserts the dispatcher's own source contains both paths, so a handler that nothing routes to fails the suite — the repo's seam-wiring rule ("a feature is not shipped until its entry point is reachable"), applied to a route rather than a button. ### v0.185.0 — E-2 Parts 3+4: the offer, and the honest degraded state (2026-07-29) — MinAgent 0.113.0 The half that makes the rest work. A degraded backup target recorded only in config is the silent-degradation pattern this arc has spent a week removing. **Part 3 — the offer.** `POST /api/backup-target/assign` moves the target via the agent's `POST /backup/target`. It is the **only** writer of the role: registration does not set it, the drive-gate does not, no scheduler does. Declining is simply not calling it. The agent returns `restart_required` rather than restarting itself, and the reason is E-1's own mistake: restarting with a backup in flight cancels the wait and records a spurious tier failure for a backup that actually succeeded. The restart belongs to whoever can re-check in-flight work immediately beforehand. **Part 4 — visibility.** `GET /api/backup-target` returns the state and, when degraded, the Hungarian copy: > „A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, > lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez." FACT → CONSEQUENCE → REMEDY, pinned by a test: a customer told only the fact cannot act on it. **Healthy renders NOTHING** — no badge, no reassurance, no tonal change. `degradedMessageFor` is the single decision point so there is exactly one place that could start decorating a working box. Red-proofed: adding „A rendszermentés védett…" to the healthy branch fails Scenario E. **UNKNOWN is not degraded.** An unreachable or pre-R-82 agent means we could not ask, which is not evidence of degradation — the absence-read-as-a-value mistake R-88 Part 2 closed. **A hollow test caught by its own red-proof.** `TestUnknownStateRendersNothing` originally used `{Known:false}` with `Degraded` left false, so it passed even with the `!Known` guard deleted — the second condition covered for it. The fixture is now `{Known:false, Degraded:true}`, which fails properly when the guard goes. The red-proof is what exposed it; without it the test would have been decoration. State is derived from the AGENT, never from our own intent flag: on the two boxes migrated by hand in E-1 the intent was never recorded while the drive really is the target. ### v0.184.1 — E-2b keying fix: the backup-target branch was unreachable (2026-07-29) **Caught before deploy by tracing, not by a failure — and the 0.184.0 image is therefore superseded and must not be shipped.** `ReconcileDriveGates` resolves the target as `isTarget[a.Path]`, and `a.Path` is the **registered** `StoragePath` — for an external drive that is the GUEST path `/mnt/felhom-drives/`, not the agent's host `MountPath` (`/mnt/`) that `/disks` reports. `driveTargetByPath` keyed the map on `MountPath` alone, so the lookup never matched: **every absent drive, the target included, fell through to the generic `storage_disconnected`.** The alarm would have looked wired, passed its own unit tests, shipped, and been silently wrong on exactly the drive it exists for — the same defect class E-2b was opened to fix, one level down. Now keyed under BOTH paths, mirroring `planDriveGates`, which already registers `present[]` under `GuestPath` and `MountPath` for the same reason. Red-proofed: reverting to MountPath-only keying fails with *"the backup target is not resolvable by its GUEST path — the gate passes a.Path (the registered StoragePath), so the backup-target branch would never fire"*. ### v0.184.0 — E-2b + Part 5: the drive-absent alarm that was never wired (2026-07-29) — MinAgent 0.112.0 **`NotifyStorageDisconnected` and `NotifyStorageReconnected` were defined and called from NOWHERE.** Registered in `allowedEventTypes`, in `DefaultEnabledEvents`, and given a Hungarian message on the hub — and never invoked. A drive going absent produced apps stopped, a `[WARN]` log and a UI badge, then **silence on every channel**. Verified against the gitignored-`cmd/` trap with a positive control. Fifth instance of this class in the project, found by E-2's Phase 0 rather than by a failure. A drive that is *only* a backup target has no apps to stop, so it was silent twice over. `ReconcileDriveGates` now calls both halves. When the absent drive is the **whole-guest backup target** it raises the more specific `backup_target_absent` (error) instead — never both; two mails for one event trains people to ignore the channel — and recovers as `backup_target_restored` (info, the existing pairing-gated pattern; `severityNotifies` is NOT widened). The recovery must mirror the alarm's choice or the operator cannot match them. **Which drive is the target comes from the AGENT** (`/disks` `backup_target`, agent ≥ 0.112.0), not from our own `StoragePath.BackupTarget`: that field is customer INTENT, and on the two boxes migrated by hand in E-1 the intent was never recorded while the drive really is the target. An older agent omits the field → false → the generic disconnect alarm, never a wrong one. Before this, an absent backup target had **no prompt signal at all**: the tier stays DUE (`targetStoragePresent` checks name presence, never reachability), so the only evidence was the tier's own failure at its next due cycle — up to ~24 h on the daily local tier. The R-100 shape. **Tests** observe the WIRE, not a mock, because the failure class is "nothing arrives": a real `Notifier` posts to an `httptest` hub and the test asserts the event type and severity that actually went out. A typo in the type string is not cosmetic — the hub 400s it and the event vanishes. ### 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 `