# Felhom Controller Architecture — Part 2: Controller Module Map > **How to read this document.** Two kinds of statement appear, and where this document marks them it > marks them like this — the same wording as `07-backup-architecture.md:11-17`, carried here on > 2026-08-22 (R-376) so a reader meets one convention and not eight: > > - **[DESIGN]** — a decision taken. Not derived from code; the code may not implement it yet. > - **[FACT]** — an observed property, carrying a `file:line`, a live command output or a citation. > > **Statements in this document are NOT yet all marked.** Marking them wholesale is a large judgement > exercise and a wrong mark is worse than none, so only what a session touches is marked (R-376). > **An unmarked statement therefore means "not yet classified", never "observed".** That ambiguity is > exactly what cost this project three sessions in August 2026: the hot/bulk placement decision sat > unmarked beside a marked `[FACT]`, and was read as an observation and reported as a defect. > **EXECUTED (slice 8C, 2026-06-10 — controller v0.37.0).** This map's target state is now realized: > the disk-execution subsystem (`storage/*`, restic, cross-drive, drive-restore, `disk_layout`, > `local_infra`, `infra_backup`, `setup/scanner`, `monitor/watchdog`+`pinger`, the storage UI) is > **deleted** (~12.3k LOC); `backup.Manager` is **split to app-data only**; disk management is > **rewired to the host agent's local API** (`web/agent_disk_handlers.go` → agent `/disks`); and the > container is **de-privileged** (no `privileged`, `/dev`, `/etc/fstab`, rshared). The in-guest > controller is now **Docker-only with no disk/Proxmox privileges**, as designed. See doc 03 §6/§9. **Status:** audit (keep / port / delete / modify / add), grounded in the v0.33 source. **Subject:** the v0.33 controller in `felhom-controller/controller/` (110 `.go` files, ~40 K LOC) audited against [01-topology-and-trust.md](01-topology-and-trust.md) and [../proxmox-platform.md](../proxmox-platform.md). > This is a **planning map, not the port.** No controller code was changed. Source > citations use `controller/internal/...:line` (a different repo, so links are not > clickable). Classifications reflect the **target model**: the in-guest controller is > **Docker-only and holds no Proxmox credentials**; everything host/disk/Proxmox moves to > a new **host agent** (out of scope here); the controller reaches the agent through a > constrained **local API**. ## Classification scheme **KEEP** (host-agnostic, ~unchanged) · **PORT** (survives, needs rework) · **DELETE (→agent)** (responsibility moves to the host agent) · **DELETE (obsolete)** (no longer needed) · **MODIFY** (stays, materially changes) · **NEW** (no v0.33 equivalent). Risk tags: **clean** · **needs-rework** · **hazard** (entangles a delete-target with a keep/port target). --- ## 0. Executive summary - The **app domain is largely intact and portable**: stack lifecycle (`stacks/`), catalog git-sync (`sync/`), app-to-app integrations (`integrations/`), `.fab` export/import (`appexport/`), the scheduler, crypto, asset sync, the hub report/notify *channels*, and most of the web UI **KEEP/PORT cleanly**. - The **disk/storage/host half deletes wholesale to the agent**: all of `storage/`, `monitor/watchdog.go`, the restic/cross-drive/disk-layout/drive-mount parts of `backup/`, `report/infra_backup*`+`infra_pull`, and the host-physical parts of `system/`. - The **setup wizard (`setup/`) was obsolete and is DELETED** (controller v0.308.0, R-554, `09` §3 decision 222) — the agent provisions the controller; a box that cannot get its configuration shows one waiting page (`bootstrap.WaitForConfig`). - **The single biggest hazard is `backup/`**: the keep side (DB dumps, Docker-volume archive, per-app restore — needed by `appexport/` and the backup UI) and the delete side (restic, cross-drive, drive-mount) are **interleaved inside the same files** (`backup.go`, `restore.go`, `paths.go`), not cleanly file-separated. Extracting the app-data-backup subset into a clean retained package is the critical refactor. - **Intent-vs-reality corrections** (vs the task's provisional split): `monitor/pinger.go` is already **dead** (legacy Healthchecks.io, "deprecated… now handled by Hub" per `main.go`) → DELETE(obsolete), not keep. `backup.go`/`restore.go`/`paths.go` do **not** split on file boundaries — they split *within* the file. `settings/` is **not** pure app domain — it stores disk/disconnect/decommission state. `system/` is genuinely mixed-per-function, not per-file. --- ## 0a. App state: desired / in-flight / observed — S-1 CONTRACT (2026-08-02, decision D-b, R-166) > **This section is a live contract, not migration history** — the rest of this document is the > v0.33 keep/port/delete inventory. Read this before touching `stacks/`, `backup/` or `bootrecon/`. > Shipped in controller **v0.189.0**. An app's state is **three different kinds of information**, and conflating them is what produced R-157 mechanism B and F-CRIT-1. They are stored differently on purpose. | Kind | Question it answers | Where it lives | Persisted? | |---|---|---|---| | **Desired** | *What did the customer ask for?* | `app.yaml` → `desired_state` | Yes, beside the app's other settings | | **In-flight** | *Is an operation part-way through, and did it finish?* | its **own** marker file under `` | Yes, written before the operation and cleared after | | **Observed** | *Is it running, unhealthy, restarting, is its drive gone?* | nowhere | **No — rebuilt by looking** | **The rule that ties them together: never derive one from another.** The defect this replaced did exactly that — it derived *desired* from *observed* (zero containers ⇒ "the customer stopped it"), and zero containers is equally what a power cut mid-compose, an interrupted deploy and an interrupted backup leave behind. Two real faults were therefore read as deliberate stops and stranded silently. ### Desired — `app.yaml`, `desired_state` Tri-state: `""` (unknown) · `"running"` · `"stopped"`. - **ONE OWNER: the customer's own action.** Writers are the `/api/stacks/{name}/{action}` switch, `DeployStack`, `UpdateOptionalConfig`'s redeploy branch, and the `.fab` import. **`StartStack` and `StopStack` are NOT writers** — a census found 14 callers of which only 2 are the customer; the rest are quiesce, the backup volume dump, offbox reconstitution, app export/restore, the storage gate, migration and the boot reconciler. Intent recorded in the primitive would make a nightly backup indistinguishable from the customer pressing Stop. - **Written BEFORE the act; a failed write REFUSES the act.** - **Absent means UNKNOWN — never "running".** Every `app.yaml` predating v0.189.0 lacks the field, so consumers must fall back to the pre-v0.189.0 behaviour rather than assume. A running-only backfill converges the unambiguous cases; **`stopped` is never inferred, from any signal.** ### In-flight — a marker file, one per owner Two exist and they are deliberately **separate files**: `quiesce-state.json` (the whole-guest backup window, `internal/quiesce`) and `appstop-state.json` (app-data operations that stop an app — `backup.AppStopGuard`, covering the volume dump, offbox reconstitution and `.fab` export). **One file, one writer**: sharing would give one record two lifetimes, and one owner clearing the other's note is a stranded app by a different route. - Written **before** the stop; cleared **only** after a restart that succeeded; a **failed** restart keeps the marker so the next startup retries. - **A `defer` is not the mechanism.** A SIGKILL runs no deferred function — established on live hardware by Campaign 8 fault 10, where what brought the stacks back was the marker read at startup. - Recovery runs at startup and **completes before** the boot reconciler is launched, so an app the marker explains is not also reported as an unexplained boot orphan. ### Observed — not persisted, by design `aggregateState` walks **every container** of a stack and any unhealthy or mixed result wins, so a partly-dead app cannot read as healthy (F-CRIT-1's shape). This requirement is met here and must not be re-implemented downstream. Nothing about observed state is written to disk: a controller restart re-observes it within one refresh, whereas persisting it risks carrying a stale verdict across the very restart that fixed it (the same argument as `RestartingSince`). ### The binding safety rule (verbatim, from decision D-b) > *Losing the state store must never cause an app to be deleted, restarted wrongly, or reported > healthy when it is not — the worst acceptable outcome is re-running a backup that already ran.* Applied: a lost or corrupt marker means the app is not auto-restarted **by that mechanism**, which is the pre-v0.189.0 position, not a new hazard. A lost `app.yaml` already means the app is not deployed. **Nothing here may make an absent file more dangerous than a present one.** ### Boot recovery reads desired, and asks before it acts (v0.190.0) **Both boot gates now read desired state.** `bootrecon.isBootOrphan` (the R-52 sweep) and `shouldRecreateOnBoot` (the drive-backed recreate gate) answer the same question — *did the customer want this running?* — with the same three-way table, absent falling back to the pre-v0.190.0 container count in both. Their agreement is pinned from both sides against one fixture table, because an import cycle prevents testing them together. R-170 closed the last gate that still guessed. **A partly-dead stack is NOT a boot orphan — written down (R-456, 2026-09-13).** `isBootOrphan` requires the stack as a WHOLE to be down: one live member (`bookstack-db` running while `bookstack` is gone) keeps the stack out of the sweep, although `desired_state: running` is recorded and the app container is missing. Measured 2026-09-02 on demo-hp: `docker rm -f bookstack` → the sweep found only `bentopdf`; removing `bookstack-db` too made the whole stack an orphan and the next pass repaired it in 6.3 s. This is deliberate as far as anyone can tell — repairing half a stack while its database is live is not obviously safe — and the customer IS told, because `classifyRunStates` counts a degraded stack as down (`StateDegraded` is in `IsDownState`). What does not happen is the automatic REPAIR. **The rule is recorded here so it is not re-derived from an absent observable; the test that pins it is owed to the next controller release** (R-456 stays open for that half). **The sweep observes a SETTLED fleet, not a single early sample.** It samples (name, state, container count) every 5 s, calls the fleet settled after 3 identical samples, and sweeps **once**, at the end. The window ends on settled or a 50 s budget, and the log says which. Two constraints bound it: - `bootReconcileSettle + budget + one DefaultRetryDelay` must stay inside `deadAppBootGrace`, or a successful recovery stops being silent. This is arithmetic, pinned by a test. - **each sample must REFRESH first.** `GetStacks()` is the Manager's in-memory map, refreshed by the scheduler on its own 10 s cadence; sampling it faster without refreshing lets "settled" mean "the cache did not update". Found by live validation, not review. A recovery completing after the grace emits a `LATE RECOVERY` warning naming the apps. The grace is **never** widened to make a late recovery look silent. **Nothing is started without asking whether it may be.** `bootrecon.StartGate` is the one question the sweep asks per candidate, and it is **fail-safe: cannot determine ⇒ do not start.** Three holders answer it, and the last two only became reachable once the window widened past T+5 s: | Holder | Why starting would be wrong | |---|---| | the drive-absent gate | compose creates bind sources wherever the mountpoint points — the guest rootfs | | a quiesce | a running app inside a snapshot meant to be clean-shutdown-consistent | | an in-flight app-data operation | restarting an app under its own tar | The rule is **not new** — the API's `startGatedByMissingDrive` already refused a customer's start on an absent drive. The sweep bypassed it by calling `Manager.StartStack` directly, which is what R-171 closed. Held apps are reported separately from "still down": they are not a fault the sweep failed to fix, and reporting them as one is a false alarm. **Read this before adding a fourth caller of `Manager.StartStack`.** That method has no gate of its own; every caller that is not the customer must decide for itself whether the app may run. --- ## 1. v0.33 module inventory (package → purpose, key deps) | Package | Purpose | Key internal deps | |---|---|---| | `cmd/controller/main.go` | Entry point; wires all subsystems; 6 adapters break import cycles; branches into setup mode | imports **every** package | | `api/` | REST API (`router.go`) + geo endpoints (`geo.go`) | stacks, backup, metrics, notify, selfupdate, sync, system, assets, integrations, cloudflare, config, settings | | `appexport/` | `.fab` app export/import (config+DB+volumes, AES-256-CTR+scrypt) | **backup** (DB dump), (provider iface → stacks) | | `assets/` | Download/cache app assets from Hub API | — (HTTP only) | | `backup/` | DB dumps, Docker-volume archive, **restic**, **cross-drive rsync**, per-app restore, **drive mount**, disk-layout, infra-backup metadata | config, monitor, settings, system, util | | `cloudflare/` | Geo-restriction via Cloudflare WAF (zone/waf/geosync/countries) — **enforcement → hub** (S4) | settings | | `config/` | `controller.yaml` schema + load | — | | `crypto/` | AES-256-GCM for app.yaml secrets | — | | `integrations/` | App-to-app (OnlyOffice→FileBrowser/Nextcloud) via docker exec / config patch | stacks, crypto, settings | | `metrics/` | SQLite time-series: system + container metrics, log scan | system | | `monitor/` | App health (`healthcheck`,`pinger`) + **storage/USB watchdog** | config, notify, settings, system | | `notify/` | Hub event push (direct, own API key) | settings | | `recovery/` | Generate `recovery-info.txt` (DR guide) | — | | `report/` | Build+push hub report; **infra-backup payload**; **recovery pull** | backup, config, metrics, monitor, scheduler, settings, stacks, system | | `scheduler/` | Cron/interval jobs, Budapest TZ | — | | `selftest/` | Startup checks (docker/dirs/catalog/hub/**restic repos**/mountpoint) | backup, config, settings, system | | `selfupdate/` | Self-update: pull image, edit compose, `up -d` | config | | `settings/` | `settings.json` persistent state: **storage paths/disconnect/decommission**, cross-drive cfg, notif prefs, geo, integration state, DB-validation cache | — | | `stacks/` | Docker Compose lifecycle, deploy + memory validation, metadata (`.felhom.yml`), HDD-data delete | config, crypto, system | | `storage/` | **Physical disk** scan/format/attach/mount/migrate/fstab/safety | backup, settings, util | | `sync/` | Catalog git-sync (pull templates) | config | | `system/` | Resource info: mem/cpu/load (guest) + **temp/disk-model/USB/mount topology (host)** | — | | `util/` | String helper | — | | `web/` | Hungarian dashboard: pages, auth, deploy, backup UI, **storage/disk UI**, DR restore UI, export UI, debug | appexport, backup, config, crypto, integrations, monitor, notify, scheduler, selfupdate, settings, stacks, storage, system | --- ## 2. Classification table (per package/file) ### `cmd/` | File | Class | Reason | Risk | |---|---|---|---| | `cmd/controller/main.go` | **MODIFY** | Wiring stays, but drop the setup-mode branch, the storage/watchdog/drive-migrator/restic/cross-drive/infra-backup wiring, and add the **agent local-API client**. 6 adapters shrink. | hazard | ### `api/` | File | Class | Reason | Risk | |---|---|---|---| | `api/router.go` | **PORT/MODIFY** | Keep stacks/deploy/integrations/metrics/sync/assets/selfupdate routes; **remove `/api/storage/*` (disk)**; backup routes become **agent-coordinated guest-backup** requests; `config/apply` (hub-pushes-yaml) changes since the **agent** now injects config at provision. | needs-rework | | `api/geo.go` | **PORT/MODIFY** | Keep the customer-facing geo **preference** endpoints (set/get global + per-app); **drop the Cloudflare-sync trigger** — enforcement → hub (S4). The controller reports geo desired-state up instead of calling the CF API. | needs-rework | ### `appexport/` — KEEP/PORT (Docker-volume + DB level, no disk ops) | File | Class | Reason | Risk | |---|---|---|---| | `crypto.go` | **KEEP** | Self-contained AES-256-CTR+HMAC+scrypt for `.fab`. | clean | | `manifest.go`, `provider.go` | **KEEP** | Bundle metadata; provider interface (impl in main). | clean | | `export.go` | **PORT** | Docker-volume `tar`, DB dump via `backup.DumpOne`, config copy. Depends on the **retained** app-data-backup subset of `backup/`; HDD-mount enumeration reworked to **per-volume placement**. | needs-rework | | `restore.go` | **PORT** | `docker volume create`/`tar xf`, DB import, compose up. Same per-volume rework. | needs-rework | | `estimate.go` | **PORT** | `du`/`df` on mounts → per-volume sizing. | clean | ### `assets/` | File | Class | Reason | Risk | |---|---|---|---| | `syncer.go` | **KEEP** | Hub API download + checksum cache; already a direct hub channel. | clean | ### `backup/` — THE SPLIT (delete side interleaved with keep side; see §3) | File | Class | Reason | Risk | |---|---|---|---| | `dbdump.go` | **KEEP** | Pure `docker exec pg_dump`/`mariadb-dump` — app/DB data layer; the retained per-app backup. | clean | | `appdata.go` | **PORT** | App-data discovery (stacks/volumes/DB containers, `du`). "HDD mount" concept → per-volume. | needs-rework | | `backup.go` (1478 L) | **MODIFY (split)** | Mixes **keep** (`RunDBDumps`, `DumpAppVolumes(Safe)`, app restore) with **delete→agent** (`RunBackup`/`backupDrive`/restic snapshot/prune/check on per-drive repos). Must be torn in two. | hazard | | `restore.go` (442 L) | **MODIFY (split)** | `RestoreApp` restic path → agent; Docker-volume + Tier-2 rsync restore (app layer) → keep. | hazard | | `restore_app_linux.go`/`_other.go` | **PORT** | Per-app restore: compose pull/up, rsync app data, DB-dump restore. App layer; depends on backup location that changes. | needs-rework | | `paths.go` | **MODIFY (split)** | `AppDBDumpPath`/`AppVolumeDumpPath` keep; `Primary/SecondaryResticRepoPath`, `InfraBackupDir` → agent. | needs-rework | | `restic.go` | **DELETE (→agent)** | restic repos on drives = infra backup tier; agent does vzdump/PBS. | hazard | | `crossdrive.go` | **DELETE (→agent)** | Tier-2 cross-drive rsync to secondary storage = storage-tier (agent + storage manifest). | hazard | | `restore_drives_linux.go`/`_other.go` | **DELETE (→agent)** | `lsblk`/`blkid`/`mount`/fstab — pure host disk. | hazard | | `disk_layout.go` | **DELETE (→agent)** | Disk topology for DR → agent. | clean | | `local_infra.go` | **DELETE (→agent)** | Per-drive infra-backup metadata → agent. | clean | | `restore_scan.go` | **DELETE (→agent)** | Scans drives to build a DR restore plan = agent-tier DR. | needs-rework | ### `cloudflare/` — DELETE (→hub): CF-API enforcement moves to the hub (S4) | File | Class | Reason | Risk | |---|---|---|---| | `client.go`,`zone.go`,`waf.go`,`geosync.go`,`countries.go` | **DELETE (→hub)** | The **hub** holds the CF API token and reconciles geo desired-state → WAF (doc 01 §5, doc 03 §2). The controller no longer calls the Cloudflare API — it reports geo desired-state up. The customer-facing geo *preference UI/data* stays (see `api/geo.go`). | needs-rework | ### `config/`, `crypto/`, `util/` | File | Class | Reason | Risk | |---|---|---|---| | `config/config.go` | **MODIFY** | Drop `BackupConfig` (restic/retention), storage-drive keys, and `InfrastructureConfig.cf_api_token` (→hub, S4); keep customer/paths/web/git/stacks/monitoring/hub/assets/system; **add agent local-API endpoint+token**. | needs-rework | | `crypto/crypto.go` | **KEEP** | App.yaml secret encryption. | clean | | `util/strings.go` | **KEEP** | Trivial helper. | clean | ### `integrations/` — all KEEP (pure app-domain) | File | Class | Reason | Risk | |---|---|---|---| | `integrations.go`,`lifecycle.go`,`manager.go`,`onlyoffice_filebrowser.go`,`onlyoffice_nextcloud.go` | **KEEP** | App-to-app via `docker exec` / compose-config patch; no host ops. | clean | ### `metrics/` | File | Class | Reason | Risk | |---|---|---|---| | `store.go`,`logscanner.go`,`telemetry.go`,`types.go` | **KEEP** | SQLite store, `docker logs` scan, container telemetry — app-domain. | clean | | `collector.go` | **PORT** | Container metrics (`docker stats`) keep; host metrics via `system.GetInfo` (temp, physical disk) become **agent-provided or dropped**. | needs-rework | | `sysinfo.go`/`sysinfo_other.go` | **MODIFY** | Reads `/host/etc`, `/proc/cpuinfo`, uptime — host static info; in-guest some is meaningful, hardware identity via agent. | needs-rework | ### `aftercrash/` + `stacks/after_unclean_boot.go` — KEEP (v0.311.0, R-1007, `09` §3 decision 236) **[FACT, read from source 2026-10-14, felhom-controller `3b85a0c`]** Once per controller start that followed an UNCLEAN host boot — `crashboot.Grace.Decided` (the host crash guard's fact via the agent; unknown = a normal boot), after `CrashGrace` (15 min) — `aftercrash.Runner` runs each deployed app's `after_unclean_boot` template block: scan the app's folder for zero-byte regular files (no symlinks, one filesystem), map names to the app's ids, run the app's own repair command for those ids in batches, re-scan, retry once, run the app's lost-file check; record in `app.yaml` (`after_unclean_boot:`), push `app_repaired_after_crash` (counts only). The commands run through a QUIET compose exec (`quietComposeExec`: nothing logged, stderr dropped, the error is the exit code only) because their output can carry the household's file names. The household's lost list is shown on the app page (`lostFilesNote`) until dismissed. ### `monitor/` | File | Class | Reason | Risk | |---|---|---|---| | `healthcheck.go` | **PORT (split)** | Keep guest health (mem/cpu/docker/protected-containers); host health (temp, **physical disk**, storage-path mount status) becomes **agent-fed**. | needs-rework | | `pinger.go` | **DELETE (obsolete)** | Legacy Healthchecks.io; `main.go` itself marks it "deprecated… now handled by Hub". *(Corrects the task's KEEP/PORT guess.)* | clean | | `watchdog.go` (902 L) | **DELETE (→agent)** | Storage/USB disconnect monitoring: `umount -l`, `mount -T /host-fstab`, UUID probing, restic-lock cleanup — pure host storage. | hazard | ### `notify/`, `recovery/`, `scheduler/`, `selftest/` | File | Class | Reason | Risk | |---|---|---|---| | `notify/notifier.go` | **KEEP/MODIFY** | Direct hub event channel (own API key) — keep; prune infra event types that move to the agent (`storage_disconnected`, `crossdrive_*`, `disaster_recovery_*`). | clean | | `recovery/info.go` | **KEEP (rewritten v0.308.0)** | `recovery-info.txt` (only when a retrieval password is set): points at the dashboard's Restore page and the ten-word recovery code, in the box's language (R-554). | clean | | `scheduler/scheduler.go` | **KEEP** | Generic cron/interval, Budapest TZ. | clean | | `selftest/selftest.go` | **PORT** | Keep docker/dirs/catalog/hub checks; drop restic-repo + system-data **mountpoint** checks (→agent). | needs-rework | ### `report/` | File | Class | Reason | Risk | |---|---|---|---| | `pusher.go` | **KEEP** | Direct hub push (`/api/v1/report`, Bearer). | clean | | `telemetry.go` | **KEEP** | Per-app telemetry section. | clean | | `builder.go` (326 L) | **MODIFY** | Keep containers/telemetry/stacks/geo/app-health; drop/relocate host system info, physical storage, **restic backup status incl. restic password**. | hazard | | `types.go` | **MODIFY** | Schema: drop infra fields (`restic password`, physical storage), keep app-domain. | needs-rework | | `infra_backup.go`/`_linux.go`/`_other.go` | **DELETE (→agent)** | Builds infra-backup payload (disk layout, restic/enc passwords) for hub. | hazard | | `infra_pull.go` | **DELETE (→agent)** | Pulls recovery config + infra backup from hub (setup-wizard DR). | needs-rework | ### `selfupdate/` — controller is agent-managed (doc 03 §11) | File | Class | Reason | Risk | |---|---|---|---| | `version.go` | **KEEP** | Semver parse / version string (still used for reporting). | clean | | `state.go` | **DELETE (obsolete)** | Self-update audit state — the agent owns controller updates now (doc 03 §11). | clean | | `updater.go` | **DELETE (→agent)** | Resolved (doc 03 §11): the controller is **agent-managed** — the agent snapshots → redeploys → health-gates → rolls back the controller. The controller's old self-update path (image pull + compose edit) is **removed**. | clean | ### `settings/` | File | Class | Reason | Risk | |---|---|---|---| | `settings/settings.go` (1101 L) | **MODIFY (split)** | Keep notif prefs, integration state, geo, DB-validation cache, cross-drive *intent*. The **storage-path registry** (`StoragePath` with `Disconnected`/`DisconnectedAt`/`StoppedStacks`/decommission) is disk-management state → reshape to **per-volume placement** fed by the agent's storage manifest; disconnect/decommission/migrate state leaves. (UUID is *not* a persisted field — runtime-derived from fstab.) | hazard | ### `setup/` — DELETED in controller v0.308.0 (R-554); kept here as the record | File | Class | Reason | Risk | |---|---|---|---| | `handlers.go`,`setup.go`,`csrf.go`,`network.go` | **DELETE (obsolete)** | First-run wizard (hub-restore, manual config, LAN-IP detection). | needs-rework | | `scanner.go` | **DELETE (→agent)** | Drive scan (`lsblk`+temp mounts) for backup discovery — host op; its capability informs the agent. | clean | ### `stacks/` — core app domain (KEEP/PORT) | File | Class | Reason | Risk | |---|---|---|---| | `manager.go` (1074 L) | **KEEP/PORT** | Docker Compose orchestration, scan/state/start/stop/logs — the heart. Minor port. | clean | | `deploy.go` | **PORT** | Memory validation (`system.GetMemoryMB` — **guest** mem, fine in LXC), secret gen, encrypted app.yaml. **Add snapshot-before-deploy → agent** hook. | needs-rework | | `healthprobe.go` | **KEEP** | TCP/HTTP app probes. | clean | | `metadata.go` | **PORT** | `.felhom.yml` parse. **Add per-volume hot/bulk classification** (doc 01 §8). | needs-rework | | `delete.go` | **PORT** | Stack delete + HDD-data `os.RemoveAll` on bind mounts → per-volume cleanup. | needs-rework | ### `storage/` — entire package DELETE (→agent) | File | Class | Reason | Risk | |---|---|---|---| | `scan*`,`format*`,`attach*`,`migrate*`,`migrate_drive*`,`safety*` | **DELETE (→agent)** | Physical disk: `lsblk`/`sfdisk`/`wipefs`/`mkfs.ext4`/`partprobe`/`mount`/`umount`/fstab/`blkid`/drive-rsync. The agent owns all of this (doc 01 §3, §8). | hazard | ### `sync/` | File | Class | Reason | Risk | |---|---|---|---| | `sync/sync.go` | **KEEP** | Catalog git-sync (clone/fetch/reset). **Since controller v0.235.0 it RENDERS `docker-compose.yml` rather than copying it** — verbatim while the catalog still offers the app's pinned version, from the app's stored `applied-compose.yml` once the catalog moves past it. `.felhom.yml` is still copied verbatim always, and `app.yaml` is still never touched. Reasoning: `09-update-architecture.md` §5. | clean | ### `system/` — split per-function (not per-file) | File | Class | Reason | Risk | |---|---|---|---| | `cpu_linux.go`/`cpu_other.go` | **KEEP** | `/proc/stat` works inside an LXC. | clean | | `info.go`/`info_other.go` | **KEEP** | Structs/stubs. | clean | | `info_linux.go` | **MODIFY (split)** | Keep mem (`/proc/meminfo`)/load/statfs (guest); **temp via `/host/sys`, hwmon → agent**. | needs-rework | | `mounts_linux.go`/`mounts_other.go` | **DELETE (→agent)** mostly | Mount-point detection, USB, disk model, fstab, probe — host/disk. Guest-meaningful `statfs` disk-usage is the only keep-candidate → fold into the kept `info`. | hazard | ### `web/` — split by UI surface | File | Class | Reason | Risk | |---|---|---|---| | `auth.go`,`csrf.go`,`logbuffer.go`,`embed.go`,`templates.go` | **KEEP** | Session/CSRF, log ring buffer, embeds/logo. | clean | | `funcmap.go` | **KEEP/PORT** | Template helpers; a few backup/state labels track the backup rework. | clean | | `server.go` (559 L) | **MODIFY** | Routing/wiring; remove storage/DR-restore/watchdog wiring; keep app/deploy/backup/settings/export/debug. | needs-rework | | `handlers.go` (1883 L) | **PORT/MODIFY** | Core pages keep; the embedded **storage-path management** (add/remove/label/schedulable, storage bars, FileBrowser mount sync) → per-volume / agent-fed. | hazard | | `handler_export.go` | **KEEP/PORT** | `.fab` UI. | clean | | `handler_debug.go` (823 L) | **PORT** | Drop storage-simulate/infra-push/DR debug; keep the rest. | needs-rework | | `alerts.go` | **PORT/MODIFY** | Storage-disconnect alert now sourced from **agent** status; backup/update alerts keep. | needs-rework | | `handler_restore.go` | **DELETE (→agent) / MODIFY** | DR restore-mode UI; DR is agent-tier — replace with an agent-status view or remove. | needs-rework | | `storage_handlers.go` (1600 L) | **DELETE (→agent)** | Format/attach/mount/disconnect/migrate-drive/decommission disk UI. Any survivor is a **thin client calling the agent API** (e.g. per-volume placement requests). | hazard | | `templates/` (HTML, non-Go) | **PORT** | Remove disk-wizard + DR pages; keep app/deploy/backup/settings pages. | needs-rework | #### Alert placement — inline under the storage bars, or the top banner (R-571) **[FACT, read from source 2026-10-05, felhom-controller `114ff27`, `controller/internal/web/alerts.go`]** Every dashboard alert is an `Alert` with two placement fields: `PageOnly` (the pages it may appear on; empty = every page) and `Inline` (rendered by the page template in place, not by the layout's banner). `GetAlerts` returns the list (endpoint-drift, agent-channel and dead-app alerts first, then the rest sorted error > warning > info, capped at five plus an overflow line), and `layout.html` paints only those that are not `Inline` and match the page; `GetInlineAlerts(page)` hands the dashboard and monitoring pages their inline ones. **Exactly one warning is inline today:** the „storage is not on a separate drive" health warning. It is `PageOnly: dashboard, monitoring` and `Inline: true`, so it sits quietly under the storage bars; **every other warning, including every off-site failure (`07` §6.7), renders in the top banner on every page.** The choice is made by the warning's KIND (`monitor.WarnKindStorageNotSeparate`, read with `report.WarningKindAt`), never by its words — the earlier Hungarian-substring test would have moved the warning to the red banner on every page the day the sentence was translated (R-553, pinned by `TestR553_DiskWarningPlacementSurvivesWordingChange`). A new inline warning needs its own kind, not a text match. #### The app page — one page, four tabs (v0.315.0) **[DESIGN]** One app is ONE page. Until v0.314.0 it was six — details, install/settings, logs, export, the per-app backup panel, and a details card for moving data — and every „Vissza" on them went to the app LIST, so there was no way back to the app you were looking at. | URL | Tab | Holds | |---|---|---| | `/apps/{slug}` | — | the default tab | | `/apps/{slug}/settings` | „Beállítások" | „Működés" (Start / Restart / Stop), the cards that want something from the household, the install-time and optional settings | | `/apps/{slug}/data` | „Adatok és mentés" | the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export | | `/apps/{slug}/guide` | „Útmutató" | tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs link | | `/apps/{slug}/logs` | „Napló" | the log | | `/apps/{slug}/install` | — | the install wizard — a page, not a tab | **[DESIGN] The default tab is RENDERED at the bare URL, never redirected to.** `/apps/{slug}` serves the settings tab's content for an installed app and the guide for a not-installed one, with the URL unchanged, so the address an alert, an e-mail, a bookmark or the app list carries stays the app's one address. A redirect would make every such link bounce and would put a tab name into addresses nobody chose. **[DESIGN] The tabs are links, not panels.** A link in an alert or an e-mail has to land ON a tab, and the log tab's poller must not run while the household reads the settings; one page with hidden panels gives neither. Each tab is an `` with `aria-current`, and the log poller is inside the log tab's template. **[DESIGN] A not-installed app has one view and one URL.** No tab bar — a bar of one tab is furniture — and every tab suffix answers 302 to the base. **[DESIGN] The old URLs are a bridge, and only for reads.** `/stacks/{name}/{deploy,logs,export,backup}` GET answer **302** to the tab that now holds them, with the query string carried through; `/deploy` goes to the settings tab for an installed app and to the install wizard for one that is not. **302 and never 301:** a 301 lives in the browser's cache beyond anyone's reach, and these paths are a bridge for one release, not an address. `name → slug` is resolved through the stack list, never assumed equal — the two are equal for every catalog app today, which is exactly why assuming it would break on the first app where they differ, on a page nobody tests. **Every POST on those paths is untouched.** The backup form, the app-email toggle and the `auto-field/reveal` endpoint keep their URLs and their handlers; the redirect cases are matched on `GET`. Redirecting a POST would turn a save into a page fetch and lose the household's input. #### After-start fields — the catalog asks, Go answers (v0.315.0, D2) **[DESIGN]** Some values can only be given once the app runs: Plex's claim code is minted on plex.tv and handed to a server that is already up (R-1028/R-1060). A catalog field marked `after_start: true` is skipped by the install wizard and gets a card on the settings tab. **[DESIGN] The catalog declares THAT a value is asked, and what to call it. It never declares what happens to the value.** The action is Go, in a registry keyed by `slug + "/" + ENV_VAR` (`web/after_start.go`), with `Needed(stack)` and `Apply(…, value)`. The household's text is therefore an argument to a function in the controller's own source, and never a fragment of a command, a URL or a container name that a catalog file chose. Adding a template cannot add an action. **[DESIGN] Both halves are required, and each is checked on its own.** A field the catalog declares with no registered action renders no card, answers its POST with 404, and logs one WARN naming the env var KEY — a field that silently does nothing is the kind of absence nobody notices. An action registered for a field **this box's catalog does not declare** is equally refused: the registry alone is not an invitation. The second check looks redundant and is not — it was deletable with the whole suite green until a test was written for it, and without it a POST for an undeclared field reached Plex and opened the setup gate. The value is a credential and is never logged. `/apps/plex/plex-claim` remains and runs the same registry entry, so a page open across the update still works. #### The catalog's user guide (v0.315.0) **[DESIGN]** `app_info.guide` carries `sections` (title, optional `intro`, `steps`) and `faq` (`q`, `a`), rendered on the guide tab. The copy model is `10-localisation.md` §7; the guide block is described there. **[DESIGN] `checked_version` is the render gate, and it is not copy.** It names the image tag the guide's author actually clicked through. A guide is rendered only when it is set; otherwise the tab shows its other content and says a guide is still to come. A guide walking a household through a screen two releases old is worse than no guide, and „someone wrote this against a version" is the only cheap evidence that it was ever true. Being outside the copy model is deliberate: a translator does not get to vouch for a version, so an overlay can neither set nor clear it. **[DESIGN] Guide text is DATA.** Plain text through `html/template` — no markdown, no raw HTML — so a step containing a tag is shown as the characters its author typed. #### The app page's layout (v0.319.0, operator-approved 2026-10-16, section style A) **[DESIGN]** The mockup canvas „Felhom app oldal — egy oszlop" (Settings screen), operator-approved 2026-10-16 with section style **A — panels**. It replaces v0.316.0's hairline sections: with the hero, the tab bar and every section on the same page background, the operator found the page flat — nothing told one section from the next. 1. **One column of one width** (820px; the log tab alone is full width, because log lines are long). Every panel is the column's full width — no narrower inner card. Boxes of differing widths read as a page assembled by accident. 2. **The hero is a panel** (`--bg-1`, `--line` border, 20px 24px, 16px above it; the logo tile on `--bg-2`). Its content is unchanged: the name, the tagline, the chips, the meta text. 3. **The tab bar is a panel** (`--bg-1`, 4px inside, 16px above, 20px below). A tab is 10px 18px, 15px/600 in `--text-2`; the open tab is filled `--bg-2` with a 2px `--blue-bright` underline. 4. **Every section is a panel, ONE style on every surface** — the four tabs and the install page, never mixed: `--bg-1`, `--line` border, `18px 24px 22px`, 16px apart; the title 17px/700 18px from the top edge with a `--line-soft` rule under it. The install page's form groups (`.form-section`) and its progress panel are drawn by the same rule. Pinned by `TestAppPage_OneSectionStyleEverywhere`. 5. **Colour still means something.** A panel is neutral. Blue (`--blue` on `--blue-dim`) is the app WAITING for the household; amber (`--warn` on `--warn-dim`) is something RISKY. The callouts keep their colours, inside a panel or as their own. 6. **A success is not a banner.** The automatic-update SUCCESS line is meta text in the hero. Everything that DOES need a decision — a failed update, a hold, lost files, a missing drive, an interrupted install — stays a banner above the tabs (R-571's placement rule, unchanged). **[DESIGN] „Belépés az alkalmazásba" — the login in one panel.** The settings tab shows the app's login name and password side by side (stacked under 720px). A login field is a `type: password` field plus, when the template has one, the `secret` or `text` field with the same prefix and `USER`/`USERNAME`/`EMAIL` (`web/app_login.go`); a password with no clean pair stands alone. Values are fetched on demand (R-254, R-709) — the page learns only whether one is stored. **[DESIGN] The empty-value rule.** A login field the box holds no value for is a SENTENCE („A doboz ennél a telepítésnél nem mentett jelszót. A belépéshez azt használd, amit az alkalmazásban beállítottál."), never an eye: the reveal endpoint answers 404 for it, a refusal the household could only meet by pressing. The page and the endpoint decide „stored" with ONE function (`storedValuePresent`). Measured cause of the case that earned it: an app installed before its template gained a `type: password` field — `InjectMissingFields` never fills one (`audits/app-page-tuning-2026-10-16/P0`). #### Logins and folders (v0.320.0, task 2026-10-16 „logins-folders") **[DESIGN] A protected infra stack is part of the box, and its app page says so.** FileBrowser (and traefik, cloudflared, samba) has no `app.yaml`, so `Deployed` is false. Every other surface counts `Deployed || Protected` as on the box; v0.315.0's app page counted `Deployed` alone and showed FileBrowser „not installed", with „Telepítés" and without its login (measured on both demo boxes, `audits/logins-folders-2026-10-16/P0`). The app page now treats a protected stack as installed: settings, guide and log tabs; Restart only (as the app list); the curated name (`inframeta.go`); no data tab and no install page (both 302 to the page). The deploy API and `DeployStack` refuse a protected stack — until then the Install press would have deployed the infra compose as an app. **[DESIGN] Every installed app says where its login is — ONE answer.** The settings tab's „Belépés az alkalmazásba" panel is on every installed app with exactly one of: the box's login fields (on demand; a field with no stored value is the v0.319.0 sentence) · the login the app wrote, or FileBrowser's (`InitialCreds`) · a sentence chosen by the catalog's `app_info.login_kind` — `first_visit` (the default: „you create the admin on the first visit; the box does not know the password"), `email` (a sign-in link), `none` (no sign-in; behind `family_gate` the sentence says the family sign-in is the login). `login_kind` is an enum, not copy: the sentence is the controller's. An infra stack with no login of its own (traefik, cloudflared, samba) gets no panel. Code: `web/app_login.go` `loginAnswerFor`. **[DESIGN] „Új jelszó beállítása" — FileBrowser's password when the box does not know it.** In R-513's `operator` state (both demo boxes) the panel says „Ezt a jelszót te állítottad be a Fájlkezelőben; a doboz nem ismeri." and offers the press. The box uses FileBrowser's own CLI (`set -u admin,`) with the server STOPPED (the CLI opens the database with no lock timeout), verifies new=200 and admin/admin refused, stores the value encrypted and records `generated`; a failed verify leaves `operator`. Not the `FILEBROWSER_ADMIN_PASSWORD` env: it re-applies on every start. Spike: `audits/logins-folders-2026-10-16/P1b`. FileBrowser still asks for its own login the first time; one sign-in for both is R-1100. **[DESIGN] A stored FileBrowser password is checked before it is offered (v0.321.0, R-1101).** In `generated`, the page asks FileBrowser whether the stored value still logs in; only FileBrowser's own 401/403 counts as refused (a FileBrowser that does not answer is not a refusal — an unproven refusal must not tell the household its password changed). Refused → „A Fájlkezelőben megváltoztattad a jelszót, ezért a doboz már nem ismeri." and „Új jelszó beállítása". The page no longer tells the household to change FileBrowser's password, and FileBrowser's own update notice and „Install app" prompt are switched off in its config (the box updates it; it is opened from the dashboard). **[DESIGN] The Data tab's backup part is ONE section (v0.321.0).** „Biztonsági mentés": an „Éjszakai mentés" row, the 2nd copy's state as rows, what the 2nd copy is, its form. Plain words — no „off-drive", no „PBS", no „DB/konfiguráció" (pinned by `TestAppData_OneBackupSection`). **[DESIGN] An installed app's install address goes to its settings tab (v0.321.0, R-1095)** — 302, GET only, like the `/stacks/{name}/deploy` bridge. **[DESIGN] „Fájlok ↗" — one of the app's folders, in FileBrowser.** The header button opens the folder with the library role, else the first declared folder, else (a media app) the first present drive's `media` folder; built only with `fileBrowserLink` (PathEscape). The data tab lists each drive's media folders the app uses (Sonarr `tv`, Radarr `movies`, the players both, a download client its `media_library.folder`) with NO backup sentence — the bind's class there means „not this app's data", which is not the class-driven „temporary, not backed up". **A folder whose drive is not bound into FileBrowser now is text, never a link** (`drivePresent` = the same gates the FileBrowser sync applies). The button never promises one click: FileBrowser's router brings a cold visitor back to the folder after its login (measured in the headless browser, `audits/logins-folders-2026-10-16/browser/`). **[DESIGN] „Cím és kulcsok" is rows, not the install form.** The settings tab does not re-render the install wizard's fields: the address is the „Cím" row, the storage is a „Tárhely: " row, every other install answer and generated key is a row (keys keep their eye). The read-only hint sits under the title. `/apps/{slug}/install` opened for an installed app still renders the form read-only, with its secret guards. **[DESIGN] The stylesheet's address changes with every build.** Every shell links `/static/style.css?v={{.AssetTag}}`, where the tag hashes version + `GitCommit` + `BuildTime` and stays opaque (8 hex) because the stranger-facing share pages use the same value. The origin sends no ETag and no Last-Modified, so a cached copy cannot be revalidated — only expire, or be addressed differently. This is the „addressed differently", and it is the fix for a measured defect: a Cloudflare edge served 0.315.0's first stylesheet under the address its third build still asked for (`audits/app-page-2026-10-15b/P0/VERDICT.md`). `Cache-Control` is deliberately unchanged. ### `scripts/` | File | Class | Reason | Risk | |---|---|---|---| | `scripts/hashpass.go` | **KEEP** | Standalone bcrypt helper. | clean | --- ## 3. Coupling hazards (delete-targets depended on by keep/port) 1. **`backup/` is half-deleted but split *inside files*, not across them.** `backup.go` contains both `RunDBDumps`/`DumpAppVolumesSafe`/app-restore (keep) and `RunBackup`/`backupDrive` + restic (delete→agent); `restore.go` and `paths.go` are likewise mixed. **Keep/port consumers reach into this same package:** - `appexport/export.go:295` → `backup.DiscoverDatabases`/`DumpOne` (DB dump is app-layer — must survive) - `report/builder.go:buildBackupReport` → backup status (MODIFY) - `web/handlers.go` (backups page, `buildAppBackupRows`), `web/funcmap.go`, `web/alerts.go`, `web/handler_restore.go`, `web/handler_debug.go` - `selftest/selftest.go:217` → `checkResticRepos` (restic path — delete) - `main.go` scheduler chain `RunFullBackup` (DB→volume→restic→infra-push) interleaves both sides. **Action:** extract the app-data-backup subset (DB dump, volume archive, per-app restore) into a clean retained package *before* deleting the restic/cross-drive code, or every keep consumer breaks. 2. **`backup/crossdrive.go` (delete→agent) is wired as `crossDriveRunner` into** `main.go`, `api/router.go`, `web/server.go`, and surfaced by `report/builder.go` and the backups page. Removing it requires reworking the backup UI/report to the agent's guest-backup status. 3. **`storage/` (delete→agent) depended on by keep/port UI:** `web/storage_handlers.go` (delete) and `web/server.go`/`web/handlers.go` (port) — the latter renders storage labels/bars and runs **FileBrowser mount sync** off the storage-path registry. `storage/migrate*.go` also imports `backup` (also being split). Untangle the per-volume placement UI from the disk-management UI. 4. **`monitor/watchdog.go` (delete→agent) depended on by** `web/alerts.go` (port), `web/server.go`, `web/handler_debug.go`, `main.go`. The disconnect **alert** must instead consume agent-reported storage status. 5. **`system/` mixed-per-function, consumed by both sides.** Keep consumers — `stacks/deploy.go` (`GetMemoryMB`, guest), `metrics/collector.go` (container) — must not drag in the host-disk/temp/USB code that goes to the agent (`mounts_linux.go`, `info_linux.go` temp). Also consumed by `report/builder.go` (MODIFY), `monitor/healthcheck.go` (PORT), `selftest`, `crossdrive` (delete). **Split `system/` cleanly into guest-info vs host-info first.** 6. **`settings/StoragePath` carries disk state into an app-domain store.** Disk fields (`Disconnected`,`DisconnectedAt`,`StoppedStacks`, decommission — UUID is *not* persisted, it's runtime-derived from fstab via `system.ParseFstabUUID`/`watchdog.go`) are written by `watchdog.go`/`storage_handlers.go`/`crossdrive.go` (all delete) but the same struct is read by `stacks`/`web` for labels and **placement** (keep). Reshape `StoragePath` to a placement record fed by the agent manifest. 7. **`report/builder.go` imports almost everything** (backup, monitor, scheduler, stacks, system, metrics, settings, config). Its MODIFY must land *after* the backup and system splits, or it pulls deleted code along. 8. **`backup/paths.go` shared both ways** — `appexport` + `selftest` + the kept DB-dump flow use the app-dump path helpers; the same file holds the restic/secondary helpers that leave. 9. **DR/provisioning chain is cross-cut:** `setup/` (obsolete) → `report/infra_pull` + `recovery/info` + `backup.MountDrivesFromLayout` + `backup.ReadLocalInfraBackup`. All obsolete/→agent, but `main.go`'s setup branch and `web/handler_restore.go` reference them; remove together. --- ## 4. Moves to the host agent (consolidated — feeds the future agent design) > Reporting only; **not** designing the agent here. - **All physical-disk management** — `storage/` in full: scan/classify, format (`wipefs`/`sfdisk`/`mkfs.ext4`/`partprobe`), attach (raw mount + bind + fstab), per-app and full-drive migration (rsync), safety checks (system-disk detection). - **Storage/USB watchdog** — `monitor/watchdog.go`: disconnect/reconnect detection, `umount -l`, `mount -T /host-fstab`, UUID-by-id probing, safe-disconnect, restic-lock cleanup. - **Infra/disk backup tier** — `backup/restic.go`, `crossdrive.go`, `restore_drives_*`, `disk_layout.go`, `local_infra.go`, `restore_scan.go`, plus the restic-snapshot half of `backup.go`, the restic-restore half of `restore.go`, and the restic/secondary path helpers in `paths.go`. (Maps to the agent's `vzdump`→tiers→PBS in doc 01 §8.) - **Infra-backup payload + recovery pull** — `report/infra_backup*`, `report/infra_pull`. - **Host-physical telemetry** — `system/mounts_linux.go` (mount topology, USB, disk model), the temp/hwmon parts of `system/info_linux.go`, and the host-hardware parts of `metrics/sysinfo.go`. - **Drive scanning for provisioning/DR** — `setup/scanner.go`. - **Self-restore-test execution** — the agent performs the restore-to-scratch-guest; the controller only orchestrates/validates (see §5). --- ## 5. New components to build (no v0.33 equivalent) 1. **Agent local-API client** — the controller's only path to guest-level Proxmox operations (doc 01 §3, §5): `snapshot-before-deploy` + rollback, "grow my RAM", request guest backup/restore, read the storage manifest / mount placement, query per-target storage status. Replaces the deleted direct host/disk code with constrained RPC. The controller holds **no Proxmox creds** — only a local-API token. 2. **Per-volume storage placement** (doc 01 §8) — `.felhom.yml` `hot`/`bulk` volume classification (extend `stacks/metadata.go`), enforcement at deploy (extend `stacks/deploy.go`), and a placement record in `settings`. Replaces the per-app HDD-path + cross-drive model. A `bulk` volume must be realized as a `backup=0` mount point, **never** a rootfs Docker named volume (validated recipe: `phase3-findings.md` B2 / doc 03 §7). 3. **Self-restore-test status display** (read-only) — the **agent owns orchestration** (it holds the PBS key and creates the scratch guest — operator-tier, doc 03 §8); the controller only surfaces `GET /restore-test/status` in its UI. (Round-trip validated: Phase 2, [../proxmox-platform.md](../proxmox-platform.md) §4.) 4. **Snapshot-before-deploy/rollback flow** in the deploy path — wraps the existing compose deploy with agent snapshot → health check → agent rollback-on-failure (doc 01 §9). New behaviour on top of `stacks/deploy.go` + `stacks/healthprobe.go`. 5. **Agent-provisioning bootstrap receiver** — the controller accepts its injected hub API key + local-API token from the agent at provision time (doc 01 §6), replacing the deleted `setup/` wizard. --- ## 6. Open / blocked items - **Geo — resolved (S4):** CF-API **enforcement moves to the hub** (it holds the CF token and reconciles geo → WAF); the controller keeps the geo **preference UI/data** and reports desired-state up. Tunnel placement is settled (host, agent-managed, doc 03 §3/§5). The `cloudflare/` package + `api/geo.go`'s CF-sync are DELETE-from-controller → hub. - **Self-update — resolved (doc 03 §11):** the controller is agent-managed; its self-update path is removed. - **`settings`/`stacks` per-volume reshape** — depends on the storage-manifest contract between hub ↔ agent ↔ controller (doc 01 §8), not yet specified. - **Backup UI/report surface** — depends on the agent's guest-backup status API shape (what the controller can see about vzdump/PBS state) — undefined. - **Notification event taxonomy** — which infra events (`storage_disconnected`, `crossdrive_*`, `disaster_recovery_*`) the **agent** emits vs the controller, once those responsibilities move. --- ## Changelog — design-review + Phase-3 fold-in (2026-06-08) - **M1:** removed `UUID` from the `settings.StoragePath` field lists (§ settings, hazard #6) — it is runtime-derived from fstab, not persisted. - **S4 (geo):** `cloudflare/` reclassified **PORT(blocked) → DELETE(→hub)** (CF-API enforcement moves to the hub); `api/geo.go` → **PORT/MODIFY** (keep geo *preference* endpoints, drop the CF-sync trigger); `config/config.go` also drops `cf_api_token`. §6 + §1 updated. - **S5:** cloudflare/geo no longer "blocked on tunnel placement" (resolved). - **S6:** §5(3) self-restore-test → **status-display only**; the agent owns orchestration. - **Self-update resolved (03 §11):** `updater.go` → **DELETE(→agent)**, `state.go` → DELETE(obsolete), `version.go` KEEP; §6 + §5(2) updated (bulk = `backup=0` mountpoint recipe). --- ## The app-definition seam — what happens to a deployed app when its compose file is rewritten > **STATUS: MEASURED BEHAVIOUR, NOT A RECORDED DESIGN.** This section describes what the system was > observed to do on 2026-09-01 (`audits/SPIKE-app-update-2026-09-01.md`), with controls. It is > deliberately **not** marked `[DESIGN]`, because the operator has not ruled on whether the behaviour > was intended. **Do not read this as an endorsement, and do not spec against it as though it were > settled.** The open questions are R-438 and R-441. Until this was measured, no architecture document said what happens here, and the gap itself is R-438. The three facts below are the ones a reader needs before touching any of it. > **⚠ SECTIONS 1 AND 2 DESCRIBE THE BEHAVIOUR UP TO CONTROLLER v0.234.0. Controller v0.235.0 > (2026-09-06) CHANGED IT, on an operator ruling.** They are kept because they are the measured > account of why it was changed, and because every box below v0.235.0 still behaves this way. **What > ships now: `09-update-architecture.md` §5.** In one sentence — an app's VERSION is frozen to what the > customer has and only a deliberate Update moves it, while template CORRECTIONS and the self-healing > below still arrive on the 15-minute cycle. Nothing was added to the thirteen call sites in §2; they > were made safe by removing the reason. ### 1. The catalog syncer rewrites the file under a running app, on a 15-minute cycle `Syncer.copyTemplates` (`sync/sync.go:319`) walks every directory in the catalog cache and copies `docker-compose.yml` and `.felhom.yml` into the matching stack folder. **There is no test of whether the app is deployed.** The only guard is a sha256 content compare (`copyIfChanged`) and the only exclusion is `app.yaml`. Interval is `git.sync_interval`, default `15m` (`config/config.go:351`), plus one immediate sync at controller start (`sync.go:98`). **SINCE v0.235.0** this walk still happens and `.felhom.yml` is still copied unconditionally, but the compose file goes through `Syncer.renderSource`, which consults a per-app plan supplied by the stack manager (`Manager.RenderPlanFor`) through a nil-safe seam. A nil seam is byte-for-byte the behaviour described above. **It restarts nothing.** The post-sync hook is `stackMgr.InjectMissingFields(updated)` and nothing else. So from the moment it runs, a deployed app's *definition* and its *running containers* disagree, and they stay that way until something else acts. ### 2. Every lifecycle path resolves that disagreement, silently, by upgrading `StartStack`, `RestartStack` and `UpdateStack` (`stacks/manager.go:1029 / 1133 / 1170`) all end in `docker compose up -d`. `up -d` makes the container match the file, **and pulls the image itself if it is absent** — measured at 18.3 s with a pull versus 0.5 s without, against a negative control (unchanged file) that did not even recreate the container. `RestartStack` carries an explicit in-source comment saying this is deliberate — *"so that … any template changes (new images, healthchecks) are picked up"*. **That is a fact about the source, not an operator ruling**, and it speaks only for the customer-pressed restart. **Thirteen non-API call sites across nine files** reach `up -d` without anyone pressing anything. The full table is §8 of the spike doc; the three that matter most are: - `bootrecon.Reconciler.Run` → `StartStack` — `bootrecon/bootrecon.go:269` - `backup.AppStopGuard.Recover` → `StartStack` — `backup/appstop_marker.go:283` - the drive-return gate, `web.Server.restartStacks` → `StartStack` — `web/intermediary.go:222` **A plain power cut does NOT trigger this.** Docker's `restart: unless-stopped` restores the existing containers on the old image, the reconciler finds no orphan, and it logs so (`no boot-orphaned apps (nothing to start)`). The unattended upgrade needs the narrower precondition *"and the app did not come back"* — which was measured, and does upgrade. ### 3. Nothing takes a copy first, and the tag cannot be put back afterwards `UpdateStack` runs `compose pull` then `compose up -d --remove-orphans` and does nothing else — no dump, no copy, no hold. The R-361 safety dump (`backup/offbox_reconstitute.go:207`) is **database-only** and is not on this path at all; an app with no database gets nothing from it even on the paths where it does run. **And restoring the old tag is not a rollback.** Once an app has migrated its data, the old image refuses to start — measured on Nextcloud: *"the version of the data (32.0.9.2) is higher than the docker image version (31.0.14.1) and downgrading is not supported"*. The data itself survives; only the downgrade is blocked. So the only route back is a **data** restore from a copy taken before the update. **And that route fights this seam** (R-441): `stackAdapter.RecreateStackDefinitionFromUnit` (`cmd/controller/main.go:2570`) writes the recovery unit's captured compose — with the OLD pin — into the live stack dir, and `copyIfChanged` overwrites it again on the next tick. The overwrite is measured; that the restore writes to that path is read, not measured. ### What a change here must not break - The syncer's overwrite is also the **repair** path: a locally-corrupted compose is replaced by the catalog's version within 15 minutes (observed). Any "don't touch deployed apps" rule loses that. - `up -d`-on-restart is what injects `app.yaml` env into a running stack. Reverting to `docker compose restart` would silently stop doing that. ## App settings after install [DESIGN, recorded 2026-09-15] **An installed app's settings are read-only on its page** („Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.", `deploy.html`). This is a design, not an oversight: a changed value would need a guarded re-deploy that nothing performs. **Finding (2026-09-15):** the catalog field flag `locked_after_deploy` (`stacks/metadata.go`) is parsed and read by **no** controller code — every field is read-only after install whatever the catalog says. So a page must never tell a household to change a value after install. **Vaultwarden (R-512)** follows the design instead of breaking it: registration is closed by default and the household is invited from the admin panel, measured to work without mail (stranger 400, invite 200, invited 200).