docs(report): TASK-D Parts 1-2 — R-51/R-52 shipped, image built, STOP legs pending

This commit is contained in:
2026-07-21 12:39:13 +02:00
parent 285dd1032f
commit 984ea8c8bd
+144 -209
View File
@@ -1,254 +1,189 @@
# REPORT — R-48 restore wizard (v0.154.0) + the v0.155.0 follow-up fix; STOP-1 BANKED; Part 3 STOPPED # REPORT — TASK-D Parts 12: dead-primary alerting (R-51) + boot reconciliation (R-52)
Session date: **2026-07-21**. Executed on DooPlex as `kisfenyo`. **Controller v0.155.0 → v0.156.0.** Session 2026-07-21, on DooPlex as `kisfenyo`.
**Status: code SHIPPED and image published; the three live STOP legs are operator-present and have
NOT run.** Nothing below claims live behaviour that was not observed.
## 0. Outcome at a glance ---
| Leg | Status | ## 1. Baselines (re-confirmed at session start)
|---|---|
| **Part 1 — R-48 restore wizard (v0.154.0)** | **SHIPPED** `3a9d744`, image published, deployed via the floor save. |
| **v0.155.0 — follow-up fix** | **SHIPPED + DEPLOYED** `9d1b498`. Fixes a real defect in v0.154.0 found by the operator on the first live click-through. |
| **Part 2 — capability-map cell** | **DONE** `ce8c539`. |
| **STOP-1 — floor save + single-fire** | **BANKED.** Exactly one swap over a 39-min window, no rollback, healthy. §6. Doc rows flipped `11ead4b`. |
| **Live click-through + Ellenőrzés** | **DONE by the operator**, and re-run endpoint-level after the v0.155.0 fix. §7. |
| **Part 3 — agent 0.90.1** | **STOPPED, ruled.** Not published, not deployed. §8. |
## 1. Baselines | Repo | `main` @ start | clean | now |
| Repo | start | end |
|---|---|---|
| felhom-controller | `b30e2e5` | `9d1b498` (v0.155.0) |
| felhom.eu | `fb0b8c1` | `c35da9d` |
| felhom-agent | `8c55ac7` | **unchanged — no commit, no publish, no deploy** |
## 2. Phase-0 probes
**P1 — self-update wiring: PASS.** `self_update.enabled: true`, image correct. `auto_update: false` is
irrelevant and this was verified by reading the code, not assumed: `MaybeAutoUpdate` never consults
`cfg.AutoUpdate` — that flag is the customer's opt-in to chase *latest*, while the FLOOR path is the
managed one. Stronger evidence: `update-state.json` already recorded a prior `initiated_by: auto-floor`
success (0.143.0 → 0.145.0, 2026-07-18), which proves the agent swapper is wired. Anti-flap could not
block, since the persisted target was `0.145.0`. `0.154.0` verified as the highest registry semver tag,
so the `floor <= latest` gate would pass.
**P2 — agent version: PASS, and it changed the plan.** Built at `main`, self-reported
`felhom-agent 0.90.1`, sha256 `ba1d0296…`, 13 734 067 bytes. But see §8.
**P3 — before-state: CAPTURED** (endpoint-level; no browser on DooPlex). The offsite section rendered
**6× `/backup/offbox/restore`, 1× `place`, 1× `reconstitute`** across three apps. For immich the two
decisive controls were adjacent siblings differing only by label:
```
Helyreállítás az élő adatok közé (csak a hiányzó fájlok) <- data CANNOT come back
Teljes visszaállítás (fájlok + adatbázis) <- data CAN come back
```
## 3. v0.154.0 — the wizard (`3a9d744`)
One „Visszaállítás…" entry per app row → `GET /backups/restore/app?name=<app>`; three intent cards
with consequence sentences; danger styling and the R-43 double-confirm carried over verbatim; pure
`deriveWizardStep`; **no new mutation endpoint**; works without JavaScript. `internal/backup`,
`internal/appbackup`, `internal/selfupdate`, the shares/local/.fab blocks all untouched.
**Latent bug fixed on the way:** `offboxRedirectTo` hardcoded `"?"` when appending its flash, which
against the wizard's `?name=<app>` target would have buried the flash inside the app name.
**Group-B red-proof (run, reverted):** trivial always-INTENT impl → **all 7 table rows FAILED** plus
the execution render test. Restored, green, `git diff` clean before commit.
Also caught one of my own assertions being hollow during authoring: a bare substring check for the
skew/empty warnings passed on a *clean* pair, because the `confirmFullRestore` JS repeats both
sentences as string literals. Tightened to assert rendered banner markup + `data-restore-*`.
## 4. v0.155.0 — the defect v0.154.0 shipped (`9d1b498`)
**Found by the operator on the first live click-through**, from the phase strip disagreeing with the
progress banner on the same screen. This is the most important finding of the session.
`backup.Manager` carries **two** different "is something running" booleans:
| flag | read by | set by | covers the verification restore? |
|---|---|---|---| |---|---|---|---|
| `running` | `IsRunning()` | `acquireRunning()`, **inside** the goroutine | **no — `RestoreOffboxScratch` never acquires it at all** | | felhom-controller | `0f9b29a` | yes | `285dd10` (v0.156.0) |
| `opRunning` | `RestoreStatus()` | `BeginRestoreOp()`, in the handler, synchronously | yes, all four offsite actions | | felhom-agent | `08b55a1` | yes | `98adb72` (v0.92.1) |
| felhom.eu | `50a7ffa` | yes | docs commit (see §9) |
v0.154.0 sourced `OpRunning` from `IsRunning()`. Verified by reading the call sites: Image: `gitea.dooplex.hu/admin/felhom-controller:0.156.0`, pushed, **deliberately not hand-deployed**
`PlaceOffsiteRestore` and `ReconstituteFromOffsite` do acquire; **`RestoreOffboxScratch` does not**. — the floor save at STOP-3 deploys it, which banks another single-fire self-update datapoint free
So for „Ellenőrzés" and the full-restore preparation — the wizard's two most-used and longest actions, (the R-23 pattern). 9201 still runs 0.155.0.
both streaming from restic — the execution step was **unreachable**. The page offered all three
intents with live buttons while a restore ran; the handler would have refused any of them. That is
precisely the "offering a control guaranteed to fail" dishonesty R-48 exists to remove, shipped inside
the release that removes it.
**Fix:** `restoreOpInFlight(st)` behind a documented seam, fed by a **single** `RestoreStatus()` read ---
per render, so the strip, the suppression decision and the running-op name cannot diverge. The helper
sits on the handler's actual path — deliberately, so a future edit reintroducing `IsRunning()` fails
the test rather than passing it.
**Why v0.154.0's tests missed it — the generalisable lesson.** The Scenario-E table proved ## 2. The defect R-51 actually fixes (the ROADMAP row was wrong)
`deriveWizardStep` behaves correctly *given* `OpRunning=true`. Nothing proved the handler ever
*computes* `true`. **A table test over a pure function proves the function, not the caller.** The new
`TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` drives a real `Manager` through
`BeginRestoreOp` and asserts the render.
**Red-proof (run, reverted):** restoring the v0.154.0 `IsRunning()` shape → The row says aggregation classifies a dead-primary stack `unhealthy`, and that `IsDownState`'s
deliberate `unhealthy` exclusion is therefore what suppresses the alarm. **That is not what the code
did.** `aggregateState`'s final branch was:
``` ```go
--- FAIL: TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag // Mix (some running, some stopped) — report as running (partial)
restore_wizard_test.go:367: a started restore op MUST read as in-flight for display if running > 0 { return StateRunning }
(this is the v0.154.0 bug)
``` ```
**„Eredmény" was a dead label.** It never lit in v0.154.0. The strip's highlight is now its own So the immich stack read **running**, not unhealthy. `IsDownState` was never consulted about
derived `Phase`, separate from `Step`: a finished restore is back on the intent step (everything `unhealthy` at all, and the constraint the row protects was never in tension with the fix. The
available again) while the strip reads „Eredmény" and an outcome card shows the result. Bounded by correction is recorded in the ROADMAP row itself.
`restoreResultWindow` (10 min) so a stale result cannot look fresh, and **bound to the app** so a
finished bookstack restore cannot light immich's page with bookstack's message. The card survives a
reload; the flash does not. 9-row table test incl. both boundary directions and a future-clock-skew row.
## 5. Test + gate results **The fix.** New `StateDegraded`. The mixed branch asks each DOWN member for its docker restart
policy: `always` / `unless-stopped` → docker was supposed to be keeping it up → the stack is
**degraded**; `no` / `on-failure` → a finished one-shot init/migrate container → benign, stack stays
running. `IsDownState` gains `degraded` **and nothing else**`downstate_test.go` is untouched and
green, which is the fix-3 contract.
Full suite green at both commits: `go build ./... && go vet ./... && go test ./...` — 0 failures. **An unreadable policy counts as supervised — fail-CLOSED**, deliberately the opposite of
All six controller design gates rc=0. `IsDownState`'s documented fail-open. They are different questions: there the *state* is ambiguous,
here a member is known dead and only its excuse is missing. P2 backs it (§4).
| Group | Test | Result | **Blast radius, decided per call site** (`StateDegraded` is a stack-level aggregate and never a
container state, so container-level code is untouched by construction):
| Question the caller asks | Sites | Degraded counts as |
|---|---|---| |---|---|---|
| B | `TestDeriveWizardStep_Table` (9 rows) | PASS | | "are there live containers to stop?" | quiesce `RunningAppStacks`, delete stop-first guard ×2, export `IsStackRunning`, telemetry, health-probe eligibility | **up** |
| C | `TestResolveWizardApp_Refusals`, `TestRestoreWizardHandler_UnconfiguredRedirects` | PASS (302, no 500) | | "is this app working?" | dashboard counter, stopped filter, dead-app banner + `app_start_failed` | **down** |
| A | `TestRestoreList_SingleEntryPerApp` | PASS | | "did this come back healthy?" | `waitHealthy` (migration), post-restore `RefreshAndIsRunning`, integrations readiness | **not healthy** (left strict) |
| C | `TestRestoreWizard_ThreeIntentCards`, `..._NoScratchLocksDataIntents` | PASS |
| E | `TestRestoreWizard_OpRunningSuppressesAllMutations` | PASS |
| D | `TestRestoreWizard_NoNewMutationEndpoints`, `..._FieldContract` | PASS |
| **new** | `TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag` | PASS (red-proofed) |
| **new** | `TestHasRecentRestoreResult` (9 rows), `TestRestoreWizard_ResultCard` | PASS |
Two pre-existing tests coupled to the old IA were **inverted, not deleted**: `TestAppRow_RestoreLists` The health probe can no longer mask it: the probe result only ever overrides `StateRunning`, so a
and `TestBackupsSplit_SectionsOnExactlyOnePage` now assert the mutation forms are ABSENT from the list. failing probe on a degraded stack cannot downgrade it to `unhealthy` (which would silence the alarm
again). Asserted by the wiring test's second refresh.
## 6. STOP-1 — single-fire self-update: **BANKED** ---
Operator saved global floor → v0.154.0. Timeline (host CEST / guest UTC, 2 h offset): ## 3. R-52 — the recovery half
``` `internal/bootrecon`, one bounded sweep 5 s after startup (after the quiesce recovery, so the two
06:57:13Z UpdateState "pending" initiated_by=auto-floor never race for the same stack): at most **2 attempts, 30 s apart**, then it stops and the alarm owns
06:57:17Z agent WARN controller swap requested 0.153.0 -> 0.154.0 the problem. Never a loop. `compose up -d` exits 0 on a crash-loop, so success is decided by a fresh
06:57:19Z agent INFO image file written, restarting bootstrap `RefreshStatus`, never by a nil error.
06:57:21Z container StartedAt · UpdateState completed_at
06:57:29Z agent INFO new controller healthy
```
| Assertion | Result | **The safety argument is the container gate.** The UI's Stop is `compose down`, which REMOVES the
|---|---| containers; a boot interruption leaves them behind as `Exited`. So "deployed, has containers, and
| `controller swap requested` | **1** | they are down" is the boot-orphan signature, and a zero-container stack is never touched.
| `image file written, restarting bootstrap` | **1** |
| `new controller healthy` | **1** |
| rollback / swap-failed / unhealthy | **0** |
| container `RestartCount` | **0** |
| bootstrap unit `Result` | `success` |
> **Correction — I over-claimed one line.** I originally cited the bootstrap unit's `NRestarts=0` as Worst case 5 s + 30 s = 35 s < the 90 s `deadAppBootGrace`, so a successful recovery never alerts
> no-storm evidence. It isn't: the unit is `Restart=no`, so `NRestarts` is 0 by construction and would and a failure alerts honestly. `TestBootReconcileFitsInsideTheBootGrace` asserts that arithmetic
> read 0 during a storm too. Dropped. The real no-storm evidence is the agent-side swap count and the instead of leaving it in a comment.
> container's own `RestartCount=0`.
`UpdateState` persisted `status: success`, `0.153.0 → 0.154.0`, `initiated_by: auto-floor`. ---
`VerifyStartup` banked it on the next boot (`Post-update startup: update successful`), and the 06:57:52
periodic check reported `Current version 0.154.0 is up to date` — the anti-flap correctly doing nothing
now that current ≥ floor. **Exactly one swap, no storm, no rollback.** This closes the leg R-23(a) was
missing: the earlier floor save proved nothing because it targeted an already-running version.
**30-minute no-re-attempt check — PASSED, and my earlier caveat was broader than the facts.** Recounted ## 4. Phase-0 probes
over 06:50 → 07:29 (39 min): `controller swap requested` = **1**, agent-driven bootstrap restarts = **1**.
I hand-deployed v0.155.0 at `07:17:10Z`, inside the observation window, and flagged that as **P2 (R-51 one-shot census) — DONE, and the answer is clean.** All **53** catalog templates,
contaminating the result. Having checked the scope: it contaminates **`StartedAt` only**. A hand-deploy **78** services: **every single one is `unless-stopped`**. Zero services with `no` / `on-failure` /
writes `/etc/felhom-controller-image` and restarts the unit directly via `pct exec` — it never goes absent. (The other 30 compose files under `output/` are generated artifacts, not templates.) So the
through `SwapController` — so the swap-count assertions, which are the actual evidence, hold restart-policy guard protects a case that does not exist in today's catalog — it is there for future
uncontaminated across the whole window. I was right to flag it and wrong about how much it cost; the templates and for the fail-closed decision above, and it costs one cached `docker inspect` per down
window is clean, not merely the 19 minutes I claimed. member of a mixed stack.
**An unplanned bonus confirmation came with it.** After the hand-deploy the box ran **0.155.0 against a **P1 (why `unless-stopped` did not resurrect immich/calibre-web) — NOT RUN, and deliberately not a
0.154.0 floor** — i.e. above the floor — and the updater logged `Current version 0.155.0 is up to date` blocker.** It requires stopping a container on a live guest and rebooting it: STOP-1's territory,
and did nothing. That is Scenario B ("at/above floor does NOTHING — must not chase latest") confirmed operator-present. The reconciliation is correct either way — if Docker did record them as
live, which no test in this session set out to produce. user-stopped, `unless-stopped` will never bring them back and only R-52 can; if it did not, R-52 is
a harmless no-op because the containers are already up when the sweep looks. Recorded as an open
question, not a dependency. **It can be answered for free during STOP-1**, whose R-52 leg reboots
9201 with containers stopped — precisely P1's experiment.
## 7. Live validation **P3/P4** were agent-side — see `felhom-agent/REPORT.md`.
Method: **endpoint-level** (no browser on DooPlex) plus the operator's own UI click-through. ---
**Operator, in the browser (v0.154.0):** list page → one „Visszaállítás…" per app; wizard → three ## 5. Tests and red-proofs
cards, pair-honesty panel showing `adatbázis-mentés ideje: 2026-07-19 12:28`; ran one **Ellenőrzés**;
result `A(z) immich visszaállítva ellenőrző mappába: /mnt/felhom-drives/hdd_1/backups/offsite-restore/immich
(a meglévő adatok változatlanok).` Non-destructive as designed.
**Endpoint-level, after the v0.155.0 fix — the proof the defect is gone.** A real Ellenőrzés run Green gate: `go build ./... && go vet ./... && go test ./...`**all 25 packages ok** (full run,
through the actual endpoint: `-count=1`; `internal/backup` alone takes 174 s).
| moment | phase strip | `<form>` count | note | New: `internal/stacks/degraded_test.go` (8 cases incl. two production-path tests),
`internal/bootrecon/bootrecon_test.go` (8 cases), `cmd/controller/bootrecon_wiring_test.go` (4).
| # | Red-proof | Mutation | Result |
|---|---|---|---| |---|---|---|---|
| before | Előkészítés | 3 | intents offered | | A | dead primary must alert | mix branch → `return StateRunning` | **FAIL ×3**`aggregateState = "running", want "degraded"`; both production-path tests report `immich state = "running"`. Restored, green. |
| **mid-flight** | **Végrehajtás** | **0** | `Jelenleg egy mentési vagy visszaállítási művelet fut (immich).` | | C | the boot sweep must be wired | `go runBootReconcile(...)` commented out in `main()` | **FAIL** `func main() no longer starts the R-52 boot reconciliation`. Restored, green. |
| after | **Eredmény** | 3 | result card names the scratch path; intents available again | | D | a user-stopped app must stay stopped | zero-container gate dropped from `isBootOrphan` | **FAIL ×2**`StartStack("jellyfin") called 1 times, want 0 — a deliberate Stop must survive a reboot`. Restored, green. |
| bookstack, same moment | Előkészítés | — | **no** result card — app-binding holds |
Under v0.154.0 the mid-flight row would have read `Előkészítés / 3 forms`. Before/after on the list **A finding from red-proof C, worth more than the fix it guards.** That wiring test was first
page: **6 restore + 1 place + 1 reconstitute forms → 0**, and 3 wizard entries. Refusal path: unknown written as `strings.Contains(src, "go runBootReconcile(ctx, stackMgr, logger)")`. Its red-proof
app → **302** + Hungarian flash, no 500. **passed** — because commenting the call out leaves the string in the file. A test whose own
red-proof cannot fail it is decoration. Rewritten as an AST walk over `func main()`'s body for a
`GoStmt` calling `runBootReconcile`; the red-proof then failed as it must. Recorded in REUSE.md and
promoted into `documentation/PROMPT-TEMPLATE.md` §10 (Part-5 rider).
Deploy verified: `felhom-controller:0.155.0 | Up (healthy)`, `RestartCount=0`. **Seam discipline (§9 rule 6).** Both features have a production-path test:
- R-51: `TestRefreshStatus_WiresDegradedThroughTheRealPath` drives the whole real chain —
`RefreshStatus``docker ps``aggregateState``docker inspect` — through a new `execFn` seam
at the process boundary, and asserts the inspect COUNT (exactly 1: only the down member of the
mixed stack, never the healthy stack; still 1 after a second refresh, proving the cache).
- R-52: the sweep is asserted from `package main`, plus the AST wiring test above.
## 8. Part 3 — STOPPED, then ruled ---
Part 3 asked me to publish + deploy v0.90.1 as "R-39's cheap half — the PBS wrapper argv fix", while ## 6. Live validation
Rule 12 forbade touching the wrapper. Both cannot hold, because **the fix IS the wrapper**:
- **`9596d5a` changes zero non-test Go files** — `CHANGELOG.md`, `REPORT.md`, `configs/felhom-pbs-apply`, **None claimed for R-51/R-52.** The three legs are destructive and operator-present. The available
`internal/pbsdr/manager_test.go`. Its own message: *"the Go binary is unchanged."* method here is endpoint-level (no browser on DooPlex); these legs additionally need a container kill
- **felhom-pve already has it** — wrapper hotfixed 2026-07-18, `args=(--fingerprint "$fp")` at L107, and a guest reboot on the production demo box, so they are STOP-1, not a CC action.
`.bak-20260718-preR39` retained.
- **New hosts get it too, unversioned** — `felhom-host-install.sh:1914` `fetch_raw` pulls the wrapper
from `raw/branch/main`, and `9596d5a` is an ancestor of `main`.
Publishing would have delivered no behaviour change, restarted a production agent at a remote site for What IS verified without the box: the full green gate, all four design gates
nothing, and — once the Day-0 manifest was saved to 0.90.1 — advertised a versioned fix the artifact (`template_id_gate.py`, `emoji_gate.py`, `native_confirm_gate.py`, `offbox_rename_gate.py` — all
channel never carried. Registry pre-check confirmed and left as found (0.90.1 → **404**, 0.90.0 → 200). OK), and the image build+push.
**Operator ruling: leave 0.90.0 published; record the closure in the docs.** Done in `ROADMAP.md` ---
(`c35da9d`). The stop surfaced a genuine new defect, filed as **R-50b**: a root-owned privileged host
artifact is delivered unversioned from `main`, absent from the Day-0 manifest, so "which wrapper is on
this host?" is unanswerable from any manifest.
## 9. Doc state ## 7. UI surfaces (Hungarian)
- Capability map row 61 → **PROVEN-LIVE** (`ce8c539`). New state label „**Részlegesen leállt**", `warn` colour token, `◑` icon (shared with unhealthy),
- ROADMAP: **R-48 → SHIPPED**; R-39 cheap half recorded as closed-in-field with the publish cancelled; counted with the stopped apps, filtered into the stopped bucket, and `routeUnpublished` → true (when
**R-50b** filed (`c35da9d`). the dead member is the routed one Traefik withholds the route and the URL 404s — the card must not
- **FLIPPED** (`11ead4b`): R-23(a) → **BANKED in full** (only the cosmetic (b) leg remains), and the imply the app is reachable). **No new customer strings were needed for the alarm itself**: the
capability-map row "Config/state change round-trips in **seconds**" → **PROVEN-LIVE (2026-07-21)**. existing `app_start_failed` sentence („Telepített alkalmazás nem fut: …") is reused unchanged, which
That row's residual PARTIAL was explicitly "the R-23 operator-UI save→apply leg alone", which §6 is why R-51 ships without touching the notifier.
closes. Both rows carry the hand-deploy disclosure and the bonus at/above-floor confirmation.
## 10. Remaining — human One honest wart carried forward, not introduced: the dead-app banner appends the raw state in
English — it will now read `(degraded)` where it read `(stopped)`. Pre-existing behaviour; changing
it is a copy decision, not part of this fix.
1. **Floor → v0.155.0** at your convenience, so the fleet matches the box (9201 is hand-deployed and ---
above floor, so nothing is pending on it).
2. **TASK-B** ruling (R-39 primary half: the descriptor-hash re-apply gap).
3. **R-50b** ruling — recommendation is (a): record the wrapper's sha256 in the Day-0 manifest and have
the agent report the installed file's hash, so drift becomes visible.
4. **C6 customer-restore pass** for the alpha.
## 11. Observations ## 8. A workspace trap found the hard way (worth more than this task)
- **The most valuable finding came from the operator, not the tests.** The phase strip he questioned `controller/.gitignore` line 7 is `controller` — intended for the built binary. It also matches the
was the visible symptom of a real bug — the strip and the progress banner disagreeing on one screen. **directory** `controller/cmd/controller/`. Two consequences, both live today:
A UI element that renders derived state is an assertion the customer can check; that is worth more
than its decorative cost. 1. **ripgrep (and therefore the Grep tool) silently skips `cmd/controller/main.go`** — the entire
- **`docker_run_volume_path_gate.py` fails at `HEAD`, unrelated** (`internal/appexport/estimate.go:179`). production wiring file. Early in this session that produced a false "the whole fix-3 dead-app
Verified pre-existing by stashing and re-running. Left alone — but that gate is currently not a path has no production caller" reading — exactly the inert-seam conclusion this task is about.
usable signal. `grep -rn --no-ignore` / plain `grep -rn` (without `--include`) shows it.
- **`opstatus.go` says in its own header that it is the DISPLAY surface** and does not gate 2. **New files under `cmd/controller/` need `git add -f`.** `bootrecon_wiring_test.go` was refused
concurrency. I read the concurrency flag anyway. The file told me; I did not check. by a plain `git add` — as part of a multi-path add, i.e. quietly. A wiring test that never
- **`felhom-pve` is up and healthy** (agent 0.90.0, `active`) — the "agent is DOWN" TEMPORARY block in reaches the repo is the same defect class it exists to prevent.
`felhom-controller/CLAUDE.md` is stale and should be trimmed on return.
- **P3's accented-grep trap fired as documented** — used a local Python pass over fetched bytes rather Recorded in the commit message and REUSE.md. Worth a `.gitignore` fix (anchored `/controller`) as
than grepping accented patterns through `ssh → pct exec → bash -c`. its own XS change — deliberately not smuggled into this task.
---
## 9. Deliverables
- `285dd10` — v0.156.0 code + tests + CHANGELOG + REUSE + README.
- Image `felhom-controller:0.156.0` pushed; **not deployed**.
- Docs in `felhom.eu`: ROADMAP R-51/R-52 → SHIPPED (with the corrected R-51 diagnosis), new R-54
row, a new capability-map row (IMPLEMENTED — not PROVEN-LIVE, the live legs have not run), and the
two Part-5 riders.
## 10. Operator actions outstanding
1. **STOP-1** (controller legs) — needs 0.156.0 live; see the ordering question in
`felhom.eu/REPORT.md` §5.
2. **STOP-3** floor → `0.156.0`, which is also what deploys it.
3. P1's answer falls out of STOP-1's reboot leg for free — worth recording either way.