20 KiB
REPORT — v0.189.0: the box stops guessing what the customer wanted (R-166 / decision D-b)
Overwritten per the standing rule. The prior contents (the R-168 CI workflow, same day) have
their durable record in CHANGELOG.md.
Session: 2026-08-02 · Repos touched: felhom-controller (v0.188.0 → v0.189.0),
felhom.eu (docs only — no hub change, no hub version bump) · Live target: demo box
felhom-pve guest 9201 · No STOP point; nothing destructive; no customer data touched.
1. Confirmed baselines — matched §1 exactly on arrival
| Repo | main @ arrival |
Expected (§1) | Match |
|---|---|---|---|
felhom-controller |
e7c44c0e0f91d21d92b1dafda3979bf382182c1f |
e7c44c0e0f91 |
✅ |
felhom.eu |
c04ea4f6c2b1554f7d5b4f9fbe7b0066c37b4ea1 |
c04ea4f6c2b1 |
✅ |
Both trees clean (git status --porcelain empty, HEAD == origin/main) before the build.
2. The four §4.9 facts, re-confirmed at source — none had drifted
| Fact | Confirmed at | Result |
|---|---|---|
AppConfig has no desired-run field; Deployed means installed |
internal/stacks/deploy.go:97-107 — five fields: Deployed, DeployedAt, Env, LockedFields, EmailEnabled |
Held |
StopStack runs compose down (removes containers) |
internal/stacks/manager.go:1056 → m.composeExec(dir, "down") |
Held |
DumpAppVolumesSafe stops/dumps/starts with no defer, no marker, no journal |
internal/backup/backup.go:682-707 — plain statements, no defer anywhere |
Held. Same shape at internal/backup/offbox_reconstitute.go:279 and internal/appexport/export.go:232 |
aggregateState already walks every container; any unhealthy/mixed result wins |
internal/stacks/manager.go:~782 |
Held — D-b's every-container requirement was already met and was NOT re-implemented |
One fact the spec did not list, and it mattered more than any of them: SaveAppConfig
(deploy.go:742) rebuilt AppConfig from a five-field struct literal. Adding a sixth field
without noticing would have dropped desired_state on every save, across nine call sites —
see §12.
3. Files created / modified
Created
controller/internal/stacks/desiredstate.go— constants,SetDesiredState,DesiredStateOf,BackfillDesiredState,isObservedUpcontroller/internal/backup/appstop_marker.go—AppStopGuard,AppStopMarker,AppStopRecovery, reasonscontroller/internal/stacks/desiredstate_test.go,controller/internal/bootrecon/desiredstate_test.go,controller/internal/backup/appstop_marker_test.go,controller/internal/api/desiredstate_intent_test.go,controller/cmd/controller/appstop_wiring_test.go
Modified
controller/internal/stacks/deploy.go—DesiredStatefield;SaveAppConfig→ copy-and-overlay;DeployStackrecordsrunning;UpdateOptionalConfig's redeploy branch recordsrunningcontroller/internal/api/router.go—desiredStateForAction+ the intent write inactionStackcontroller/internal/bootrecon/bootrecon.go—isBootOrphanrewritten on intent; package doc updatedcontroller/internal/backup/backup.go—appStopfield, guard construction,SetAppStopGuard/AppStopGuard,DumpAppVolumesSafebracketedcontroller/internal/backup/offbox_reconstitute.go—Begin+ arestartStackclosure covering all four bring-up pathscontroller/internal/appexport/export.go— consumer-sideappStopGuardseam +SetStopGuardcontroller/cmd/controller/main.go— guard construction +Recoverbefore the bootrecon goroutine, backfill,NotifyBackupFailedreporting,exportStopGuardadapter,.fabrestore recordsrunningCHANGELOG.md,CONTEXT.md,REUSE.md,controller/README.mdfelhom.eu:CONTEXT.md(S-12),CLAUDE.md,STATUS.md,documentation/architecture/00-capability-map.md,documentation/architecture/02-controller-module-map.md(new §0a — the S-1 contract),documentation/backlog/OPEN-ITEMS.md,documentation/backlog/ROADMAP.md
4. Commits pushed to main
| Repo | Commit | Subject |
|---|---|---|
felhom-controller |
dbcb306 |
v0.189.0 — desired state + the app-stop crash marker (R-166 / D-b) |
felhom.eu |
(docs commit, this session) | docs: R-166 shipped — S-1 contract, capability map, backlog, STATUS |
Direct to main, no branches, no Co-Authored-By. The pre-push hook ran the gate entry point
(--fast) and passed; --no-verify was NOT used.
5. Tests and red-proofs
27/27 packages green before and after; +37 test functions across 5 packages.
Full go build ./... && go vet ./... && go test ./... → rc=0, run to completion twice.
Test run and commit were never combined in one command (standing rule 1).
Every red-proof below was observed FAIL, then the mutation was reverted and a control run passed.
| # | Test | Mutation applied | Result |
|---|---|---|---|
| B | TestReconcile_DesiredRunning_ZeroContainers_IsRecovered + TestIsBootOrphan_DecisionTable |
restored len(s.Containers) > 0 && IsDownState(...) in the DesiredStateRunning branch |
FAIL ✅ |
| C | TestReconcile_LegacyNoDesiredState_BehavesExactlyAsBefore + ..._AppConfigPresentButFieldAbsent... |
made the default: (absent) branch return len == 0 || IsDownState(...), i.e. absent treated as running |
FAIL ✅ — "a LEGACY app with no recorded intent and zero containers was started 1 time(s)" |
| D | TestBackfillDesiredState_RunningIsRecorded_AmbiguousIsLeftAlone + ..._NeverWritesStopped... |
deleted the if !isObservedUp(s) guard |
FAIL ✅ — "exited (state=exited) was backfilled to "running"" |
| E | TestDumpAppVolumesSafe_MarkerCoversTheWholeStopStartWindow, ..._Interrupted_RecoveryBringsTheAppBack, ..._FailedRestartKeepsTheMarker |
deleted the production m.appStop.Begin(...) call from DumpAppVolumesSafe |
FAIL ✅ (3 tests) |
| H | TestSaveAppConfig_PreservesEveryKnownFieldAcrossLoadSave + TestSetDesiredState_PersistsStoppedToDisk |
restored the field-by-field saveCfg := AppConfig{...} literal |
FAIL ✅ — "desired_state was LOST across load→save" |
| §8.2 | TestActionStack_RecordsIntentBeforeActing |
moved the intent write below the action switch | FAIL ✅ |
| I (seam) | TestMainWiresAppStopRecovery + TestAppStopRecoveryPrecedesTheBootReconciler |
commented out appStopGuard.Recover() in main.go |
FAIL ✅ — and grep -c "appStopGuard.Recover()" main.go still returned 1, i.e. a strings.Contains test would have passed. The AST walk is what bites. |
The hollow test I caught and fixed
Scenario E's first version called m.appStop.Begin(...) itself and then recovered — and it
PASSED under red-proof E while its two siblings failed. It proved the marker type worked, not
that DumpAppVolumesSafe uses it. Rewritten to drive the real function with a simulated hard abort
(GetDockerVolumes panics, unwinding past the restart statement — which is the point, since a
defer is not crash-safety). It then failed the same red-proof correctly.
6. The §8.1 table — every row proven by test
TestIsBootOrphan_DecisionTable (10 sub-tests, all green; the whole table fails under red-proof B):
desired_state |
containers | state | Expected | Sub-test |
|---|---|---|---|---|
stopped |
0 | stopped | not an orphan | stopped/no containers ✅ |
stopped |
2 | exited | not an orphan | stopped/down containers ✅ |
stopped |
2 | running | not an orphan | stopped/running ✅ |
running |
0 | — | ORPHAN | running/no containers ✅ |
running |
2 | exited | ORPHAN | running/down containers ✅ |
running |
2 | degraded | ORPHAN | running/degraded ✅ |
running |
2 | running | not an orphan | running/up ✅ |
| absent | 0 | — | not an orphan (legacy) | absent/no containers ✅ |
| absent | 2 | exited | ORPHAN (legacy) | absent/down containers ✅ |
| absent | 2 | running | not an orphan | absent/up ✅ |
Plus TestIsBootOrphan_ExistingGuardsSurviveTheRewrite — Protected, Deploying and !Deployed
still refuse at the strongest desired state, because the rewrite reordered the terms and a reorder is
how a guard silently disappears.
7. Build, deploy, gates
build.sh 0.189.0 --push → gitea.dooplex.hu/admin/felhom-controller:0.189.0 (145M)
guest 9201: docker pull → /etc/felhom-controller-image → systemctl restart felhom-controller-bootstrap
Verified live: gitea.dooplex.hu/admin/felhom-controller:0.189.0 | Up … (healthy).
python3 controller/scripts/controller_gates.py — all 8 gates OK (template-id, emoji,
native-confirm, offbox-rename, app-row-dedup, mojibake, docker-v, reuse-refs: 133 cited paths,
126 exact / 6 suffix / 1 cross-repo, 0 failures).
8. Live validation on the demo box — method, and the observables
Method: endpoint-level (claude-in-chrome is not available on DooPlex). The exact request the
UI's „Leállítás"/„Indítás" buttons make was driven — layout.html:231,
POST /api/stacks/<name>/<action> with X-CSRF-Token, session-authed through traefik. No server
logic was skipped; only rendering. The rendered click-through remains the operator's manual check.
Two traps hit, both already in project memory: curl's cookie jar drops felhom_session (handled by
hand, one cookie), and the controller is not published on :8080 — it sits behind traefik on :443
with Host: felhom.demo-felhom.eu. The credential-bearing helper was deleted afterwards (§14).
Flow 0 — the backfill, on four real legacy app.yaml files
[INFO] [stacks] desired-state backfill: 4 app(s) recorded as running, 0 left unrecorded …
bookstack, immich, docmost, calibre-web — all deployed, all observed up, all with no
desired_state before the upgrade, all converged to desired_state: running on disk.
Flow 1 — a customer's Stop survives a restart (Scenario A)
action_response={"ok":true,"message":"Stack calibre-web stop completed"}
[INFO] [stacks] desired state for calibre-web recorded as "stopped" (was "running") ← 16:45:51
[INFO] [stacks] Stopping stack: calibre-web ← 16:45:51
The intent line precedes the stop line — §8.2 observed live, not only structurally.
app.yamlon disk:desired_state: stoppeddocker ps -a --filter name=calibre→ empty (compose downremoved the containers)- controller restarted →
[bootrecon] Boot reconciliation: no boot-orphaned apps (nothing to start) docker ps -a→ still empty. The app stayed down.
Flow 2 — the power-cut shape is recovered (Scenario B — the point of the release)
Started from the UI endpoint → desired_state: running, calibre-web Up 8 seconds. Then the R-157
shape was created out-of-band: docker rm -f calibre-web → containers_now: 0, app.yaml untouched
at running. Controller restarted:
[INFO] [stacks] desired-state backfill: 0 app(s) recorded as running, 0 left unrecorded …
[INFO] [bootrecon] Boot reconciliation: 1 boot-orphaned app(s) found: [calibre-web] — up to 2 attempt(s)
[INFO] [bootrecon] Boot reconciliation attempt 1/2: started "calibre-web" (took 0.2s)
[INFO] [bootrecon] Boot reconciliation complete: 1 app(s) recovered in 1 attempt(s): [calibre-web]
calibre-web Up 56 seconds (healthy). Before v0.189.0 this app was invisible to the reconciler.
Flow 3 — a legacy app.yaml behaves exactly as before (Scenario C)
desired_state deleted from calibre-web/app.yaml (desired_state_lines: 0), containers removed
(containers: 0), deployed: true kept. Controller restarted:
[INFO] [stacks] desired-state backfill: 0 app(s) recorded as running, 1 left unrecorded (state ambiguous — legacy boot behaviour retained)
[INFO] [bootrecon] Boot reconciliation: no boot-orphaned apps (nothing to start)
App not started; grep -c "^desired_state" app.yaml → 0, i.e. stopped was never
inferred. Byte-identical to pre-v0.189.0 behaviour, with a positive observable for both halves.
Not live-validated, deliberately, and stated rather than rounded up: the app-stop marker's recovery leg. It is unit-proven and red-proofed, but nobody killed the controller mid-backup on real hardware. The capability map records it as IMPLEMENTED, not PROVEN-LIVE.
9. Every customer-intent entry point found
A census of all 14 StartStack/StopStack call sites was run (grep -rn --include=*.go, tests
excluded). Four are the customer; ten are machines.
| # | Intent point | Records | Note |
|---|---|---|---|
| 1 | internal/api/router.go actionStack |
start/restart/update → running; stop → stopped |
the primary one, named in §1.3 |
| 2 | internal/stacks/deploy.go DeployStack |
running |
named in §1.3 |
| 3 | internal/stacks/deploy.go UpdateOptionalConfig (redeploy branch only) |
running |
found, not assumed — an API endpoint ending in compose up -d; the non-deployed branch starts nothing, so it expresses no opinion |
| 4 | cmd/controller/main.go exportAdapter.SaveEncryptedAppConfig |
running |
found, not assumed — the .fab import path, which then starts the app (appexport/restore.go:461) |
Confirmed: no machine-initiated caller writes desired state. The machine callers —
quiesce/quiesce.go:494, backup/backup.go:688, backup/restore.go:55, backup/restore_unit.go:252,
backup/tier2_restore.go:177, backup/offbox_reconstitute.go:279, appexport/export.go:232,
appexport/restore.go:366, stacks/migrate.go:707, web/intermediary.go:186,430,
web/storage_handlers.go:526, bootrecon/bootrecon.go:135 — were each read and none touch it.
10. Marker coverage — all three sites, none left out
| Site | Covered | How |
|---|---|---|
backup/backup.go DumpAppVolumesSafe |
✅ | Begin before the stop; End only on a successful restart; cleared on a failed stop (nothing was stopped, so nothing is owed) |
backup/offbox_reconstitute.go |
✅ | Begin before the stop; one restartStack closure replaces all four bring-up paths, so the success path cannot silently skip the clear |
appexport/export.go |
✅ | consumer-side two-method seam; main.go's exportStopGuard supplies the reason, so backup.ReasonAppExport's value exists in exactly one place |
Nothing was left out. All three share one guard over one file: main.go builds it,
backup.Manager.SetAppStopGuard receives it, Exporter.SetStopGuard receives it. A second guard
over the same path would be one file with two owners — the shape this marker was kept out of
quiesce's file to avoid.
11. The operator event path (§2.4) — reachable, used, no new channel
Reachable, but not from where Recover runs. Recover must complete before the boot-reconcile
goroutine (main.go:~236) and the notifier is not constructed until ~307. Rather than wire a seam
that would fire after the fact — the "built but never wired" shape with four recorded instances here
— Recover returns an *AppStopRecovery and main.go reports it once the notifier exists.
It rides the existing backup_failed event type (notifier.NotifyBackupFailed). A new type
would need the hub's allowedEventTypes + customerMessages pair changed — a wire change, and this
release ships no hub change. The call is guarded by if appStopRecovery != nil so a healthy boot
pages nobody; TestMainReportsTheInterruptedOperation asserts both the call and the guard.
12. Does app.yaml round-trip unknown keys? — MEASURED: no
TestSaveAppConfig_UnknownYAMLKeysAreDropped writes an app.yaml containing
future_field: keep-me, loads it, saves it, and reads the file back: the key is gone. The
round-trip goes through the struct, so yaml.Unmarshal discards unmodelled keys before
SaveAppConfig ever sees them. Pre-existing, not widened by R-166, and now stated in
SaveAppConfig's own comment — it is why every writer must load-then-save rather than construct.
What WAS fixed is the more dangerous half. SaveAppConfig rebuilt AppConfig from a five-field
struct literal — the R-100 shape, which v0.181.0 shipped two live instances of. desired_state
would have been dropped on every save across nine call sites, so a customer's Stop would have
been erased by the next unrelated app.yaml write (an email-toggle change, an optional-config edit,
the encryption migration). Replaced with copy-and-overlay (saveCfg := *cfg), safe by construction
and pinned by TestSaveAppConfig_PreservesEveryKnownFieldAcrossLoadSave + red-proof H.
13. OPEN-ITEMS.md rows opened / closed / re-ranked
- R-166 → SHIPPED + PROVEN-LIVE. Both blocking facts recorded with their answers: (a) the
crash-safe journal pattern did already exist (quiesce, migrate) but covered none of the
app-data path —
DumpAppVolumesSafehad no marker, no journal and not even adefer; (b) the SQLite store is reachable and deliberately unused, becausemetrics.dbis optional by design and operational state must not live in a store built to be dropped. - R-157 → mechanism B CLOSED, mechanism A remains and is now the whole row (READY, S). A is a pure timing fix (the sweep observes ~5 s after start and never re-checks). Recorded: B's fix makes A cost more, since the sweep now has more it could legitimately recover.
- R-170 → NEW.
shouldRecreateOnBoot(internal/web/intermediary.go:131) still ends in&& hasContainers— R-157 mechanism B, one gate over, for drive-backed apps. Established free:grep -ro "R-170\b" documentation/ *.md→ 0 hits. Not acted on (§16). ROADMAP.mdgained matching R-166/R-170 rows and an updated R-157;STATUS.md's "an app can stay switched off and nothing says so" line was rewritten, not extended.
14. Teardown
This task provisioned nothing — no VM, no guest, no scratch app, no storage. The one app used for
validation (calibre-web) already existed and was restored to its original state: started
through the same UI endpoint, desired_state: running on disk, calibre-web Up … (health: starting),
15 containers running on the box, controller healthy on 0.189.0. The credential-bearing helper script
was deleted from both the guest (/tmp/act.sh plus its scratch HTML) and DooPlex; ls confirms
No such file or directory.
15. CI
| Run ID | head_sha |
Conclusion |
|---|---|---|
| 17 | dbcb306f (the controller push) |
success |
Checked by pulling the run list, not by trusting the absence of a failure email — which is exactly
the check now added to felhom.eu/CLAUDE.md's end-of-session list (§N.6).
16. Observations — noticed, documented, NOT acted on
- R-170 (filed). The drive-backed boot gate still infers a Stop from a container count. Left
alone deliberately: the task scoped
bootreconand named this gate nowhere, and shipping two boot behaviour changes under one live validation is one too many. Its consequence is bounded, not zero —bootreconnow recovers the app on intent seconds later, so what is actually lost is the gate's recreate-against-the-rebound-drive step, which matters when an app needs recreating rather than merely starting. app.yamlcannot carry an unmodelled key (§12). Pre-existing; documented in-code rather than fixed, since fixing it means an inline catch-all map on a struct holding encrypted secrets.- The
.fabrestore adapter constructs a freshAppConfig(main.go), so it does not benefit from copy-and-overlay — it is a genuinely new record. Correct today; noted because any future field added toAppConfigmust be considered there explicitly. - Part 3 (UI) — N/A: no user-visible string changed. No template, funcmap, badge, copy or
notifier-message change. The v0.164.0
StateStoppedsuppression is untouched and still correct: an app the customer stopped is still not an alarm, and it is now not a recovery candidate either — for a recorded reason instead of an inferred one. aggregateStatealready satisfies D-b's every-container requirement and was deliberately not re-implemented. The backfill'sisObservedUpis an allow-list of up-states rather than!IsDownState, because that negation would call a crash-looping or unreadable stack "up" and seed a durable record from it.