# REPORT — v0.235.0, update arc slice 3: freeze the version, keep the fixes flowing (2026-09-06) *Overwritten each run. This records the most recent implementation only.* > **Read this first — one claim in the task was wrong, and one thing this run FOUND that the task did > not anticipate.** > > **The task's §7 Scenario B is incomplete, and the live run is what showed it.** It asks that a > catalog version move freezes the app to "the **stored applied definition**", and says nothing about > keeping that store current. But the store is written when the PIN is written, so a fix delivered > afterwards (Scenario A's own case) lands in the live file and **not** in the store — and the first > freeze then reverts it. **Observed live at 08:20:29Z**: the frozen file came back with > `interval: 30s`, nineteen minutes after `45s` had been delivered. Left unfixed, slice 3 would have > silently undone the half of the ruling that says fixes keep flowing. **The equal-images branch now > refreshes the store as it delivers.** Everything else in §5's symbol table was accurate; the > remaining corrections are in §10. --- ## 1. Confirmed baselines — none had moved | repo | task's baseline | found | |---|---|---| | felhom-controller | `998aa319588f` | `998aa319588f` | | felhom.eu | `bc47dd4ef997` | `bc47dd4ef997` | | app-catalog-felhom.eu | `8220f8d82e53` | `8220f8d82e53` | | felhom-agent | — | untouched | Highest `R-` id: **457**, confirmed. Minted **R-458**. ## 2. Files created and modified **Created:** `controller/internal/stacks/pin.go`, `controller/internal/stacks/pin_test.go`, `controller/internal/sync/render_test.go`. **Modified:** `controller/internal/stacks/deploy.go` (the `PinnedImages` field + the deploy writer), `controller/internal/stacks/manager.go` (`Stack.CatalogImages`, `ScanStacks`, `UpdateStack`), `controller/internal/sync/sync.go` (the seam + the render table), `controller/internal/web/updatebadge.go`, `controller/internal/web/updatebadge_test.go`, `controller/cmd/controller/main.go` (the seam, adoption, and the startup ordering), plus `CHANGELOG.md`, `CONTEXT.md`, `REUSE.md`, `controller/README.md`. ## 3. Commits pushed to `main` | repo | commit | what | |---|---|---| | felhom-controller | **`8a0e0a59adc7`** | v0.235.0 — the pin, the render, adoption, the badge fix | | felhom-controller | **`2a56f557d048`** | the stored definition must follow a delivered fix (found live) | | app-catalog-felhom.eu | `dc7e548` / `09b4ff5` | the two live-test pushes | | app-catalog-felhom.eu | `1798ce6` / `17cc784` / `7b9b9b3` | their reverts, and the CHANGELOG entry recording them as a measurement | | felhom.eu | `417df06f3529` | the architecture doc, module map, register, roadmap, capability map, STATUS, live evidence | No branches. All gate runs passed on push. ## 4. Tests: 1729 → 1746 (+17). 28 packages, 0 FAIL. `go build ./... && go vet ./... && go test ./...` green in the controller module. | group | what it pins | |---|---| | **A** | a non-image template change reaches a pinned, matching app | | **B** | an image change freezes it — and the file content is the **stored definition**, with the new template's body (`NEXTCLOUD_TRUSTED_DOMAINS`) asserted **absent** | | **C** | self-healing from a corrupted file in **both** branches | | **D** | the update advances the pin, re-renders and stores, all before the pull; and **refuses** when the catalog cannot be read, while leaving an unpinned app alone | | **E** | adoption skips an incomplete observation and a complete-but-mismatched one, manufactures no applied file, is idempotent, and runs **no docker command at all** | | **F** | a restore's pin is reported to the syncer with its stored definition (R-441's contract) | | **G** | the badge reads the catalog, not the rendered file; no catalog entry renders nothing | | **H** | the AST walk: the seam and adoption are wired, **and their order** against the backfill and `syncer.Start()` | | — | the render table's remaining rows, the nil seam, and an empty stored definition | ### All three companion red-proofs — mutation, observed failure, revert | # | mutation | observed failure | reverted | |---|---|---|---| | **1** | `renderSource` returns the catalog template on the moved branch | `TestGroupB` — *"a pinned app must NOT receive the catalog's new version"* | yes | | **2** | `observationCoversTemplate` guard removed from `AdoptPins` | `TestGroupE/incomplete_observation` — *"pinned 1, want 0 — only 1 of 2 services was observed"* | yes | | **3** | `compareInstalledToTemplate` reads `TemplateImages` again | `TestGroupG` — *"THE FEATURE IS INVERTED"*, and *„Naprakész"* with no catalog entry | yes | Full suite re-run green after each revert. **A fourth defect was caught by a test rather than by review:** the syncer trusted the applied path it was handed and would have written an **empty compose file over a live app**. It now re-reads and falls back to the catalog. ## 5. Deployed version ``` $ ssh hp "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'" gitea.dooplex.hu/admin/felhom-controller:0.235.0 Up 31 minutes (healthy) ``` Previous: `0.234.0`. Fleet after the run: **21 containers up, none unhealthy.** ## 6. Live evidence Full quotes: `felhom.eu/documentation/tests/VALIDATION-update-slice3-2026-09-06.md`. **Method: endpoint level, plus two REAL catalog pushes travelling the REAL 15-minute cycle** — a hand-edited file on the box would have proved nothing about a change to the syncer. **Adoption:** `9 pinned, 0 already pinned, 0 left unpinned`, multi-service apps pinned per service. **Scenario A** — catalog `dc7e548` (healthcheck 30s → 45s, no image): ``` 08:01:51 [INFO] [sync] Updated bentopdf/docker-compose.yml live: image …:v2.8.6 interval: 45s container: v2.8.6, started 03:30:20Z (untouched) ``` **Scenario B** — catalog `09b4ff5` (v2.8.6 → v2.8.5): ``` 08:20:29 [INFO] [sync] Updated bentopdf/docker-compose.yml catalog: v2.8.5 live: v2.8.6 pin: v2.8.6 ``` and then `POST /api/stacks/bentopdf/restart`: | | before v0.235.0 (spike §2) | now | |---|---|---| | elapsed | **18.3 s** | **0.1 s** | | pulled? | yes, v2.8.5 entered the local store | **no** — `docker images` lists only v2.8.6 | | container | recreated | **not recreated**, `started` unchanged | | digest | changed | `sha256:eaeea1e4…`, identical to baseline | **Scenario D** — `POST …/update`, 16.7 s: ``` 08:22:21 update bentopdf: pin advanced to the catalog's current definition (…:v2.8.5) 08:22:38 Stack bentopdf updated successfully (took 16.6s) container v2.8.5, digest sha256:2d867aac… live/pin/applied all v2.8.5 ``` The pin advanced **17 s before** the update completed — i.e. before the pull, as the design requires. **Scenario G** — quoted from the live page while frozen: ```html Frissítés elérhető — 56 napja ``` ASCII fragments (`grep -oF`): `Naprak` **0** on the frozen app page and **8** on the list (the eight current apps); `napja` **1**; positive control `BentoPDF` 4; negative controls `zzz-never-present` and `Nem-karbantartott-XYZ` both **0**. **The freeze holds in BOTH directions:** with the catalog reverted to v2.8.6 and the app pinned to v2.8.5, the sync logged `hash match, skipped` and the app stayed on v2.8.5. The pin is what the customer HAS, not what is newest. **Teardown:** a final Update returned the container to `sha256:eaeea1e4…` — **byte-identical to the pre-run baseline** — with `interval: 30s` restored. Both catalog commits reverted; the catalog tree is byte-identical to `8220f8d`. **This run provisioned nothing.** ## 7. R-441 closed by MEASUREMENT The restore writer now pins to the unit's captured compose and stores it, so the render obeys the restored definition. **The closure rests on the render behaviour being measured live** (§6 Scenarios B and 6b — a pinned app's file surviving a sync that would previously have overwritten it within 15 minutes), plus `TestGroupF` for the contract the syncer relies on. **What was NOT done: a full live restore.** That needs a restore rehearsal, which is a phase, not a check — named in §8. ## 8. NOT yet live-validated — an explicit list 1. **A live RESTORE setting the pin.** The mechanism is measured; this specific entry point is not. 2. **The adoption skips** — on demo-hp all nine apps were complete and matching, so `0 left unpinned`. The skip branches are unit-tested with a red-proof; no live app exercised them. 3. **The `no stored definition` render row.** Every pinned app on the box has one. 4. **A multi-service app through a freeze.** bentopdf is single-service; the multi-service half is covered by adoption (docmost, paperless-ngx, romm pinned per service) and by unit tests. 5. **Any box other than demo-hp.** demo-felhom is still on 0.234.0. ## 9. Register — 203 open before, 203 after; closed 163 → 167 **Closed:** R-447 (slice 3 shipped), R-441 (by measurement), R-438 (both halves discharged), R-455 (the operator added a Docker Hub PAT). **Opened:** R-458 — `.felhom.yml` keeps flowing to a frozen app (P3-LOW, CC), with what would settle it by measurement rather than code. All four compressed into `CLOSED-ITEMS.md`, each naming `bc47dd4ef997`. ## 10. Every claim in the task that turned out to be wrong, named 1. **§7 Scenario B is incomplete** — see the box at the top. The stored definition must FOLLOW a delivered fix, or the first freeze reverts it. Found live, not by review. 2. **§5 says "the syncer must not import the stack manager".** It now imports the `stacks` PACKAGE for two pure symbols — `RenderPlan` and `ParseComposeImages` — and never touches `Manager` or `app.yaml`. The alternative was a second compose-image parser, which is the duplication `REUSE.md` exists to prevent. **The intent is honoured; the letter is not, and the import carries a comment saying so.** 3. **§2.2 says to call adoption "right after `BackfillInstalledImages()`" and stops there.** That is necessary but not sufficient: `syncer.Start()` fires an immediate sync, and at its original position (~L392) that first sync would have run while every app was still unpinned — copying the catalog over a deployed app **once per boot**. `Start()` was moved to after adoption; the order is pinned by `TestGroupH`. 4. **All line-number landmarks were accurate** (`AppConfig` ~L100, `ScanStacks` ~L468 with `TemplateImages` at ~L539/~L550, the syncer wiring ~L389, `RecreateStackDefinitionFromUnit` ~L2582), and **§3's warning was exactly right** — the badge would have inverted silently, and red-proof 3 shows it doing so. ## 11. Observations — noticed, documented, NOT acted on 1. **The syncer's DEBUG hash line now prints two identical hashes and the word `(changed)`.** `logFileHashes` re-reads src and dst *after* the copy, so they always match; with the render, `src` is sometimes the applied file, which makes the oddity conspicuous. Cosmetic, DEBUG-only, and pre-existing. **NOT-A-FINDING: it misleads no verdict — the `Updated /` INFO line above it is the real signal, and changing a debug helper inside a release about the syncer would add unreviewed noise to the one path that touches every app on every box.** 2. **An app pinned before v0.235.0's refresh logic shipped carries a store one fix behind** until its next delivered fix, which self-corrects it. Observed on bentopdf at 08:20:29. **NOT-A-FINDING: the state converges by itself on the next delivered fix, and the only alternative — rewriting every stored definition at boot — would touch every app on every box to correct something that costs nothing.** 3. **The golden is three releases behind** (`0.232.0` vs `0.235.0`), per the push advisory. **NOT-A-FINDING: the `golden-notice` gate raises it on every release and `STATUS.md` carries the debt; a register row would duplicate an instrument that already fires.**