Files
felhom.eu/documentation/architecture/capability-map-detail/B.md
T
admin 0d463a859e
gates / gates (push) Successful in 6m12s
answers 2026-10-12 Parts D, F, G: controller 0.308.0 delivered, the capability map restructured, the notification design; 128 -> 97
- closed: R-922 R-255 R-266 R-333 R-577 R-554 R-562 (controller 0.308.0 on demo-hp, demo-felhom, Tester 1), R-288 (map 212 KB -> 48 KB, detail per section)
- R-521 narrowed to F7; R-388 design in 08 §8.1 (two questions for the operator)
- privacy draft: why the registered address stays (S1); 02 module map: setup wizard deleted
- STATUS + REPORT-answers-2026-10-12.md; docs.md routing line names capability-map-detail/

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:54:44 +02:00

382 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capability map — detail, section B. Apps & catalog
> **What this file is.** The long text of every row of section B of
> [`00-capability-map.md`](../00-capability-map.md), moved here **verbatim** on 2026-10-12 (R-288,
> R-290 folded in; `09` §3 decisions 214 and 229) from the map at commit `1b45bb4d`. Each entry is one
> former table row, its cells laid out one per paragraph; the cell text itself was not edited. The
> map row carries the capability's ONE status word and its dated evidence pointer; this file keeps
> the argument, the corrections and the history. **Where this text and the map row disagree on the
> status, the map row is current** (see the map header for the words that were normalised).
>
> A new correction goes **under the entry it corrects, dated** — never into the map row.
<a id="b1"></a>
## B1 — Deploy an app from the catalog (env config, memory guard, health-aware progress)
*Map row: [B1](../00-capability-map.md#b1) · former map line 105.*
**Scenario (as written):** Deploy an app from the catalog (env config, memory guard, health-aware progress)
**Components (as written):** controller, catalog (~52 apps, images pinned)
**Status (as written):** **PROVEN-LIVE**
**Evidence (as written):** `CAMPAIGN-2` T-DEPLOY-SET (7 apps, env config, health-aware); `RERUN-p1p3` (×4 PASS)
**Gap / roadmap (as written):** Memory-guard FIRING is not live-shown (T-RES-MEMGUARD never fired: ample RAM / auth-walled) — implemented + unit-level only
---
<a id="b2"></a>
## B2 — App lifecycle: start/stop/restart/update/logs/remove/redeploy
*Map row: [B2](../00-capability-map.md#b2) · former map line 106.*
**Scenario (as written):** App lifecycle: start/stop/restart/update/logs/remove/redeploy
**Components (as written):** controller
**Status (as written):** **PROVEN-LIVE — the ACTIONS work. NARROWED 2026-09-13: `CAMPAIGN-3` proved `remove` removes the APP, not the DATA — the "delete my data" half was INERT on every box until controller v0.236.0 (R-442). RE-PROVEN 2026-09-13 on demo-hp: data written by the app itself (63 MB) gone after removal and listed; an unresolvable data location is REFUSED (409) with the app kept; an SSD app gets `[]` and a note.**
**Evidence (as written):** `CAMPAIGN-2` T-LIFECYCLE (stop/start/restart/update/logs); remove (app only) live in `CAMPAIGN-3`; **remove WITH data: `audits/R442-2026-09-13/`**; **data behaviour: `audits/SPIKE-app-update-2026-09-01.md` (2026-09-01)**
**Gap / roadmap (as written):** Redeploy-after-remove edge remains open (T-REMOVE-REDEPLOY never cleanly passed — stale dryrun journal); non-pilot-critical
---
<a id="b3"></a>
## B3 — Update is GUARDED: it refuses without a restorable backup, backs up first when the copy is stale, and HOLDS an app that does not come up — on ANY backup tier, and the release itself arrives by the managed floor
*Map row: [B3](../00-capability-map.md#b3) · former map line 107.*
**Scenario (as written):** **Update is GUARDED: it refuses without a restorable backup, backs up first when the copy is stale, and HOLDS an app that does not come up — on ANY backup tier, and the release itself arrives by the managed floor**
**Components (as written):** controller **v0.237.0 + v0.238.0 + v0.238.1 + v0.239.0**, hub **v0.112.0**
**Status (as written):** **PROVEN-LIVE (2026-09-13, and again the same afternoon for any tier + floor delivery)** — **afternoon (`audits/rulings-r472-r475-2026-09-13/`):** an undeclared floor above the golden refused with nothing stored (02); a declared floor 0.239.0 / MinAgent 0.129.0 served `from declared` and both demo boxes self-updated in 14 s and 15 s (03); nothing on any tier → backed up first, Tier 1 chosen, done (04); gokapi updated on its Tier-1 unit alone (05); a never-healthy update held naming „saját meghajtó" (07); restored from „helyi", hold cleared (08). **Morning:** scenarios A (real upgrade, success only after health), B (stale copy → backup first), E (pull failure → pin back, app untouched), F (never healthy → held, hold text on API and page), H (start/restart/update and the boot sweep all refuse the held app) and **the restore walk** (Mentések unit restore → back on the old version, hold cleared), on demo-hp with a throwaway app
**Evidence (as written):** **`audits/slice4-2026-09-13/`** (live/, redproofs/, gates/); design `architecture/09-update-architecture.md` §6.1
**Gap / roadmap (as written):** ~~**Tier-2-only precondition**~~ — superseded by v0.239.0 (any tier, R-475 CLOSED); a Tier-1 route back restores only what the unit holds (R-479); the card keeps the failure sentence after a successful restore (R-480); no automatic rollback, by measurement; a release does not reach the fleet by floor between golden bakes (R-472) **WIDENED 2026-09-21 (the update night) from 3 apps to 21 edges across 19 apps, and NARROWED in one place by the same run.** `audits/DRILL-update-night-2026-09-21.md`. On scratch guest 9202 (controller v0.261.0), against a **private drill catalog** so the live catalog carried no test reference at any point, 21 edges across 19 apps real within-a-major upstream edges were walked through the product's own guarded Update, each app seeded and read back **through its own front door** (R-156) with a negative control on every readback: **14 proven, 3 failed, 4 inconclusive.** **What the PROVEN edges prove, precisely:** the app moved, the four version observables agreed, and the data the app itself was given came back through the app's own interface afterwards. Ten of them printed a verbatim migration line. **What the FAILED edges prove, and they are the more valuable half.** `adventurelog` (a real upstream edge that migrates and then never serves), `tandoor` (an update that SUCCEEDED and was stopped by its own wrong health port), and the PostgreSQL engine major, which refused exactly as predicted. `adventurelog v0.12.1 → v0.13.0` applied **nine database migrations successfully** and then never bound its port; the update held after the full health wait, the hold sentence named the tier, the date and what the copy holds, and the restore the sentence names brought the app back. **That is this row's own promise, exercised on a real upstream edge rather than a staged one.** **AND THE NARROWING, which this row must carry because it is the same mechanism:** the `verifying` phase trusts the `.felhom.yml` probe absolutely, and **two of the 53 templates name a probe the app does not answer** — `tandoor` (port 8080; it listens on 80) and `zipline` (`/api/health`; it answers 404 there, while the compose healthcheck in the same file uses `/api/healthcheck` and is green). For those apps a **successful** update is stopped by its own health wait: tandoor was measured **serving HTTP 200 on the new version at four samples across five minutes**, with docker's own healthcheck green, and was then stopped by `failAndHold` and the household sent to a restore they did not need. **R-618, P1.** No data was lost and the restore works — but "the update is guarded" must not be read as "the guard is right about whether the app came up". **Still true and unchanged:** no automatic rollback (by measurement); the route back is the restore; a multi-major jump ends held honestly. **Not measured on this venue, and named rather than assumed:** every event and every customer mail. Guest 9202 runs `hub.enabled: false` and the notifier returns before it logs (**R-620**), so the whole "who was told" half of `08` was structurally unobservable tonight. **THE NARROWING ABOVE WAS CLOSED THE NEXT DAY, 2026-09-22 — and re-widened the row.** `audits/PROBE-FIX-2026-09-22.md`. All three wrong probes were corrected in the catalog (`app-catalog-felhom.eu@793c4fb`: tandoor `8080→80`, wger `80→8000`, zipline `/api/health→/api/healthcheck`) and **red-proofed live on 9202 through the product in both directions**: at the live pin all three read `Nem egészséges` / `Not healthy` on their own app page while docker reported every container healthy and the front door served a real page; after the real sync all three read `Fut` / `Running` with no redeploy. **tandoor's edge was then re-walked with nothing else changed and ended `done` at +41.1 s**, seed read back, where the identical edge had ended `failed` at +361.9 s with the app stopped — so the tally is now **15 proven, 2 failed, 4 inconclusive**, and all fifteen are on the live catalog. A `--fast` catalog gate (`check-probe-matches-compose.py`) now refuses a probe that does not match the same service's own compose healthcheck, with four red-proofs and ten decoys including the no-PyYAML mode CI actually runs. **WHAT THIS ROW STILL CANNOT CLAIM, and the reason is exactly R-96 rule 3:** the guard is now shown correct for **47 of 53** templates. `paperless-ngx`'s probe has **never run on any box** — no container name matches its stack name, so it is silently skipped and its badge can never go red (**R-630**); and five more cannot be judged statically, one of which (`home-assistant`) is right only because its check type cannot fail (**R-631**). An absent alarm is equally consistent with healthy and with never checked. **And the sweep's ceiling, counted: 28 of the 53 templates have never been deployed by any drill (R-632).** **THAT CEILING WAS REMOVED THE SAME NIGHT, 2026-09-22 — all 28 walked (`audits/DRILL-the-28-2026-09-22.md`), so every template in the catalog has now been attempted at least once.** 26 of 28 deployed, **6 proven**, 5 inconclusive, 14 with no within-a-major edge upstream, 1 failed honestly and 2 undeployable — one of those (`plant-it`) **by design**, refused by the product's lifecycle gate, proven live for the first time. Each app also got the half the update night skipped: a **restore from its own copy, with the seed read back again** — 21 restored, and **2 were correctly REFUSED** with the sentence `07` §6.2 predicts for a class-A app whose local copy holds no file leg. **AND THE NIGHT NARROWED THIS ROW AGAIN, in the place the probe work could not reach.** `paperless-ngx` has no container matching its stack name, so no probe is ever built for it — and `verifying` does not skip: it waits out the full `update.health_timeout` and **HOLDS**, stopping an app whose three containers all read `healthy`. The controller's own words: *`not healthy within 5m0s (last: no probe container) — stopping and HOLDING the app`*, at **+313.0 s**. **R-630, raised to P1.** So "the update is guarded" is now shown correct for 47 of 53 templates, wrong for none, and **actively harmful for the one template that has no probe at all**. **Two further limits on what this row may claim, both about STATE rather than health:** a `remove` sent while a restore is still running reports success and leaves a container restarting with a live public route (**R-633**) — while the product already refuses exactly that clash for `update` and for `restore`, naming the blocking operation; and an app can be **running, healthy and serving while recorded as `deployed: false`**, in which state the product refuses to remove it at all (**R-634**). In both, a person needed a shell to clear what the product could not. **ALL THREE ARE FIXED IN CONTROLLER v0.262.0 (2026-09-22), and the first is PROVEN LIVE.** *The stopped app:* `verifying` no longer loops on a probe that resolves to nothing — it settles on container state, the way an app declaring no check is judged, and says which it did. **Measured on paperless-ngx: the identical Update that ended `failed` at +313.0 s with the app stopped now ends `done` at +53.4 s**, with no `no probe container` warning in the log because the explicit `healthcheck.container` resolved the target. *The ghost:* `RemoveStack` consults the backup side's `Busy` guard — which the product already applied to `update` and to `restore` — and then WATCHES the compose project for 25 s after `down`, removing anything that carries its label and answering `verified: true/false`, because `down` returning 0 is a request rather than a result. *The unremovable app:* the refusal now asks whether anything EXISTS (containers, a compose file, an `app.yaml`) instead of reading a flag. **WHAT THIS ROW STILL MAY NOT CLAIM:** R-634's MECHANISM — why `deployed` goes false while containers run — **is not diagnosed**; only the consequence is fixed. And a probe can be right about the port and still wrong about what a 200 means: `romm` answered 200 from nginx for six hours while its workers were OOM-killed behind it (**R-635**). **"The update is guarded" has never meant "the new version runs".**
---
<a id="b4"></a>
## B4 — A failed update is UNDONE by the box itself — the previous version back with its data from seconds before the update; the app is held only if that undo fails too
*Map row: [B4](../00-capability-map.md#b4) · former map line 108.*
**Scenario (as written):** **A failed update is UNDONE by the box itself — the previous version back with its data from seconds before the update; the app is held only if that undo fails too**
**Components (as written):** controller **v0.263.2** (the undo), **v0.264.0** + hub **v0.120.0** (the mail)
**Status (as written):** **PROVEN-LIVE (2026-09-23)**
**Evidence (as written):** `audits/undo-live-2026-09-23/README.md` (+ `audits/undo-bakeoff-2026-09-23/` for the method). On scratch guest 9202, through the endpoints the UI invokes: docmost (PostgreSQL), romm (MariaDB) and vikunja (SQLite in a volume), each a real migrating edge failing a deliberately wrong probe, **undone in 30–52 s** with seeds written before the backup, after it and seconds before the press ALL read back through each app's front door, ledgers equal to before; page line in hu and en. Cut-off copy → HOLD (`untouched`), prefix in the box's language; power cut (`pct stop`) during `undoing` → resumed after boot and undone; a person's press after an undo → `done`; removal deletes kept copies. **NARROWED 2026-09-23 night (`audits/DRILL-night-2026-09-23.md` Part D):** a controller `kill -9` in `verifying` → resumed after restart → undone, seeds read back (round 9); a 1 GB memory hog and a whole-box backup during `verifying` did not disturb it (rounds 6, 8). **Two holes found:** after ANY restore the app's volumes lose their compose label and the undo copies NOTHING (R-658, P1); and a held FILE-LEG app is told to restore from a copy the restore then refuses, with no other route on a box without an off-site tier (R-659, P1).
**Gap / roadmap (as written):** Folder copy of NAMED volumes only (`09` §3 decision 19); bind folders never touched. **The household is told (2026-09-23, `audits/undo-fleet-2026-09-23/`):** on guest 9201 the household and the operator each received ONE mail per app per outcome — undone and held, in Hungarian (vikunja) and, after one language switch, in English (glance) — with the app named in the subject; `notification_log` rows 937–946 all `sent`. **Not built:** the automatic caller (part 7) — this protects the manual button today. Leftovers: R-647.
---
<a id="b5"></a>
## B5 — The catalog holds only TESTED steps — an image move without a proven test record (bench + box, digests the registry still serves) is refused at push time
*Map row: [B5](../00-capability-map.md#b5) · former map line 109.*
**Scenario (as written):** **The catalog holds only TESTED steps — an image move without a proven test record (bench + box, digests the registry still serves) is refused at push time**
**Components (as written):** catalog `6db08a5` (`scripts/check-test-record*.py`, `ladder.py`, `upgrade-test.py --write-ladder`)
**Status (as written):** **PROVEN (2026-09-23 night)**
**Evidence and gap (as written — this row's text carries a `|` outside code, so its last cells are kept together as they stood):** `audits/DRILL-night-2026-09-23.md` Part B/C | 16 decoy cases both ways + 3 red-proofs; the gate judged every one of the night's 12 published steps against the live registry and refused a wrong-digest control. 21 earlier moves backfilled from their records. | **Closed 2026-09-24 by the row below** (v0.268.0 reads the ladder).
---
<a id="b6"></a>
## B6 — A box behind climbs ONE tested step per press, each with its own definition (`09` §3 decision 14)
*Map row: [B6](../00-capability-map.md#b6) · former map line 110.*
**Scenario (as written):** **A box behind climbs ONE tested step per press, each with its own definition** (`09` §3 decision 14)
**Components (as written):** controller **v0.268.0** (`stacks/ladder.go`) + catalog `5ed599c` (`steps/<StepKey>.yml`, gate rule 4)
**Status (as written):** **PROVEN-LIVE (2026-09-24)**
**Evidence and gap (as written — this row's text carries a `|` outside code, so its last cells are kept together as they stood):** `audits/ladder-2026-09-24/partD/10-romm-two-steps.json` | romm 5.3.0/11.4 → 5.3.1/11.4 → 5.3.1/11.8 in two presses on 9202, seeded account read back after each; page count 2 → 1 → none; spike confirmed v0.267.0 jumped (`partD/00-spike.json`). 6 unit tests + red-proofs. | Nothing climbs by itself yet (part 7). A step has no `.felhom.yml` of its own (R-664).
---
<a id="b7"></a>
## B7 — A file app comes back WHOLE from the second drive — its files by four rules (never delete, never overwrite a newer file, bring back what is missing, keep an older replaced file beside it), then its settings and database (`09` §3 decision 26)
*Map row: [B7](../00-capability-map.md#b7) · former map line 111.*
**Scenario (as written):** **A file app comes back WHOLE from the second drive — its files by four rules (never delete, never overwrite a newer file, bring back what is missing, keep an older replaced file beside it), then its settings and database** (`09` §3 decision 26)
**Components (as written):** controller **v0.269.0** (`backup/tier2_whole.go`)
**Status (as written):** **PROVEN-LIVE (2026-09-24)**
**Evidence (as written):** `audits/night-2026-09-24/A1/21-readback.json`, `A1/30-held-then-whole.*`, `E/round-03.json`
**Gap / roadmap (as written):** nextcloud on 9202: 1 file brought back, the household's newer edit kept, 137 unchanged, 3/3 volumes + 1/1 database in 38 s; again from a hold that named the second drive, and again after a power cut mid-update (round 3). R-538's refusal of the unit-only restore stays.
---
<a id="b8"></a>
## B8 — A crash-looping or out-of-memory app is STOPPED by the box, the household and operator are told, and Start gives one more try (decision 28)
*Map row: [B8](../00-capability-map.md#b8) · former map line 112.*
**Scenario (as written):** **A crash-looping or out-of-memory app is STOPPED by the box, the household and operator are told, and Start gives one more try** (decision 28)
**Components (as written):** controller **v0.269.0**, hub **v0.123.0**
**Status (as written):** **PROVEN-LIVE (2026-09-24)**
**Evidence (as written):** `audits/night-2026-09-24/A3/21-gokapi-page-event.txt`, `A3/30-start-then-trip2.*`, `E/round-01/07/08.json`
**Gap / roadmap (as written):** gokapi stopped at 6 restarts in 10 min; Start lifted it, 9 fast restarts, stopped again with the support-informed sentence (hu + en); chaos hour: OOM storm stopped after a power cut (+185 s), a crash loop under a backup run (+116 s), a storm on a nearly full disk (+102 s).
---
<a id="b9"></a>
## B9 — A held app whose page says support is informed can only be removed KEEPING its data (decision 27)
*Map row: [B9](../00-capability-map.md#b9) · former map line 113.*
**Scenario (as written):** **A held app whose page says support is informed can only be removed KEEPING its data** (decision 27)
**Components (as written):** controller **v0.269.0**
**Status (as written):** **PROVEN-LIVE (2026-09-24)**
**Evidence (as written):** `audits/night-2026-09-24/A2/10-held-no-copy-keep-data.*`
**Gap / roadmap (as written):** the dialog reads `keep_data_only`; Remove with data or backups → 409 in both languages; the app stayed installed.
---
<a id="b10"></a>
## B10 — The box runs the exact TESTED image of a floating tag, and an installed app keeps its image until a guarded Update moves it (`09` §6.4 part 6, box half)
*Map row: [B10](../00-capability-map.md#b10) · former map line 114.*
**Scenario (as written):** **The box runs the exact TESTED image of a floating tag, and an installed app keeps its image until a guarded Update moves it** (`09` §6.4 part 6, box half)
**Components (as written):** controller **v0.269.1** (`stacks/digest.go`)
**Status (as written):** **PROVEN-LIVE (2026-09-24)**
**Evidence (as written):** `audits/night-2026-09-24/B/21-floating-tag-0.269.1.*`, `B/01-compose-accepts-digests.txt`
**Gap / roadmap (as written):** redis:7-alpine: a newer tested digest → the „Frissítés elérhető" badge, the sync left the running file alone (v0.269.0's sync did not — fixed), Update pulled exactly that digest. Compose accepts `tag@digest` for all 25 ladder apps (37 digests).
---
<a id="b11"></a>
## B11 — Automatic updates at night — the update leg (`09` §6.4 part 7)
*Map row: [B11](../00-capability-map.md#b11) · former map line 115.*
**Scenario (as written):** **Automatic updates at night — the update leg** (`09` §6.4 part 7)
**Components (as written):** controller **v0.271.0**
**Status (as written):** **PROVEN-LIVE on scratch 9202 (six simulated nights, 2026-09-24/25) — the demo boxes' first real night: see `audits/DRILL-night-2026-09-25.md` Part D**
**Evidence (as written):** `audits/night-2026-09-25/C/`, `B/redproofs/`
**Gap / roadmap (as written):** after the off-site leg on every path; one step per app per night; `needs_person` never, `files_may_change` only with a whole copy; a failed step not re-pressed until the catalog re-tests it (R-680); a power cut mid-step resumed and finished; a controller kill mid-step put back; switch off = nothing pressed; data read back after every night. **Not claimed:** W+5h with steps left and a FAILING off-site leg live (unit only), the full-system gate waiting live (9202 has no agent), resume of the leg after a restart (R-686).
---
<a id="b12"></a>
## B12 — What `restart` and `update` do to a deployed app whose compose file the catalog already moved
*Map row: [B12](../00-capability-map.md#b12) · former map line 116.*
**Scenario (as written):** **What `restart` and `update` do to a deployed app whose compose file the catalog already moved**
**Components (as written):** controller **v0.235.0**
**Status (as written):** **CHANGED 2026-09-06 — they NO LONGER upgrade it.** The row below records what shipped; this text records what it replaced, because every box under v0.235.0 still behaves the old way. **Up to v0.234.0: PROVEN-LIVE (2026-09-01) — they UPGRADE it.** Every lifecycle action ends in `docker compose up -d`, which makes the container match the file and PULLS the image itself when it is missing (measured: 18.3 s with a pull, 0.5 s without; negative control with an unchanged file did not even recreate the container). This is DELIBERATE on the restart path — `Manager.RestartStack` says so in a comment — but the syncer moves the file under a deployed app on a 15-minute cycle with no deployed check (R-438), and NOTHING tells the customer.
**Evidence (as written):** `audits/SPIKE-app-update-2026-09-01.md` §2, §3
**Gap / roadmap (as written):** **No safety copy is taken by any of them** — `writeSafetyDump` is DATABASE-ONLY and is not on the update path at all. R-438, R-440, R-443.
---
<a id="b13"></a>
## B13 — Whether the box UPGRADES an app by itself, with nobody pressing anything
*Map row: [B13](../00-capability-map.md#b13) · former map line 117.*
**Scenario (as written):** **Whether the box UPGRADES an app by itself, with nobody pressing anything**
**Components (as written):** controller
**Status (as written):** **PROVEN-LIVE (2026-09-01) — YES, but only when an app fails to come back.** A plain power cut does NOT upgrade: Docker's `restart: unless-stopped` restores the old containers and the reconciler logs `no boot-orphaned apps (nothing to start)`. When an app does NOT return, `Reconciler.Run` (`bootrecon.go:269`) calls `StartStack` -> `compose up -d` and the app comes back on the NEW version, unattended (measured). **13 non-API call sites across 9 files reach `up -d` this way** — not the five previously believed.
**Evidence (as written):** `audits/SPIKE-app-update-2026-09-01.md` §2, §8
**Gap / roadmap (as written):** The drive-return gate (`intermediary.go:222`) and `AppStopGuard.Recover` (`appstop_marker.go:283`) call the same function; located by reading, **not exercised live** — stated as such.
---
<a id="b14"></a>
## B14 — Whether an app UPGRADE can be undone
*Map row: [B14](../00-capability-map.md#b14) · former map line 118.*
**Scenario (as written):** **Whether an app UPGRADE can be undone**
**Components (as written):** controller + catalog
**Status (as written):** **PROVEN-LIVE (2026-09-01) — NO, and "rollback" is the wrong word for it.** Once a migration has RUN, putting the old image tag back yields a container that refuses to start: Nextcloud — *"the version of the data (32.0.9.2) is higher than the docker image version (31.0.14.1) and downgrading is not supported"*. A 3-major jump is refused outright (*"only possible to upgrade one major version at a time"*) and IS recoverable, precisely because nothing migrated. Positive control: the data is not destroyed — returning to 32.0.9 restored both seeded markers byte-identical.
**Evidence (as written):** `audits/SPIKE-app-update-2026-09-01.md` §7
**Gap / roadmap (as written):** The only route back is restoring DATA from a copy taken BEFORE the update — which no update path takes. And a restore's image-level rollback is itself overwritten by the syncer within 15 minutes (R-441). R-40 is confirmed live by the same measurement.
---
<a id="b15"></a>
## B15 — Protected infra stacks can't be stopped/removed from UI
*Map row: [B15](../00-capability-map.md#b15) · former map line 119.*
**Scenario (as written):** Protected infra stacks can't be stopped/removed from UI
**Components (as written):** controller
**Status (as written):** **PROVEN-LIVE**
**Evidence (as written):** `CAMPAIGN-nomercy` + `RERUN-p1p3` T-SEC-PROTECTED (refuse stop/remove, stay Up)
**Gap / roadmap (as written):** (Cited `CAMPAIGN-2` T-SEC-PROTECTED was a stale-dryrun FAIL — corrected to the runs with a real server-side refusal)
---
<a id="b16"></a>
## B16 — What VERSION a box is running, and whether it is behind the catalog
*Map row: [B16](../00-capability-map.md#b16) · former map line 120.*
**Scenario (as written):** **What VERSION a box is running, and whether it is behind the catalog**
**Components (as written):** controller **v0.234.0** + catalog `69761cf`
**Status (as written):** **PROVEN-LIVE — both the record and the rendered badge.**
**Evidence (as written):** **`tests/VALIDATION-update-slice12-2026-09-02.md`** — on demo-hp 0.233.0, through a REAL production caller (`bootrecon → StartStack → compose up -d → recordInstalledImages`, no hand-set state): `bentopdf` recorded **1** service and `bookstack` recorded **2**, keyed by compose SERVICE name, and **all three digests match the ground truth read independently from the containers before anything was touched**. `catalog_since` reached the box on the normal 15-minute sync. bookstack's two encrypted secrets are byte-identical across the write. Badge evidence: same file §4. **v0.234.0 startup backfill, PROVEN LIVE 2026-09-03 on demo-hp:** the record was stripped from `privatebin` (1 service) and `romm` (3 services) to recreate the pre-0.233.0 shape, the controller restarted, and the backfill re-seeded **exactly** those two — every digest matching the ground truth read from the containers beforehand — while logging `2 app(s) recorded, 7 already had a record, 0 left unrecorded`. **All 9 deployed apps then carried „Naprakész" on `/stacks`** (ASCII fragments with a negative control at 0). On demo-felhom the operator's own case, OpenGist, now renders the badge. **Why the backfill exists at all: without it the label never reached an app that simply runs**, which the operator found the morning after v0.233.0. Unit side: `installed_test.go` + `updatebadge_test.go`, incl. a wiring test through a real `RestartStack`, an AST walk of all four call sites, and three companion red-proofs.
**Gap / roadmap (as written):** **THE UNEXERCISED LEG, NAMED: one badge STATE of four.** „Frissítés elérhető" WITHOUT an age needs an app whose `catalog_since` is absent, malformed or future-dated, and all 53 now carry a valid one — unit-tested only (`TestGroupF`). The other three are live: „Naprakész" ×2 on `/stacks` and on `/apps/bookstack`; **NO badge at all on `/apps/docmost`**, a deployed app with no record — absent is UNKNOWN and is not rendered as current; and „Frissítés elérhető — **52 napja**" on both surfaces, the age being real arithmetic on bentopdf's `catalog_since` 2026-07-12. **The behind state was staged by editing bentopdf's compose tag ONLY** — no container restarted, no `up -d` — then reverted byte-identically (`sha256` equal, `diff` empty); so the RENDER is measured and the syncer's own half stays measured separately in the spike. Searched with `grep -oF` ASCII fragments plus positive AND negative controls. **The Frissítés/Újraindítás/Leállítás buttons are unchanged on the live page in the behind state.** **Absent means UNKNOWN, never current** — a legacy `app.yaml` renders NOTHING, red-proved. **No version number is shown to the customer** and **no registry is queried**, so „Naprakész" CAN BE FALSE for the 23 floating pins (**R-446**). Nothing about updating changed: R-438, R-440, R-441, R-443 all stand. Reasoning: `architecture/09-update-architecture.md`; remaining slices R-447..R-452
---
<a id="b17"></a>
## B17 — An app's VERSION is frozen to what the customer has; only a deliberate Update moves it, while template CORRECTIONS still arrive
*Map row: [B17](../00-capability-map.md#b17) · former map line 121.*
**Scenario (as written):** **An app's VERSION is frozen to what the customer has; only a deliberate Update moves it, while template CORRECTIONS still arrive**
**Components (as written):** controller v0.235.0
**Status (as written):** **PROVEN-LIVE (2026-09-06)** — by two REAL catalog pushes travelling the REAL 15-minute cycle, not a hand-edited file
**Evidence (as written):** **`tests/VALIDATION-update-slice3-2026-09-06.md`** — a non-image catalog change REACHED the pinned app (08:01:51Z) with the container untouched; an image change did NOT (08:20:29Z); and the restart afterwards took **0.1 s, did not recreate the container, and never pulled the new image**, against the spike's **18.3 s with a pull** for the identical sequence before. The Update button still moved the version (pin advanced 17 s BEFORE the pull completed) and the teardown update returned the container to the baseline digest byte for byte. The freeze holds in BOTH directions. Unit side: `internal/sync/render_test.go` (the whole render table, incl. self-healing in BOTH branches), `internal/stacks/pin_test.go` (adoption never guesses; the update advances the pin BEFORE the pull), `TestGroupG` (the badge reads the catalog, not the frozen file), `TestGroupH` (an AST walk of `cmd/controller/main.go` asserting the seam, adoption, and their ORDER against `syncer.Start()`). **Three companion red-proofs**, each run, failing, and reverted.
**Gap / roadmap (as written):** Operator ruling 2026-09-06, Option 1 (`architecture/09-update-architecture.md` §3.4). **Nothing was added to the thirteen `compose up -d` call sites** — most are repairs, and a repair that refuses to repair leaves an app down; they were made safe by removing the reason. `pinned_images` is INTENT, `installed_images` is an OBSERVATION — never fed from each other (R-166, one field over). **Known limitations, all recorded rather than fixed:** a frozen app is frozen WHOLE (§8.4); `.felhom.yml` keeps flowing, so a frozen app can get a probe for a newer version — false alarm, never data loss (**R-458**); and **the Update button is still unguarded** (R-448 is slice 4). Closes R-447, R-441, R-438
---
<a id="b18"></a>
## B18 — Catalog sync (git, 15 min) + orphan lifecycle + validation choke point (bad `backup:` block degrades to legacy, loudly)
*Map row: [B18](../00-capability-map.md#b18) · former map line 122.*
**Scenario (as written):** Catalog sync (git, 15 min) + orphan lifecycle + validation choke point (bad `backup:` block degrades to legacy, loudly)
**Components (as written):** controller v0.132, catalog
**Status (as written):** **PROVEN-LIVE**
**Evidence (as written):** `CAMPAIGN-2` T-SYNC-IDEMPOTENT; v0.132 LoadMetadata red-proofs
**Gap / roadmap (as written):**
---
<a id="b19"></a>
## B19 — Lemez-egészség felügyelet: per-disk SMART kártya („Lemezek állapota") + degradáció-riasztás (Rendben/Figyelmeztetés/Hiba/Nincs adat)
*Map row: [B19](../00-capability-map.md#b19) · former map line 123.*
**Scenario (as written):** Lemez-egészség felügyelet: per-disk SMART kártya („Lemezek állapota") + degradáció-riasztás (Rendben/Figyelmeztetés/Hiba/Nincs adat)
**Components (as written):** agent v0.94.0→**v0.95.0**, controller v0.169.0→v0.171.0→**v0.215.0**, hub v0.73.1
**Status (as written):** **PROVEN-LIVE (healthy path + delivery + the severity wire).** **IMPLEMENTED, NOT proven-live: the Hiba-from-counters path** (v0.215.0) — it has never fired on real hardware, only against the committed fixture's values in unit tests (**R-332**)
**Evidence (as written):** **2026-07-25 (v0.95.0 + v0.171.0 — the SMART-coverage fix):** the card on guest 9201 now shows BOTH real disks with **real verdicts + human model labels** — **„AirDisk 512GB SSD" → Rendben (34°C)** (the system SSD, via LVM/dm resolution) and **„TOSHIBA MQ04ABF100" → Rendben (30°C)** (the USB, via union-path SMART). `/disks` carries `smart.health=PASSED` + `model_name` for both. This reverses the 2026-07-24 „Nincs adat on a raw UUID" state (`SPIKE-smart-coverage-2026-07-25.md` had proven both disks answer `smartctl -a -j` PASSED but the agent never asked). Prior: verdict table (+≥90 red-proof); check first-run/degradation/recovery/UNKNOWN tests; hub allowlist test. **Notification pipeline PROVEN-LIVE 2026-07-24** — a `disk_health_degraded` POST (the exact `notify.PushEvent` wire call) was **400-rejected by hub v0.73.0** and **200-accepted + „Operator email sent" by hub v0.73.1**
**Gap / roadmap (as written):** No new smartctl load; feature-detect by payload presence → **MinAgent floor unchanged**; no sudoers/`-d sat` change. **No global banner** (deliberate). Agent v0.95.0 fixes: union-path SMART (Fix B) + LVM/dm whole-disk resolution incl. the builtin `local` on the LVM root (Fix A, SMART-only — never touches backing/durable_id) + `model_name` capture. **2026-08-14 — a genuinely failing disk HAS now been seen, and it broke three assumptions** (`audits/DIAG-smart-passed-trap-2026-08-14.md` + two committed fixtures: raw `smartctl -a -j` and 406 `smartd` lines from ST3000VX010 S/N Z6A07P2G). **(1)** `smart_status.passed` is STRUCTURALLY incapable of failing on unreadable sectors — attrs 187/197/198 all carry `thresh: 0` and a normalized value floors at 1 — so the drive read PASSED at 352 pending sectors and 1001 uncorrectable reads. **(2)** The alert it did produce carried severity `"warn"`, which the hub coerces to `info` and never emails: **the counterfactual is ZERO emails about this drive** (R-328, fixed controller v0.215.0, and the `warning`-vs-`warn` pair proven side by side in `notification_log` on 2026-08-14 — `sent` vs no row at all). **(3)** The old check spoke once and forgot on restart, so between 8 and 352 sectors it emitted nothing. v0.215.0 adds the sustained/count/heat Hiba rules, persisted state and an hourly cadence. **The verdict half of that arm remains unit+red-proof covered only** — no live drive has reached Hiba from counters (R-332). **SMART history/trending (hub-side) PARKED** (ROADMAP R-73)
---
<a id="b20"></a>
## B20 — App crashes → customer notified (one event per transition, no flapping spam)
*Map row: [B20](../00-capability-map.md#b20) · former map line 124.*
**Scenario (as written):** App crashes → customer notified (one event per transition, no flapping spam)
**Components (as written):** controller v0.120, hub v0.48
**Status (as written):** **IMPLEMENTED**
**Evidence (as written):** controller v0.120.0 (dead-app alerting, `app_start_failed`, one-event-per-transition red-proofs); `CAMPAIGN-3` F11 surfaced the gap
**Gap / roadmap (as written):** End-to-end crash→customer-email delivery never live-confirmed (6B deferred / 6C inconclusive: clean stop ≠ crash); anti-spam unit-proven
---
<a id="b21"></a>
## B21 — Post-deploy optional config (API keys etc.) with restart
*Map row: [B21](../00-capability-map.md#b21) · former map line 125.*
**Scenario (as written):** Post-deploy optional config (API keys etc.) with restart
**Components (as written):** controller, catalog `.felhom.yml`
**Status (as written):** **IMPLEMENTED**
**Evidence (as written):** feature long-standing; config page renders (`CAMPAIGN-2` T-PAGE-ALL is GET-only)
**Gap / roadmap (as written):** The config-save+restart flow is exercised in no campaign (CAMPAIGN-3 explicitly skipped interactive app config). Demoted: T-PAGE-ALL is a page-render smoke test, not this flow
---
<a id="b22"></a>
## B22 — Backup classification: 13 bind-bearing apps carry mandatory/optional/excluded classes
*Map row: [B22](../00-capability-map.md#b22) · former map line 126.*
**Scenario (as written):** Backup classification: 13 bind-bearing apps carry mandatory/optional/excluded classes
**Components (as written):** catalog, controller v0.132–133
**Status (as written):** **PROVEN-LIVE**
**Evidence (as written):** `SPIKE-backup-classification-2026-07-14`, `CAMPAIGN-6D/6E`
**Gap / roadmap (as written):** Remaining ~39 apps are legacy-class by design (unit-only offsite)
---