# 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.**