slice 3 docs: the ruling, the shipped mechanism, and four rows closed
gates / gates (push) Successful in 17s

09-update-architecture.md gains the fourth dated operator ruling (2026-09-06,
Option 1) and its section 5 is rewritten from a proposed shape into the shipped
one: the pin, the stored definition, the render table, the four writers, the
startup ordering, and the trap this slice set for slice 2 - the live compose file
is now the frozen one, so a badge comparing against it would answer Naprakesz on
exactly the apps that are behind.

02-controller-module-map.md said 'copy compose + .felhom.yml'. That stopped being
true today, so it is corrected, and the two sections describing the old seam now
carry a banner saying they describe v0.234.0 and below - kept because every box
under v0.235.0 still behaves that way and because they are the measured account
of why it changed.

R-447, R-441, R-438 and R-455 closed and compressed into CLOSED-ITEMS; R-458
opened for the .felhom.yml asymmetry, with what would settle it by measurement.

Live evidence: two real catalog pushes travelling the real 15-minute cycle, both
reverted, the tree byte-identical afterwards. The restart that used to take 18.3
seconds and pull a new image now takes 0.1 seconds and pulls nothing.
This commit is contained in:
2026-09-06 10:37:33 +02:00
parent bc47dd4ef9
commit 417df06f35
9 changed files with 411 additions and 76 deletions
@@ -0,0 +1,179 @@
# VALIDATION — update arc slice 3, live on demo-hp (2026-09-06)
**Controller `0.235.0`, guest 9201 on `demo-hp` (Tier 0 — disposable).** Evidence copied off the box
at the end of the phase that produced it.
**Method: endpoint level plus a REAL CATALOG PUSH travelling the REAL 15-minute cycle.** There is no
browser on DooPlex. Because v0.235.0 changes what the catalog SYNCER does, a hand-edited file on the
box would have proved nothing — the same reasoning `SPIKE-app-update-2026-09-01` §3 used, and the same
app: **`bentopdf`**, deployed on demo-hp only, file-based, no database and no volume, so no data
anywhere could be touched.
**Two catalog commits, both reverted in the same session**, and the catalog tree is byte-identical to
`8220f8d` afterwards: `dc7e548` (non-image), `09b4ff5` (image), `1798ce6` + `17cc784` (the reverts).
---
## 0. Baseline, read before anything
```
ref = ghcr.io/alam00000/bentopdf:v2.8.6
digest = sha256:eaeea1e447205a79cb61d7efdc6966f37311dc1bc9c36a3a5c897bf79107c2c3
compose = image: ghcr.io/alam00000/bentopdf:v2.8.6 / interval: 30s
controller before = 0.234.0
```
## 1. Adoption — all nine apps pinned, none guessed at
```
07:46:50 [INFO] [stacks] pin bentopdf: bentopdf=ghcr.io/alam00000/bentopdf:v2.8.6
07:46:50 [INFO] [stacks] pin bookstack: bookstack=lscr.io/linuxserver/bookstack:26.05.2, bookstack-db=mariadb:12.3
07:46:50 [INFO] [stacks] pin docmost: docmost=docmost/docmost:0.95.0, docmost-postgres=postgres:16-alpine, docmost-redis=redis:7-alpine
…
07:46:50 [INFO] [stacks] pin adoption: 9 pinned, 0 already pinned, 0 left unpinned
(0 not completely observed, 0 running something the template no longer offers)
```
`applied-compose.yml` appeared in every stack dir (bentopdf: 1109 bytes). **Multi-service apps pinned
per service** — `docmost` and `paperless-ngx` carry three each.
## 2. Scenario A — the catalog still offers the pinned version: **FIXES FLOW**
Catalog commit `dc7e548`: healthcheck `interval: 30s → 45s`. **No image touched.** Left to travel the
real cycle.
```
08:01:51 [INFO] [sync] Updated bentopdf/docker-compose.yml
08:01:51 [INFO] [sync] Periodic sync: Sablonok frissítve — frissítve: bentopdf
live compose : image: …bentopdf:v2.8.6 interval: 45s ← the fix arrived
container : ghcr.io/alam00000/bentopdf:v2.8.6 started=2026-09-06T03:30:20Z ← untouched
```
**This is the half the operator explicitly chose to keep, and it is intact.**
## 3. Scenario B — the catalog moves: **THE APP FREEZES**
Catalog commit `09b4ff5`: `v2.8.6 → v2.8.5`. Real cycle again.
```
08:20:29 [INFO] [sync] Updated bentopdf/docker-compose.yml
catalog cache: image: …bentopdf:v2.8.5 ← the catalog has moved
live compose : image: …bentopdf:v2.8.6 ← the app has NOT
pin : bentopdf: ghcr.io/alam00000/bentopdf:v2.8.6
```
### 3b. And a restart afterwards leaves the version alone — **the quantitative discriminator**
`POST /api/stacks/bentopdf/restart` → `{"ok":true,…}`
| | before v0.235.0 (spike §2, variant 1a) | **now** |
|---|---|---|
| elapsed | **18.3 s** | **0.1 s** |
| image pulled? | **yes**, v2.8.5 appeared in the local store | **no** — `docker images` lists only `v2.8.6` |
| container | recreated on the new image | **not even recreated**, `started=2026-09-06T03:30:20Z` unchanged |
| digest | changed | `sha256:eaeea1e4…` — **identical to the baseline** |
```
08:21:44 [INFO] [api] restart requested for stack: bentopdf
08:21:44 [INFO] [stacks] Stack bentopdf restarted successfully (took 0.1s)
```
**The 18.3 s → 0.1 s split is worth more than the tags:** the old path spent eighteen seconds because
it was pulling a new image. There is nothing to pull now.
## 4. Scenario G — the badge still tells the truth
**The trap this slice set for slice 2:** the live compose file now names the OLD version, so a badge
comparing against it would answer „Naprakész" on exactly the apps that are behind. Quoted from the
live page while frozen:
```html
<span class="tag tag-warn" title="Újabb változat érhető el ehhez az alkalmazáshoz. A frissítés indításához nyomd meg a Frissítés gombot.">Frissítés elérhető — 56 napja</span>
```
56 is arithmetic on a real value: bentopdf's `catalog_since` is `2026-07-12`.
ASCII fragments, `grep -oF`, with positive **and** negative controls:
| fragment | `/apps/bentopdf` | `/stacks` |
|---|---|---|
| `Naprak` | **0** | **8** (the eight apps that are current) |
| `napja` | **1** | **1** |
| `BentoPDF` (positive control) | 4 | 1 |
| `zzz-never-present` (**negative**) | **0** | **0** |
| `Nem-karbantartott-XYZ` (**negative**) | **0** | **0** |
The `update` button renders exactly once, unchanged.
## 5. Scenario D — the Update button still moves the version
`POST /api/stacks/bentopdf/update` → `{"ok":true,…}`, 16.7 s wall clock.
```
08:22:21 [INFO] [stacks] update bentopdf: pin advanced to the catalog's current definition (bentopdf=…:v2.8.5)
08:22:38 [INFO] [stacks] Stack bentopdf updated successfully (took 16.6s)
container : ghcr.io/alam00000/bentopdf:v2.8.5 started=2026-09-06T08:22:38Z
digest : sha256:2d867aacb8ab5b196d00ee86944b1899d09d72df355384c5e15cf974737963a0
live : image: …:v2.8.5 pin: …:v2.8.5 applied-compose: …:v2.8.5
```
**The pin advanced at 08:22:21, seventeen seconds before the update completed** — i.e. BEFORE the pull,
which is the ordering the design requires. The digest matches the one the spike recorded for v2.8.5
(`sha256:2d867aac…`), independently.
## 6. A REAL GAP THIS RUN FOUND, fixed before Scenario B was run
**Scenario A passing is what exposed it.** The stored definition is written when the PIN is written,
so the healthcheck fix delivered at 08:01:51 landed in the live compose file and **not** in
`applied-compose.yml`. The first time the catalog then moved, the freeze rendered the *pre-fix*
definition — **reverting every fix delivered since, silently undoing the half of the ruling that says
fixes keep flowing.**
Observed directly at 08:20:29: the frozen live file came back with `interval: 30s`, not the `45s`
delivered nineteen minutes earlier.
**Fixed the same session:** the equal-images branch now refreshes the stored definition as it delivers
a fix. The images cannot move in that branch by construction, so no version moves and no intent is
rewritten. `TestFixRefreshesTheStoredDefinition` pins both halves. **The 08:20:29 observation above was
taken on the binary before that fix and is left in this document deliberately** — it is the evidence
for why the fix exists.
## 6b. The freeze holds in BOTH directions — and the refreshed store proves itself
The catalog was reverted to `v2.8.6` (commits `1798ce6`, `17cc784`) and left to travel the cycle. The
app was by then pinned to **v2.8.5**, so the *older* catalog is now the one that has "moved" relative
to the pin:
```
08:35:29 [DEBUG] [sync] bentopdf/docker-compose.yml: hash match, skipped
catalog : image: …:v2.8.6 interval: 30s
live : image: …:v2.8.5 interval: 45s ← frozen, and BOTH fields come from the stored definition
container: ghcr.io/alam00000/bentopdf:v2.8.5
```
**The pin is what the customer HAS, not what is newest** — a downgrade in the catalog is refused
exactly as an upgrade is. And the `interval: 45s` here is the §6 fix working: the applied definition
written by the 08:22 update carried both the v2.8.5 image *and* the 45 s healthcheck, so the freeze
reproduced the whole thing. `hash match, skipped` shows the content-compare still suppresses a
pointless rewrite.
## 7. Teardown — back to the baseline, byte for byte
`POST /api/stacks/bentopdf/update` once more, against the reverted catalog:
```
container : ghcr.io/alam00000/bentopdf:v2.8.6
digest : sha256:eaeea1e447205a79cb61d7efdc6966f37311dc1bc9c36a3a5c897bf79107c2c3 ← IDENTICAL to §0
live : image: …:v2.8.6 interval: 30s ← identical to §0
pin : …:v2.8.6
status : healthy
```
Fleet afterwards: **21 containers up, none unhealthy**; `/stacks` renders **„Naprakész" ×9** with
`napja` at 0 and the negative control at 0. Controller `0.235.0`, healthy.
Both catalog commits reverted; the catalog tree is byte-identical to `8220f8d`. **This run provisioned
nothing** — no guest, no storage, no hub record — so there is no teardown to report on any of the
three layers.