Skip to content

Latest commit

 

History

History
218 lines (201 loc) · 166 KB

File metadata and controls

218 lines (201 loc) · 166 KB

Implementation progress

ADR-style entries — one per unit of work. Each records what was done and what's next, so the history of the build is legible without reading every commit. Entries are named by kebab-slug (<slug>.md) and are not numbered — sequential numbering was dropped because parallel branches kept colliding on the next number.

Because the filename no longer carries order, the Index below is the only record of build order — entries are listed oldest-first and a new entry is appended to the bottom, never inserted mid-list. Keep that order; it's the chronological spine the old NNNN- prefix used to provide.

Every change ships with a progress entry or an update to one (see ../../CLAUDE.md # Documentation discipline).

Up next

The implementation slice queue, ordered. Each item links back to the progress entry whose "what's next" it satisfies. Pull the top item; when it lands, write the next entry and re-order. Design topics (not implementation slices) live in ../specs/NEXT.md.

This is the maintainer's critical-path queue. Work carved off for parallel contributors lives in GitHub Issues (some items there are pulled from these "what's next" follow-ups). The two are kept from overlapping on purpose. See ../dev/contributing.md for the contributor loop.

  1. GPU + device capacity enforcement. install-permissions-enforcement.md deferred gpu enforcement and device-existence validation (both need a host hardware-introspection endpoint). A 422 from the brain will surface correctly in the UI via the existing dialogError path (install-consent-ui.md) once the host endpoint lands. See NEXT.md # GPU.

Entry template

# <title>

- **Status:** done | in progress
- **Date:** YYYY-MM-DD
- **Specs touched:** docs/specs/X.md, …

## What was done## How it maps to the specs
Which locked decisions this exercises / realizes.

## Known gaps & deviations
Honest list of what's stubbed, faked, or diverges from spec (with why).

## What's next
Ordered follow-ups. Update as they land.

Index

Oldest first; append new entries to the bottom.

Title Status
walking-skeleton.md — Walking skeleton — install an app end-to-end done
reconcile-and-health-wait.md — Startup reconcile + health-wait & splash flip done
door-2-and-admission.md — Door-2 custom apps + admission policy done
image-digest-pinning.md — Image digest pinning (TOFU + catalog verify) done
brain-test-pyramid.md — Brain test pyramid: DockerDriver refactor + Layers 1–3 done
auth-and-users.md — Auth + initial user model (setup, login, sessions, middleware, UI router) done
audit-events.md — Audit events (append-only table, Record(), client IP, call sites, GET /api/v1/audit) done
user-crud.md — User CRUD (admin list/create/patch-role/delete/reset-password + self-service password change) done
recovery-redemption.md — Recovery-code redemption (POST /api/v1/recover) done
session-expiry-elevation.md — Session expiry (idle + hard cap) + 5-minute elevation window done
host-agent-pam-verify.md — Real PAM-based password verification in host-agent-real done
avahi-dbus-publisher.md — Avahi DBus publisher — per-app A records via EntryGroup.AddAddress done
caddy-routing-verified.md — Caddy subdomain routing verified (Host-header routing end-to-end, path-based confirmed absent) done
host-agent-set-password.md — Real set-password in host-agent-real (useradd + chpasswd; /etc/shadow is now the source of truth) done
host-agent-set-role.md — Real set-role in host-agent-real (gpasswd) + brain bootstrap wires SetRole into /setup and createUser done
host-agent-delete-user.md — Real delete-user in host-agent-real (userdel -r -f) + close orphan-on-rollback gap in /setup and createUser done
nspawn-usermgr-lane.md — nspawn fast-lane wiring for usermgrtest (bootstrap.sh + run-usermgr-tests.sh + make test-usermgr-nspawn) done
boot-pipeline-units.md — Boot pipeline: storage-ready target, malmo-storage-verify reporter, brain health registry + GET /api/v1/health done
nspawn-boot-chain-lane.md — nspawn boot-chain fast lane: --boot of dist/systemd/ units + dependency-shape assertions done
qemu-medium-lane-scaffolding.md — QEMU+swtpm medium-lane scaffolding: real kernel + real systemd + TPM plumbing done
health-persistence.md — SQLite persistence for health issues (health_issues table, store write-through, boot-time restore) done
luks-tpm-enrollment.md — LUKS root + first-boot TPM enrollment + PCR-7 unseal verification done
per-issue-health-audit.md — Per-issue health audit records (ApplyStorageFindings returns affected keys; one health.issue.* record per issue) done
owner-scoped-instances.md — Owner-scoped app instances (owner_user_id + scope, <slug>--<user> derivation, install authorization, warn-don't-block) done
dashboard-chassis-home-grid.md — Dashboard frontend: stack chassis (Router/Pinia/Tailwind 4/reka-ui/lucide) + grouped Household/Yours home grid + four-item dock done
health-notifications.md — Health raise/clear → dashboard notifications (notifications table + internal/notify emitter; coalesced, admin-routed, resolved-on-clear) done
notification-read-surface.md — Notification read surface: /api/v1/notifications family + notification_reads per-recipient read/dismiss + notification.created/.updated SSE kinds done
notification-web-ui.md — Notification Web UI: dashboard bell + unread badge + dropdown inbox (useNotifications(), SSE-invalidated TanStack Query, plain CSS) done
notification-clears-transparency.md — Notification clears + member transparency variant (members audience, info-only member notice, "all clear" on resolve, flap retraction) done
notification-category-mute.md — Per-category notification mute (notification_mutes table, read-time filter on list/count/mark-all, GET/PUT/DELETE mute API) done
install-permissions-folders-schema.md — Parse permission fields (folders/devices/gpu) + collapse user_folders/shared_folders into folders with installer-elected source (slice 1 of install consent flow) done
host-agent-resolve-home.md — host-agent GET /v1/users/{username}/home{home_path, uid, gid} (slice 2 of install consent flow) done
install-plan-endpoint.mdGET /api/v1/catalog/{id}/install-plan: role-derived scope options + per-folder source menus for the install consent screen (slice 3 of install consent flow) done
host-agent-well-known-identity.md — host-agent GET /v1/identity/well-known{malmo_app_uid, malmo_app_gid, malmo_shared_gid} for household user:/shared group_add (slice 4a of install consent flow) done
install-permissions-enforcement.md — enforce folder/identity permissions in the override (user:, source-elected bind mounts, group_add, devices, MALMO_FOLDER_*) + authoritative election validation (slice 4 of install consent flow) done
install-consent-ui.md — install consent + config UI in StoreView: InstallDialog (scope picker, permissions display, per-folder source/subfolder elections), 409 duplicate-install confirm flow, 422 inline error (slice 5 of install consent flow) done
single-label-app-local.md — app LAN names go single-label <slug>.local (was <slug>.malmo.local, which Linux nss-mdns rejects) + Avahi collision fallback <slug>-<box>.local; brain trusts the published name for route + URL done
hostname-uniqueness-not-ownership.md — Hostname encodes uniqueness, not ownership (first-come bare slug, --<user> only on collision) done
single-user-simplification.md — Single-user simplification + split-button install (suppress household/personal UI when one user; scope moved from dialog to button) done
notification-retention-prune.md — Notification retention/pruning (store.PruneNotifications: 90-day age cap + 1000-row resolved-first ceiling, boot + hourly loop in brain) done
notification-mute-settings-ui.md — Notification mute settings UI: per-category toggle list in Settings → Notifications (reka-ui Switch, optimistic useNotificationMutes() over the mute API) done
health-system-report.md — Generalize health reporting: GET /v1/health/system + per-category ApplyFindings, with service-down as the first cross-category locus-B detector (two-axis category model, debounce) done
version-mismatch-detector.md — Health detector: version-mismatch (locus C) — brain reconciles host-agent's reported agent_version against a brain-side expected version; raise on mismatch, clear on match, 1-shot done
brain-db-corrupt-detector.md — Health detector: brain-db-corrupt (locus C) — brain runs PRAGMA integrity_check at boot + every 6h, raises on a non-ok result, clears on ok; best-effort, non-blocking, never gates startup done
container-restart-loop-detector.md — container-restart-loop health detector (locus D): brain polls Docker RestartCount deltas over a 5-min window, raises per-instance_id past >3 restarts, clears on stabilize/uninstall done
system-live-sse.md — Live system-resources SSE (GET /api/v1/system/live): host-agent raw-counter GET /v1/system/resources + brain internal/systemlive ref-counted 1 Hz poller diffing counters into rates, all-users top-bar dropdown (LiveResources.vue) done
notification-error-toasts.md — Surface notification mutation failures as ephemeral error toasts (toasts.ts singleton + ToastHost.vue, onError on mute toggle + bell read-state mutations); error-only, no backend change done
sse-stream-cap.md — SSE per-session stream cap (internal/api/streamCap + beginStream): ≤16 concurrent SSE streams per session shared across /api/v1/events and /api/v1/system/live, excess → 429 done
manifest-lint-cli.mdmalmo manifest lint <path> CLI (cmd/malmo): parses + schema-validates a manifest.yml via manifest.Parse and confirms its sibling compose_file exists, parses, and declares main_service (exported manifest.ComposeServiceNames); author inner-loop tool backing the catalog CI schema-lint, no running brain needed done
openapi-codegen.md — OpenAPI codegen pipeline: server-less spec emitter (cmd/openapi-genapi.OpenAPIDocument, committed api/openapi.{json,yaml}) + make openapi-check freshness gate (CI), and a generated TS client (openapi-typescriptweb-ui/src/generated/openapi.ts) replacing the hand-rolled api.ts wire interfaces; openapi-fetch client swap deferred done
api-rate-limiting.md — General API rate limiting: rateLimit middleware (internal/api/ratelimit.go) with two token-bucket planes — per-session (120/min, burst 60) keyed on the session token, per-IP (30/min) on the unauthenticated allowlist — in the chain after auth; SSE + /files/content exempt; locked 429 {code:"rate-limited", details:{scope, retry_after_s}} + Retry-After; in-memory, opportunistic idle-bucket GC, resets on restart (no spec change) done
door-2-admin-only-gate.md — Door-2 custom install is admin-only: requireAdmin gate + failure audit on installCustomApp (POST /api/v1/apps/custom → 403 for members), store path unchanged, admission stays door-symmetric; realizes the locked 2026-06-02 decision (no spec change) done
door2-custom-install-flow.md — Door-2 custom-container install flow: dedicated full-screen CustomInstallView (paste/upload compose, live <slug>.local preview, service dropdown, expose:-prefilled main port, internet toggle, store split-button scope, inline 422 admission coaching) + admin-only Store affordance; backend manifest.InferMainPort (expose:-only) + read-only admin-gated POST /api/v1/apps/custom/inspect + internet election through CustomSpec (DASHBOARD.md # Door-2) done
door2-permission-block.md — Door-2 permission authoring: LAN/GPU toggles + folder-grant rows (Source picker → typed in-container target → read/write) + Edit-as-YAML escape hatch (brain-owned RenderPermissionsOverlay/ParsePermissionsOverlay + admin-gated POST /api/v1/apps/custom/overlay/{render,parse}); Synthesize carries elected Permissions; InferMainPort mines the ports: container side; Folder.Target/FolderMount.Target bind the scope-derived source to the typed destination (DASHBOARD.md # Permissions, # Form is a projection, # Folder grants carry an explicit destination path) done
app-health-probe.md — Per-app HTTP health-probe: optional health_probe manifest field + app-unresponsive locus-C detector that GETs the probe path through Caddy (Host: <slug>, never dialing the container), 2-bad/1-good debounce + start-period grace + steady-running gate; shares the restart-loop poll goroutine; realizes the locked 2026-06-02 decision (no spec change) done
login-rate-limit.md — Dashboard login rate-limit + lockout (auth.LoginThrottle): per-username exponential backoff → 15-min lock (3/5/10/20 fails) + per-IP 10/min token bucket, gated before the PAM round-trip; login.lockout audit on the lock crossing; in-memory, no persistence (AUTH.md # Rate limiting) done
image-prune-on-uninstall.md — Container image reclaim on uninstall: new DockerDriver.RemoveImage (docker rmi by pinned repo@sha256:…); Uninstall reclaims the instance's images iff no other installed instance references them (cross-checked against instance_images after the row is deleted), best-effort; periodic/update-orphaned sweep stays deferred (NEXT.md) done
users-admin-screen.md — Settings → Users admin screen (UsersView.vue): list/create/role/reset-password/delete against the built user-CRUD API, guard rejections inline, confirmed delete; a withElevation retry helper + ElevateDialog drive the 5-minute elevation re-prompt the mutations require; re-enables sign-in (Login user-list picker + public GET /api/v1/auth/users) which the dev phase had stubbed done
health-banners.md — Health / degraded-mode banners (issue #12): SSE seam (health.issue_raised/_cleared kinds threaded through emitHealthTransitions → all five transition paths publish, nil-guarded) + GET /api/v1/health now emitted into the OpenAPI spec (store-less manager in OpenAPIDocument) so the wire type generates; useHealth() composable (Query cache, not Pinia — reconciled WEB_UI.md), global HealthBanner in AppShell for error/critical + toast-on-clear, HealthGated disable-with-reason wrapper on Store Install, Home #health-issues list; banner admin-only + remediation buttons / member visibility deferred done
per-app-logs.md — Per-app Logs tab: live container log tail end-to-end (host-agent journal_follow SSE op + real journalsource journalctl reader → hostclient.JournalFollow → brain internal/applog per-instance ring/replay hub → raw GET /api/v1/apps/{id}/log with logVisibility admins-only-on-household + leak-guard → useLogStream/AppLogs.vue toggle in the Settings installed-apps list); authoritative ring lives in the brain, host-agent is a per-connection streamer (journal_query/export_range + CONTAINER_NAME replica-suffix match deferred) done
catalog-image-footprint.md — Catalog image footprint (#69, root of #68): manifest.Images → object form {digest, download_bytes, disk_bytes} (string-or-object back-compat) + Storage{estimated_size} + derived (*Manifest).Footprint(); new malmo manifest resolve subcommand resolves sizes via the Docker daemon and rewrites the manifest's images block in place (comment-preserving, fails loud — no bogus zeros); three sample manifests migrated; pinning.go reads .Digest (APP_STORE.md # Catalog schema, realized not changed) done
activity-view.md — Settings → Activity audit-log browser (ActivityView.vue): newest-first table over the built GET /api/v1/audit with useInfiniteQuery after_id pagination, admin name resolution via GET /users, plain-English action labels, relative/absolute time, success-pill, CSV/JSON export of loaded rows; open to all users (members see own events, server-scoped) — reconciled DASHBOARD.md/SETTINGS.md (Activity is member-reachable, DECISIONS.md 2026-06-05). Frontend-only, no Go change done
account-ui.md — Account UI (frontend for #13): self-service password change in Settings → Account (changeMyPassword → forced logout, no elevation), public recovery-code redemption (RecoverView.vue + /recover route rendered off App.vue's logged-out branch since router-view only mounts in AppShell; show-once fresh code with copy + "I've saved this" gate), and "Forgot password?" on Login; confirms withElevation/ElevateDialog reusable as-is done
clock-not-synced-detector.mdclock-not-synced locus-B health detector (#39): host-agent clockhealth reporter parses chronyc tracking (Ref time + Last offset, lazy 5-min cache) and emits the finding when unsynced >6h or offset >10s; new ClockReporter interface + Time category on GET /v1/health/system; first Network-category registry definition (warning, debounce, ReportCategory: time); reconcile is the already-generalized ApplyFindings done
ram-pressure-detector.mdram-pressure locus-B health detector (internal/hostagent/rampressure): stateless host-agent reporter samples /proc/pressure/memory (PSI some avg60) and emits a box-wide finding above a conservative tunable threshold (20%, first-soak); new RAMReporter seam + Resources field on the system-health report (resources category) + first capacity-category brain Definition (warning, tier-1, Debounce); fails open when psi=1/PSI is absent; sibling of #39 off the #34 seam (HEALTH.md # Detector catalog, # Capacity & informational) done
manifest-check-cli.mdmalmo manifest check <path> CLI (cmd/malmo/check.go): runs schema lint + the compose admission policy (admission.Check) in one pass, so authors never hand-eyeball admission.go; admission step is a composeChecker seam (real admission.Check in prod, daemon-free admission.CheckStructure in tests). Rewrote the agent-authoring guide (docs/dev/authoring-apps-with-an-agent.md) to call check instead of pre-reading the rule sources, and pinned the deferred malmo catalog scaffold item in NEXT.md with its build trigger done
app-secret-injection.md — App-secret injection (MALMO_SECRET_*): manifest secrets: [{name, bytes?}] → brain draws each from crypto/rand, base64url-encodes, persists in a cascade-deleting instance_secrets table, and writeEnv re-emits MALMO_SECRET_<NAME> read back from the store (generate-once, stable across restarts so token-signing secrets don't rotate under live sessions); internal/manifest validation (snake_case names, byte default 32/floor 16), install step 5b; kan's compose now maps BETTER_AUTH_SECRET: ${MALMO_SECRET_AUTH} + NEXT_PUBLIC_BASE_URL: ${MALMO_APP_URL}, closing the blocks-start secret-injection ledger gap (DECISIONS.md 2026-06-05, APP_MANIFEST.md # D2); security hardening parked in NEXT.md # App-secret injection hardening done
managed-services-postgres.md — Managed services Tier 1 (Postgres): manifest services: {<name>: {type, version}} → brain lazily spins up one shared malmo-svc-postgres-<v> container (compose project, --internal network, postgres-<v>.malmo.internal DNS alias), provisions a per-app DB+role via docker exec psql (control plane stays off the service network, DECISIONS.md 2026-06-02), injects MALMO_SERVICE_<NAME>_{HOST,PORT,NAME,USER,PASSWORD,DSN}; new service_instances/service_grants tables, install step 5c, override attaches every app service to the svc network, uninstall+rollback DROP DATABASE … WITH (FORCE); unblocks kan (DECISIONS.md 2026-06-05). Redis, grace-shutdown, backup/restore + cross-version migration deferred (NEXT.md) done
one-shot-job-restart.md — One-shot-job restart override (#92): the override no longer force-stamps restart: unless-stopped onto services designed to terminate — writeOverride now parses each author service's restart:/depends_on and exempts terminating jobs (union of restart: "no"/"on-failure" and being a service_completed_successfully gate target), while main_service stays always-forced; layer 2 bounds compose up -d by the health-wait budget so a never-completing gate fails the install cleanly instead of wedging the brain. Un-skips TestLiveKanBoot — kan now boots end-to-end (migrate completes, web serves) against managed Postgres (DECISIONS.md 2026-06-05) done
install-footprint-brain.md — App footprint served by the brain (#70, middle of #68): coarse manifest.Footprint hoisted onto every catalog.Entry (store-grid upper bound) + box-specific incremental install-plan footprint (lifecycle.InstallFootprint subtracts images already pulled via DockerDriver.ImageInspect, parses storage.estimated_size to bytes with (int64, bool, error) tri-state so estimated_state_bytes is omitted not zeroed, reads live free_bytes); free space added to protocol.SystemStatus (data_disk_free_bytes/_total_bytes) via a new hostagent.DiskReporter seam + linux diskusage statfs reporter (fails open); *int64+omitempty DTO field, degrades-to-zeros never fails the plan, regenerated OpenAPI + TS types; reconciled BRAIN_HOST_PROTOCOL.md status example + BRAIN_UI_PROTOCOL.md "no mutating host calls" (APP_STORE.md # Catalog schema, BRAIN_UI_PROTOCOL.md # install-plan, realized not changed) done
install-footprint-ui.md — App footprint UI (#71, closes #68): shared formatSize consumer-facing byte helper (GB/MB labels, binary math, no GiB) in utils.ts; coarse ~size on the StoreView catalog row from footprint.image_disk_bytes (skipped when unsized); a Storage block in InstallDialog from the install-plan footprint — download line (box-specific, "already downloaded" at 0), space line (image disk + qualitative "grows as you use it" when an estimate exists), user-content-excluded reassurance, and a surfaced-not-blocking not-enough-space warning when image_disk+estimated_state ≥ 90% free_bytes (silent when free_bytes==0); block skipped for unsized manifests; AppTile (home launcher, not the store card) left untouched; frontend-only, no Go change (DASHBOARD.md # Install authorization, realized not changed) done
reboot-required-detector.mdreboot-required locus-B health detector (internal/hostagent/rebootrequired): stateless host-agent reporter stats /var/run/reboot-required (+ .pkgs → package list in Details) and emits a box-wide finding when the flag is present; new system report category + RebootReporter seam + Reboot Agent field on GET /v1/health/system; first info-severity brain Definition (capacity, tier-2, Debounce: false — deterministic flag, 1-shot); self-clears on reboot (tmpfs /run); last unblocked locus-B sibling off the #34 seam (HEALTH.md # Detector catalog, # Capacity & informational) done
store-app-metadata-brain.md — Store app metadata + detail endpoint (brain half of the card-grid/detail-page work): internal/manifest now parses the identity block the sample manifests already carry (icon, screenshots, categories, author, license, links, changelog_url); catalog.Entry gains short_description/categories/icon_url (icon URL omitted when absent → glyph fallback), new catalog.Detail (embeds Entry + long_description/screenshot_urls/author/license/links) behind GET /api/v1/catalog/{id}, and raw …/icon + …/screenshots/{n} asset routes (traversal-guarded assetPath, off-huma like SSE) served via http.ServeFile; regenerated OpenAPI + TS types. UI card grid + Umbrel-style detail page are the follow-up slice (BRAIN_UI_PROTOCOL.md # Catalog browse, detail, and assets) done
store-app-detail-ui.md — Store browse grid → app detail page (UI half, stacked on store-app-metadata-brain): StoreView becomes a flat grid of StoreAppCards (logo + name, whole card → /store/:id); new AppDetailView at /store/:id with app-store-style header / screenshot gallery / markdown long description (marked + DOMPurify, scoped :deep() typography) / info sidebar (version, category, size, license, links); install flow extracted into a useInstall(manifestId) composable (plan fetch, 409/422/mid-job branches, household-vs-personal button state) consumed by the detail page; size shown on the detail page only, flat grid for v1 (DASHBOARD.md # global navigation, APP_STORE.md # Catalog schema — UI shape) done
folderless-app-data-dir-ownership.md — Folderless app data/ writability: the brain now pins user: on every instance (not just folder apps) and chowns data/ to match, because a cap_drop:[ALL] container has no CAP_DAC_OVERRIDE and can only write its private bind when it runs as that dir's owner; a folderless app runs as the brain's own euid/egid (root in prod, the dev user in the native inner loop) — fixing Open WebUI's sqlite3 unable to open database file crash-loop in dev and a latent prod gap for folder apps writing private data/. Chown is privilege-aware (hard-fail under root brain, warn-and-skip under the unprivileged dev brain). Dev-fake folder-app UID fidelity deferred (APP_ISOLATION.md # Volumes) done
managed-services-mysql.md — Managed services Tier 1 grows the MySQL family (#108): mysql (8.0, 8.4) + mariadb (10.11, 11.4) as two type values on the one Postgres-established code path — per-engine deltas are data (servicePort 3306, image repo, mysql:// DSN scheme for both engines, mysqlTools client/admin/root-pw-env triple); serviceName folds version dots to dashes (malmo-svc-mysql-8-0, mysql-8-0.malmo.internal — compose project names reject dots); provisioning/drop via docker-exec of the image's own client with the SQL as a positional $1 and the root password expanded from the container env (never host argv); readiness ping TCP-only so the init-time socket-only bootstrap server doesn't read as ready; db/user stem bounded to MySQL's 32-char cap. Live-verified against real mysql:8.0 (TestLiveMySQLProvisioning) + one-off mariadb:11.4. Unblocks Ghost (#85) + Kimai (#89) re-imports; ledger managed-mysql entries flipped to implemented (DECISIONS.md 2026-06-09) done
install-pending-button-state.md — App page "Installing…" through the POST-pending window (#114): the installing computed in useInstall now also covers the click-to-202 round-trip (install.isPending scoped by pendingRequest.manifest_id), closing the gap where the consent dialog had unmounted but the page button still read enabled "Install" and a re-click fired a duplicate POST; bare isPending (the issue's sketch) would leak "Installing…" across /store/:id navigation since the composable survives route reuse. Frontend-only, no Go change (DASHBOARD.md # Install authorization, realized not changed) done
service-user-identity.mdservice_user: true dedicated non-root identity for folderless apps (#116): host-agent allocates a stable per-instance UID/GID from the reserved app-service band [2100, 2999] (POST /v1/identity/app-service + /release; real reservation = a malmo-svc-<uid> system account whose GECOS carries the instance ID for idempotency, durable with no side state), brain persists it on the instance row (service_uid/service_gid) and feeds the existing pin-user:-and-chown path, releases at uninstall + install rollback (best-effort, mirrors grant drops); admission rejects any numeric user: (both doors — a host-namespace number could alias a real principal) and service_user+folders; live-verified end to end (TestLiveServiceUserBootAndWrite: container runs as 2100, writes data/ owned 2100:2100). Hardcoded-internal-UID images stay curation-rejects pending userns-remap (APP_ISOLATION.md # Runtime identity & data ownership, APP_MANIFEST.md # B, DECISIONS.md 2026-06-10) done
resolve-disk-bytes-containerd.mdmalmo resolve store-agnostic disk_bytes (#117): under the containerd image store docker image inspect {{.Size}} reports the compressed content size (≈ download_bytes), so resolves undercounted the real footprint 2–4×; the sizer now streams docker save and decompress-counts the layer blobs named by the save's own manifest.json (gzip + raw, loud error on zstd / multi-image streams), plus a < 1.2× download authoring-time warning that would have caught it on #113. Back-filled memos (22.9 MB → 65.8 MB) and open-webui (1.71 GB → 4.85 GB); digests untouched done
real-proc-sampler.md — Real /proc sampler for the live system-resources panel (#115): new Linux-only internal/hostagent/procsource reads /proc/{stat,meminfo,loadavg,net/dev,diskstats,uptime} under an injectable root into raw cumulative counters (jiffies sum user..steal — guest excluded as double-counted; net filtered to physical + mesh, lo/docker*/br-*/veth* out; whole-disk devices only, sectors × 512), behind a new SystemSampler seam on Agent — nil keeps the fake's synthetic counters, cmd/host-agent-real injects the real sampler; sampler error → 500 the brain's poller skips. Disk source reconciled in LOCAL_ANALYTICS.md/BRAIN_HOST_PROTOCOL.md (/sys/block/<dev>/stat/proc/diskstats); brain hub, API, UI untouched done
settings-left-nav-shell.md — Settings restructured from one long scrolling page into a left-nav shell (views/settings/SettingsLayout.vue): a role-filtered sidebar (horizontal tab strip on narrow screens) + a nested-route content pane. Old SettingsView split into AccountSection/NotificationsSection/InstalledAppsSection; UsersViewUsersSection, ActivityViewActivitySection (breadcrumbs dropped); new minimal AboutSection. /settings redirects to account; account/notifications/apps/activity/users/about are nested children, old /settings/{users,activity} paths preserved. Reserved admin Storage/Network/System sections left unbuilt rather than stubbed; About carries no version/box-name (no UI endpoint yet). Frontend-only, no brain/OpenAPI change (DASHBOARD.md # global navigation) done
stop-start-service.md — Stop/Start an installed app (the running ⇄ stopped half of the state machine): Manager.Stop (compose stop, never down; route → "stopped" splash) + Manager.Start (compose up -d, not compose start — same op as reconcile, so depends_on ordering + completion-gate jobs behave as on install; state written running before the op, brain-commits-first; "starting" splash → health → real upstream, health-fail → failed), both behind a new per-instance lock shared with Uninstall; ErrNotRunning/ErrNotStopped → 409. New POST /apps/{id}/{stop,start} (job-based, authorizeAppMutation: household=admin, personal=owner/admin, synchronous 409 guard), regenerated OpenAPI + TS. UI: new per-app detail page /settings/apps/:id (logo/name/description, Stop·Start, Uninstall, logs — InstalledAppsSection becomes a list of links); home AppTile stopped tiles lose the alert mark, gain click-to-start + "Service stopped…"/"Starting up…" captions, HomeView owns the start mutation (APP_LIFECYCLE.md # stop/start/uninstall, DASHBOARD.md # Tile, DECISIONS.md 2026-06-10) done
store-browse-filters.md — Store browse filters (picks up store-app-detail-ui's deferred "flat grid only"): the Store gains a real "Store" heading, a page-wide search over name + short-description, and a row of category pills — "All" first, then the sorted union of the catalog's own categories (derived from the corpus, no hardcoded taxonomy; pill row hidden when none declared). Both filters compose into one filtered computed; new "No apps match your search." empty state distinct from the empty-catalog one. Frontend-only, no brain/OpenAPI/type change — categories/name/short_description already ride every catalog.Entry (DASHBOARD.md # global navigation reconciled — Store carved out of the deferred global search; APP_STORE.md # Catalog schema realized; taxonomy still open in NEXT.md) done
store-icon-glyph-fallback.md — Store icon glyph fallback + card sizing (sibling of store-browse-filters on the same branch): new optional manifest icon_glyph (Lucide name, kebab-case) → catalog.Entry passthrough → regenerated OpenAPI/TS, so an icon-less app shows an author-chosen Lucide glyph instead of one generic AppWindow for all. New shared AppGlyph.vue (lazy import("lucide-vue-next"), kebab→Pascal, falls back to AppWindow) used by StoreAppCard + AppDetailView; brain shape-validates the name (kebab-case) but can't confirm it exists (icon set lives in UI) → unknown degrades client-side. Also: card raster icon now 50% centered (size-1/2 object-contain, was edge-to-edge size-full object-cover) + cursor-pointer on the pills. Curated glyphs onto the four icon-less catalog apps (calibre-web/hermes-agent/files-demo/whoami). Whole-set lazy chunk (~155 KB gzip, loads only when a fallback renders) — per-icon split deferred (APP_MANIFEST.md # A, APP_STORE.md # Catalog schema, BRAIN_UI_PROTOCOL.md # Catalog browse) done
catalog-listed-flag.md — Catalog app status roster + listed: false (pull a broken app from the store without deleting it; trigger: calibre-web crash-loops under the sandbox). New optional manifest listed *bool (IsListed(), default-true, back-compat) read asymmetrically in internal/catalog: store-facing List() (browse) skips unlisted + Detail() 404s them, while honest by-id Entry()/Load() ignore the flag so an app unlisted after install keeps its dashboard card + reconciles; both install paths (installApp, installPlan) gate on IsListed() → same 404 as a missing manifest, and listApps/getApp enrichment moved off the filtered List/Detail onto Entry. calibre-web flipped to listed: false (manifest/compose/digests preserved). New docs/dev/catalog-status.md per-app roster (Full/Degraded/Blocked/Rejected; Blocked+Rejected carry the flag) — keyed by app, the companion to the mechanism-keyed gaps ledger; authoring-apps-with-an-agent.md gains a status-recording step. No DTO/OpenAPI change — listed is manifest-only (APP_MANIFEST.md # A, APP_STORE.md # Listed apps) done
avahi-lan-ip-detection.md — Avahi publisher announces the route-probed LAN address (#129): detectLocalIPv4's interface enumeration let a Docker bridge IP win (avahi-resolve memos.local172.25.0.1) making app .local names unreachable from LAN devices; replaced with the kernel's route-chosen source address (UDP connect to TEST-NET-1, no packet sent — structurally excludes bridges, no RFC1918 blacklist), enumeration kept only as a warned fallback for no-default-route boxes, the localIP cache/test-override dual-use field split into a detectIP stub seam + per-Publish re-detection (post-DHCP-change installs announce the current address; pre-change groups wait for replay — slice 2 is #130). Verified live on a six-bridge box: malmotest129.local192.168.2.160. Stale BOOT.md static-file paragraph and DISCOVERY.md host-record attribution corrected done
byo-outgoing-mail.md — BYO outgoing mail (#122, the smtp-relay gap-class): admin-registered SMTP providers (Settings → Outgoing email; admin+elevation CRUD, write-only passwords, synchronous net/smtp test-send) + per-instance bindings (install-dialog picker riding the install plan — None default, sole provider preselected; rebindable from the app detail page via PUT /apps/{id}/mail-binding job behind authorizeAppMutation, options endpoint readable by any authenticated user) + MALMO_MAIL_HOST/_PORT/_USER/_PASSWORD/_FROM/_ENCRYPTION + Symfony-style _DSN direct injection (smtps:// implicit TLS; unbound → nothing injected). New mail_providers + cascading instance_mail_bindings tables; manifest mail: {optional: true} (only shape admitted — optional: false/mail:{} parse-rejected); RebindMail re-stamps only the MALMO_MAIL_* .env lines + recreates running containers; kimai mapped (MAILER_URL/MAILER_FROM with null-transport defaults), gitea recipe in the ledger. No malmo relay — deferral ladder in NEXT.md # Outgoing mail (SERVICE_PROVISIONING.md # BYO outgoing mail, APP_MANIFEST.md # D3, DECISIONS.md 2026-06-12) done
network-state-discovery.md — Network-state slice (#130): new internal/hostagent/netstate (NM-over-DBus LAN provider — active ethernet/WiFi connections by type, so bridges/mesh/veth are structurally out; 2s-debounced Watch; the #129 route-probe moved here as the no-NM fallback), avahipublisher announces every app name per LAN interface with that interface's own address (one entry group, AddAddress per ifindex; zero LAN interfaces = publish error) and RepublishAll replays stored names verbatim on every NM change (committed groups hold literal IPs — --reload fixes nothing), new Sync rewrites allow-interfaces in the main avahi-daemon.conf in place (no conf.d exists; conf change → daemon restart → groups destroyed → always republish). host-agent-real wires provider + startup Apply + watcher; GET /v1/discovery/state interfaces went live; fake stays NM-free. QEMU medium lane extended: 3 pinned-MAC NICs (SSH NIC stays networkd, NM-unmanaged), CGO-free cmd/malmo-network-verify drives allowlist/interface-removal/IP-replay assertions on the second boot — lane executed, PASS. DISCOVERY.md/BRAIN_HOST_PROTOCOL.md corrected (--reload myth, static-file remnant); brain-side restart replay deferred again done
logs-container-name-pin.md — Per-app logs: pin container_name on the main service (#83, closes the CONTAINER_NAME replica-suffix gap in per-app-logs.md): MainContainerName's malmo-<id>-<service> was only a network alias while compose named the running container …-<service>-1, so journald tagged lines with the suffixed name and the Logs tail's exact CONTAINER_NAME= match found nothing on real hardware (the dev fake masked it). writeOverride now stamps container_name: malmo-<id>-<main_service> on the main service only — running name == computed name, journald match holds; sidecars stay unpinned (explicit container_name forbids scaling, which only the single-replica main service guarantees). Same fixed-exec-handle pattern as managed services. No journalsource/wire change — the specs already described the match as true; the pin makes it so (APP_LIFECYCLE.md # Locked: override file contents) done
admission-reject-replicas.md — Admission rejects deploy.replicas > 1 on any service (companion to the container_name pin above): pinning the main service's container_name makes compose refuse to scale it, so an author setting deploy.replicas: 3 would have failed opaquely at docker compose up; checkService now rejects multi-replica at admission with a field-naming message, door-symmetric (catalog CI + install). Applied to every service, not just main — on a single node a replica buys no availability and Caddy routes one upstream alias per instance. rawService gains Deploy.Replicas; two TestCheckStructure cases (3 rejected, 1 allowed); new reject bullet in APP_LIFECYCLE.md # Locked: admission policy done
gpu-runtime-brain-slice.md — GPU runtime wiring, brain slice (#67): permissions.gpu: true was parsed and then silently dropped (no override stanza, no host query — CPU fallback, late compose up failure on a no-GPU box). New GET /v1/system/gpu (protocol.SystemGPU: presence + vendor + render GID) through hostclient + lifecycle's HostDriver; hostagent GPUReporter seam (nil → present: false, the safe side) with FakeGPUReportercmd/host-agent wires a synthetic Intel iGPU, MALMO_FAKE_NO_GPU=1 flips it for the refusal path; install gains a hard capacity gate (typed ErrNoGPU raised before the instance row and any Docker work — unlike advisory resources.recommended) and writeOverride emits /dev/dri + render-GID group_add on main_service only, merged with shared-folder/devices lists. Spec half landed separately (7d2d0e6); real /dev/dri detection + OS-image media stack are #125; AMD/NVIDIA follow-ons done
prepare-every-bind-dir.md — Brain prepares every private bind dir, not just ./data (#147; surfaced by Paperless-ngx #142, which binds ./data/{data,media,export} + ./data/redis): folder apps and any multi-bind app crash-looped because the brain created + chowned only the top-level data/, and the docker daemon then created every other declared ./… bind source as root:root — unwritable to a cap_drop: ALL container running as the non-root runtime UID. Part A (internal/lifecycle): parseComposeServices now also captures each service's volume sources (BindSources + bindSource short/long-form parser), new relativeBindDirs returns the deduped/sorted ./-relative sources (absolute use-case folder binds + named volumes excluded by construction), and the single-data/ chown became a per-dir MkdirAll+Chown loop before compose up (prod euid-0 hard-errors + rolls back, dev brain warns). Part B (internal/hostagent): the fake resolveHome/wellKnownIdentity branches now return the operator's own os.Getuid()/os.Getgid() + home (new devIdentity, removes fakeUID), so the unprivileged dev brain owns what it creates and Part A's chowns are no-op successes — closing the dev-fake UID-fidelity gap flagged in folderless-app-data-dir-ownership.md. Widened APP_ISOLATION.md # Volumes / # Runtime identity invariant; ledger nonroot-data-ownership — paperless-ngx (multi-dir binds) flipped to implemented done
prepare-shared-folder-sources.md — Prepare elected shared folder sources, the shared-tree half prepare-every-bind-dir.md (#147) scoped out (#156): a household shared-folder app whose elected /srv/malmo/shared/<Folder>[/<subfolder>] didn't pre-exist got a docker-created root:root dir the malmo-app container couldn't write. The ownership model is a spec call — STORAGE.md settles it as root:malmo-shared mode 02770 (setgid), not chowned to a runtime UID (the issue's malmo-app:malmo-shared guess superseded); the container reaches it via its malmo-shared group_add. New prepareSharedSource walks the elected path from the shared root down, owning each newly created level group-malmo-shared + setgid (`os.ModeSetgid 0o770, not a raw 0o2000os.Mkdir/Chmodtake a named-flagFileMode), never re-owning a pre-existing parent; a second installloop runs it euid-0-only (rollback on fail) and warn-skips under the unprivileged dev brain. Shared tree root became a test-injectableManager.sharedRootso the suite never touches real/srv. **Dev seam: household shared-folder apps are out of the inner loop** (dev brain can't write /srv) — a dev-rooted shared base via the host seam is the follow-up. APP_ISOLATION.md# Runtime identity +STORAGE.md` # Permissions updated
disk-usage-bars.md — Per-disk storage usage bars in the system-resources panel (#149): the Activity dropdown gains a Storage section — a collapsed aggregate used/total bar that expands to one bar per volume (System /, Data /srv/malmo). New protocol.DiskSpace + SystemStatus.Disks (a display superset; DataDisk* kept untouched for the install-plan footprint, DECISIONS.md 2026-06-13); diskusage.Reporter grows Disks() measuring both mounts and omitting the Data bar on a Level-0 box (data path shares the OS drive's st_dev — a successful statfs there isn't a separate volume); new consumer-side DiskSpaceReporter seam + FakeDiskSpaceReporter (fake wires two canned volumes, real wires one diskusage.New() to both disk seams). Brain proxies it to the UI as a one-time poll at GET /api/v1/system/storage (huma, all signed-in users — storage fullness doesn't move at the live gauges' 1 Hz cadence, so it's not on the SSE stream); regenerated OpenAPI + TS. UI fetch on panel open + humanBytes TiB tier; drive-by loadLine computed unbreaks pristine main's vue-tsc (inline .map closure defeated sample null-narrowing). diskusage 100% covered (LOCAL_ANALYTICS.md # Real-time system resources, BRAIN_HOST_PROTOCOL.md # GET /v1/system/status) done
liveresources-load-narrowing.md — Fix vue-tsc null-narrowing break in LiveResources load line: inline .map closure in the template defeated v-else null-narrowing of sample (TS18047) and triggered noUncheckedIndexedAccess on the tuple index (TS2532), breaking ci-web. Hoisted into a loadLine computed with an early null guard; rendered output unchanged. make check-web green. done
owner-visible-secrets.md — Owner-visible app secrets (#152): a manifest secrets: entry may declare show: true, surfacing that secret's value to the instance owner so a self-auth app's bootstrap token can be per-instance random instead of a published constant (the Jupyter molma-setup fail-open-backdoor case). New manifest.Secret.Show; lifecycle.RevealSecrets joins the instance-dir manifest's show flags against stored values (internal-only secrets never listed); GET /apps/{id}/secrets behind authorizeAppMutation (owner-or-admin, same gate as stop/start; household = admin-only; other-user personal → 404; pure read, no audit); web-ui Setup secrets detail-page section (masked → per-secret Reveal/Hide + best-effort Copy that degrades to select-on-screen on the HTTP-only .local). Additive, no manifest_version bump. Jupyter migration off the constant is a follow-up PR (APP_MANIFEST.md # D2, SERVICE_PROVISIONING.md # Env-var injection, DASHBOARD.md # Installed apps, DECISIONS.md 2026-06-13) done
brain-image-base-slim.mdmalmo-brain runtime base decided (#162, gates #163/M0): the brain shells out to the docker / docker compose CLI (~15 sites in internal/lifecycle/docker.go) which a distroless image can't host, so BUILD.md # 5's leaned-distroless runtime is flipped to debian:trixie-slim with docker-ce-cli + docker-compose-plugin bundled (Docker's official apt repo — same trusted source as the host engine). Rejected (b) rewriting the shell-outs onto the Docker Go SDK (no compose up equivalent → vendoring compose-go + reimplementing orchestration, busts size:S) and (c) host bind-mounting the CLI (distroless lacks the libs; host/container version + glibc skew). Distroless size win is immaterial (multi-stage already drops the toolchain; the CLI is a ~170 MB runtime dep, image ~200 MB — noise vs the multi-GB app images the box pulls) and the attack-surface win marginal for a daemon already holding Docker API access via the socket proxy. Docs-only, no code — Dockerfile authoring is M0 (BUILD.md # 5, DECISIONS.md 2026-06-13) done
m0-control-plane-images.md — M0 control-plane images + QEMU bundle (#163, part of #161; picks up brain-image-base-slim.md's next-step): the two malmo OCI images + the control-plane compose + the offline docker save/docker load bundle, no launch wiring. cmd/brain/Dockerfile (multi-stage golang:1.25-bookwormdebian:trixie-slim + docker-ce-cli/docker-compose-plugin, CGO_ENABLED=0; 256 MB, docker+compose resolve inside it) and web-ui/Dockerfile+Caddyfile (node:20-alpine npx vite buildcaddy:2-alpine serving /srv/ui, SPA fallback; 90 MB) with .dockerignores that keep .dev/ secrets out of the brain context. dev/control-plane/compose.yml+caddy.json: caddy (80/443, admin in-network only) + read-only malmo-ui + docker-socket-proxy (allowlist CONTAINERS/IMAGES/NETWORKS/VOLUMES/INFO/VERSION/PING+POST, EXEC/host-bind denied) on the brain-owned malmo-ingress net. make {brain,ui,control-plane}-image[s] builds both + docker saves all four images (incl. caddy:2-alpine+tecnativa/docker-socket-proxy, pulled first) to .dev/control-plane/*.tar (Docker-only, hermetic; 127 MB bundle). Medium-lane VM gains Docker: mkosi.conf docker-ce+PackageManagerTrees=mkosi.pkgmngr, bootstrap.sh stages the bundle + Docker apt repo (key fetched at build; bookworm) + first-boot loader, mkosi.postinst.chroot enables docker.service/malmo-load-images.service, mkosi.repart Minimize=guessSizeMinBytes=8G for the docker-load headroom (CANARY v17v18). Native half verified (images build/run, bundle saved, compose+Caddyfiles validate, check-web green atop #170); VM-boot acceptance not verified here — needs sudo make test-medium-qemu on an mkosi/swtpm/QEMU host (BUILD.md # 5 / # 5b, WEB_UI.md # deploy model, TESTING.md # Full-stack control-plane integration — realized, no spec change) done
host-agent-launches-brain.md — M1a host-agent launches the brain (#164, part of #161; next link after m0-control-plane-images.md): new internal/hostagent/brainlaunch implements the first-boot bootstrap over a consumer-side Docker seam — docker-load the bundled tarball if the image is absent, a lockstep major-version check (read the brain image's malmo.protocol.major OCI label, refuse ErrProtocolMismatch on mismatch or a missing label before any launch), then docker run --restart unless-stopped with the host-agent socket dir + /var/lib/malmo mounted and MALMO_* env; idempotent on the container name (Docker supervises after launch, host-agent does not). New protocol.Major/ImageProtocolMajorLabel + cmd/brain/Dockerfile LABEL malmo.protocol.major="1". cmd/host-agent-real runs Launch after the socket binds (best-effort — a refused mismatch logs and host-agent keeps serving); the brain comes up degraded (no Docker reach until M1b's socket-proxy) but binds and logs malmo-brain listening. QEMU lane un-stubbed: bootstrap.sh builds + stages the real CGO host-agent-real (libpam preflight) over the /bin/true symlink with a dev-image drop-in, mkosi.postinst.chroot enables host-agent.service (the /bin/true-exit RuntimeDirectory hazard is gone). Launch 95.7% covered, make check green; VM-boot acceptance still needs sudo make test-medium-qemu. Registry pull (BUILD step 3) deferred per #161 (CONTROL_PLANE.md # host-agent launches the brain — OCI-label mechanism added, BUILD.md # First-boot brain bootstrap steps 2+4) done
socket-proxy-compose-validation.md — M1b spike: validated the socket-proxy ↔ compose-CLI integration #165 flags as its open risk (against local Docker 29.5.3, M0's exact allowlist, a containerized docker:28-cli driving compose via DOCKER_HOST=tcp://proxy:2375). Finding 1: docker compose up -d and down -v work fully through the proxy — create/start/stop/delete, external malmo-ingress attach, ports, restart, bind mount, read_only+tmpfs; the M0 allowlist needs no widening for compose, and the brain's compose down -v uninstall path is covered by POST=1. Confirmed the bind-mount same-path constraint (daemon resolves bind sources as host paths → stage the compose + caddy.json under /var/lib/malmo/control-plane/, already mounted same-path per #164). Finding 2 (escalated): the proxy denies EXEC by design, but internal/lifecycle/services.go provisions managed Postgres/MySQL/MariaDB via docker exec (psql/mysql/pg_isready), so the instant M1b sets DOCKER_HOST=proxy managed-DB provisioning breaks (managed Redis #159 in flight on the same seam) — resolution (widen EXEC vs re-architect off exec vs defer) is onel's call. Proxy-bootstrap chicken-and-egg resolved (product): host-agent seeds the proxy. Caddy "audit" → no hardcoding (injected via caddy.New(cfg.caddyAdmin); prod sets MALMO_CADDY_ADMIN=http://malmo-caddy:2019). #165 reassigned to onel; implementation blocked on the EXEC decision (CONTROL_PLANE.md # Docker socket exposure) findings
managed-services-redis.md — Managed services Tier 1 grows Valkey, redis a compatibility alias (#159): type: redis was schema-valid but unprovisioned, so a redis declaration passed manifest check then failed at install — closed by provisioning on the SQL families' code path. Engine is Valkey, not Redis (DECISIONS.md 2026-06-13): redis:7 resolves to RSALv2/SSPLv1 Redis 7.4+ (8+ is AGPLv3), both on malmo's avoid-list, so malmo runs the BSD-3 Linux Foundation Valkey fork and never upstream Redis. valkey is a first-class type (version 8); redis (7) is a pure alias that normalizeEngine maps to valkey/8 once early, so a redis-7 app and a valkey-8 app coalesce onto one malmo-svc-valkey-8 instance; the grant stores the engine identity and the lifecycle maps are valkey-only (no dead redis path). Per-app isolation model resolved: a per-app ACL user with full keyspace (ACL SETUSER <app> on >pw ~* &* +@all -@admin -flushall -flushdb -swapdb) over a logical-DB split — the credential is the auth boundary, keyspace shared (per-app key read confidentiality deferred → NEXT.md # Managed-service per-app key isolation). valkeyServiceCompose runs valkey-server --aclfile /data/users.acl so ACL users persist a restart; provisionValkeyACL does ACL SETUSER+ACL SAVE via docker exec valkey-cli (superuser auth via REDISCLI_AUTH, never argv); grant carries an empty DBName; writeEnv omits the /db DSN path → redis://…valkey-8.malmo.internal:6379. Hermetic alias/native/coalesce/uninstall/exec-failure tests + live TestLiveRedisProvisioning against real valkey/valkey:8 (declared type: redis to prove the alias) — PASS. Closes the managed-redis — postiz ledger asymmetry; Postiz itself still blocked on image-internal-path gaps (SERVICE_PROVISIONING.md # Catalog, # Implementation status, # Per-app isolation; APP_MANIFEST.md; NEXT.md) done
install-phase-spinner.md — Install button shows a live phase spinner (#150, built on #114's installing state): the button was a static "Installing…" for the whole image-pull/compose-up/health-wait window — no motion, then a jump straight to Open. The brain's install job already emits a fine-grained step (lifecycle.go: admitting_composeresolving_digestscompose_upwaiting_healthyflipping_route…) and Job.step is on the generated client type, but waitForJob discarded every intermediate poll. Frontend-only, four files: waitForJob(jobId, onPoll?) fires a callback on each non-terminal poll; useInstall tracks a currentStep ref (cleared in onSettled + the manifestId watch, scoped to this app like installing); SplitButton gains a loading? prop rendering an animate-spin Loader2; AppDetailView collapses the ~15 technical steps into Preparing… / Downloading… / Starting… (unknown/empty → "Installing…") with the wording in the view (per the useInstall convention) and wires spinner + phase label on the header split-button (the duplicate-install button stays disabled but shows no spinner — handleConfirmDuplicate unmounts its panel in the same tick installing becomes true). No brain/OpenAPI/type change. CI check-web is red on main for an unrelated pre-existing LiveResources.vue vue-tsc break that #170 fixes; this diff passes check-web on top of #170 (verified locally, fix reverted to keep the diff surgical) (DASHBOARD.md # install authorization unchanged; docs/dev/web-ui.md # Install flow updated) done
start-reasserts-mdns.md — Start re-asserts the mDNS name, not just the Caddy route (#153, surfaced recovering a failed Jupyter via #136/#124): a stopped → running Start re-registered only Caddy, so an app whose <slug>.local went dark (mid-life host-agent restart dropping its process-local Avahi entry group, or an install that failed before publishing) came back reachable by Host-header proxy but unresolvable-by-name until the next brain reboot's reconcile. Extracted reassertRouting's publish block into shared lifecycle.publishHost (idempotent host.Publish → returned name authoritative incl. box-qualified collision fallback → persist-on-change → reconstruct-<slug>.local fallback when mDNS is down) and pointed both reconcile and Start at it; Start now re-asserts mDNS and Caddy, up-front (before the starting splash, mirroring install step 9 — DECISIONS.md 2026-06-13, grounded in the name-stays-announced-for-life model that Stop already follows), so one host keys the starting/real/failed routes without divergence. Stop untouched (must not re-publish — stopped splash keeps resolving; out of scope). Regression TestStartReassertsMDNSName (drop the fake entry group → Start re-publishes + re-announces, no brain restart; Stop count unchanged) + TestPublishHostBranches (mDNS-down fallback + collision-fallback persist → publishHost 100%). Backend-only; the mid-life-restart-for-untouched-apps gap (uptime_s-poll) stays open in DISCOVERY.md # Restart durability (APP_LIFECYCLE.md # stop, start, uninstall) done
failed-app-retry.md — Failed instances get a click-to-retry recovery path (#154, on top of #153): a failed app had no UI way back (Start/Stop rejected it, Reconcile skipped it; only uninstall/reinstall or SQLite surgery). The insight — a retry is a Start (compose up -d + health-wait + Caddy flip + the #153 mDNS re-publish) — so rather than a parallel Retry, Manager.Start's guard widens stopped{stopped, failed} (and startApp's 409 guard with it); success → running with name + route restored, persistent failure → failed via the same startFailed splash. Widening exposed a hardcoded "stopped" prev-state in the success state_changed event → now captures the real prevState so failed→running reads right. No new endpoint — existing POST /apps/{id}/start + authorizeAppMutation carry it, no OpenAPI/TS change. Reconcile left untouched on purpose (failed stays failed across reboot; retry is a deliberate action, not auto-remediation — HEALTH.md). UI: AppTile failed tiles get an amber/warning tint (distinct from gray stopped) + retained alert mark + controller-only click-to-retry (same start emit, no HomeView change) + "Retrying…" caption + a "View details" link to the app page where the failure logs live; InstalledAppDetailSection Start control shows for failed too, labeled "Retry". TestRetryFromFailed drives install→failed→retry→running asserting the re-publish; make check + web build green (DASHBOARD.md # Tile, APP_LIFECYCLE.md # state machine / # stop, start, uninstall) done
brain-control-plane-stack.md — M1b: the brain brings up the control-plane stack (#165, part of #161; the link after host-agent-launches-brain.md, which left the brain degraded with no Docker reach). Unblocked by onel's defer-EXEC call (DECISIONS.md 2026-06-14): ship the socket-proxy with EXEC denied (correct prod posture), gate managed-DB-in-production on a provisioning re-architecture, don't compromise. host-agent seeds the transport — new brainlaunch.EnsureTransport creates malmo-ingress + docker runs tecnativa/docker-socket-proxy (raw socket :ro, M0 allowlist, docker-proxy network alias) before launching the brain, because a process can't bootstrap its own sole Docker path; the brain run-spec gains the ingress network + DOCKER_HOST=tcp://docker-proxy:2375/MALMO_CADDY_ADMIN/MALMO_CONTROL_PLANE_DIR/MALMO_DASHBOARD_UI_UPSTREAM and still never the raw socket (RunSpec.Network/Aliases, Mount.ReadOnly, CLIDocker.NetworkCreate). The brain reconciles the services it ownsManager.EnsureControlPlane brings up Caddy + malmo-ui via new DockerDriver.ControlPlaneUp (single compose, project malmo-control-plane), gated on MALMO_CONTROL_PLANE_DIR so dev is untouched, after caddy.WaitReady; the proxy left the brain's compose (it's host-agent's). Dashboard routecaddy.EnsureDashboard PUTs a host route splitting /api/* → brain (flush_interval -1 for SSE) from else → malmo-ui (WEB_UI.md # deploy model; new RemoveRouteByID). QEMU lane stages the compose + caddy.json same-path at /var/lib/malmo/control-plane/, extends the host-agent drop-in env, CANARY v18v19, and medium-assertions gains an M1b block (4 containers up, brain has no docker.sock + DOCKER_HOST at the proxy, dashboard GET / 200 + /api/v1/me reaches the brain via bash /dev/tcp). Unit-tested + make check green; VM-boot acceptance pending sudo make test-medium-qemu (CONTROL_PLANE.md # Docker socket exposure refined, DECISIONS.md 2026-06-14) done
managed-db-one-shot-provisioning.md — Managed-DB provisioning moves off docker exec to a one-shot client container (#185; resolves the gate socket-proxy-compose-validation.md Finding 2 + DECISIONS.md 2026-06-14 left open). The brain's only Docker path (the socket-proxy) denies EXEC, but services.go provisioned per-app DBs/roles by docker exec'ing the engine's client — so the containerized brain broke managed DB. Re-architected to a throwaway one-shot container (docker run --rm --network malmo-svc-<k>-<v> --env-file <serviceDir>/.env <serviceImage> <client …>): the ephemeral container — not the brain — joins the service network and connects over TCP to <k>-<v>.malmo.internal. Chose this over engine-over-TCP because it preserves both 2026-06-05 reasons (no new Go driver dep; brain stays off the app network), keeps EXEC denied, and is byte-identical SQL/ACL. docker.go: dropped DockerDriver.Exec, added RunOneOff + ContainerHealth. services.go: clientWrapper (remaps the superuser pw from --env-file to PGPASSWORD/MYSQL_PWD/REDISCLI_AUTH, never argv; per-app cred in argv as before) + runServiceClient drive provisionPostgresDB/provisionMySQLDB/provisionValkeyACL/dropServiceGrants; waitServiceReady polls the compose healthcheck via ContainerHealth (CONTAINERS read), serviceReadyProbe deleted. De-risked first: a foreground docker run --rm (output capture = /containers/{id}/attach, gated by CONTAINERS=1) survives the M0 allowlist while EXEC 403s; full provision verified through the proxy against real Postgres. Hermetic suite updated (fake gains RunOneOff/ContainerHealth); dockerlive Postgres/MySQL/Valkey provision→connect→drop PASS; make check green. VM-boot acceptance pending sudo make test-medium-qemumedium-assertions.sh gained a managed-DB-through-proxy block (DECISIONS.md 2026-06-15, SERVICE_PROVISIONING.md # Implementation status / # Locked: provisioning, CONTROL_PLANE.md # Docker socket exposure) done
headless-first-run-m1c.md — M1c: minimal headless first-run, /setup over SSH (#166, part of #161; the link after brain-control-plane-stack.md). The brain /setup (brain-first-then-host rollback) and host-agent-real usermgr (useradd/chpasswd/gpasswd) were already complete — this slice makes that path exercisable end-to-end on the VM by provisioning the box prerequisites it depends on and adding the browser-free assertion. Without them the real /setup 502s and verify-password denies: mkosi.conf adds the sudo package (provisions the sudo group at its canonical GID for SetRole's gpasswd -a … sudo); mkosi.postinst.chroot creates the malmo primary group at GID 3000 (FIRST_RUN.md # Identity) so useradd --gid malmo works; bootstrap.sh stages the canonical dev/pam/malmo to /etc/pam.d/malmo (the PAM service pamverifier dials — without it pam_start falls back to /etc/pam.d/other deny and a correct /etc/shadow entry 401s). medium-assertions.sh M1c block drives the first-run over Caddy :80 (no curl/jq → new http_post over bash /dev/tcp): POST /setup (200 fresh / 409 on the disk-reuse second boot), host-side checks (admin is a real user, primary group malmo, member of sudo), then POST /login200 proving the account authenticates against /etc/shadow via host-agent verify-password (the M1c "Done when"; brain holds no password hash). No Go change. http_post smoke-tested + bash -n clean; VM-boot acceptance pending sudo make test-medium-qemu (realizes TESTING.md # Full-stack control-plane integration — headless first-run + Real PAM login, no spec change) done
offline-install-digest-trust.md — Offline (air-gapped) install path, the first brain slice of #167/M2 (carved off ahead of the QEMU harness — it's the blocker the air-gapped app-install assertion sits on): lifecycle.install resolved every image digest by an unconditional docker pull + registry RepoDigest inspect, so on a baked, registry-less box (the M2 guest runs with QEMU netdev restrict=on to prove the offline bundle is complete) any install hard-fails before the assertions can run, and a docker save/loaded image carries no RepoDigest to pin from. New box-level mode MALMO_OFFLINE_INSTALL (off by default; Manager.offlineInstall + SetOfflineInstall, wired in cmd/brain via a new envBool): in it, resolveImages/pullAndResolve thread the catalog-promised digest and, on a pull failure, fall back via resolveOffline to the locally-present image — ImageInspect is the presence probe (empty RepoDigest for a loaded image, error when absent) and the catalog promise becomes the pin (the signed catalog is the version→bytes trust anchor; offline there's no manifest digest to re-derive — APP_STORE.md # Trust model). Two cases stay hard-fails: a genuinely-absent image (incomplete bundle — the missing-image failure the air-gap exists to catch) and a Door-2 install (no promise to trust). Gated, not silent: an online box still treats a pull failure as fatal so a transient registry outage can't mask a stale local image on an update. Online happy path byte-unchanged. 5 install-level tests + a loaded set on the fake docker; make check green. Remaining #167 slices (bundle whoami image, test-only catalog + brain-container catalog mount, restrict=on, the five assertions, CANARY bump) follow (APP_LIFECYCLE.md # image digest pinning, DECISIONS.md 2026-06-16) done
vm-offline-app-install-prep.md — Wire the VM brain to install an app offline (M2 prep, #167; the plumbing slices after offline-install-digest-trust.md): two gaps stopped the air-gapped guest from installing a catalog app even with the offline logic in place. (1) brain-container catalog + offline mode — the brain run-spec mounted only the socket dir + /var/lib/malmo and set no MALMO_CATALOG_DIR, so catalog.New fell back to ./catalog (empty in a container); brainlaunch.Config gains CatalogDir (emitted as MALMO_CATALOG_DIR, lives under DataDir so it rides the existing data-dir mount — brain only reads it) and OfflineInstall (emits MALMO_OFFLINE_INSTALL), both read from env by cmd/host-agent-real (catalog default /var/lib/malmo/catalog; offline off via a new envBool). (2) offline bundlebootstrap.sh docker saves traefik/whoami:v1.10.3 into the first-boot loader's images dir (globs *.tar, no loader change) and stages a test-only catalog at /var/lib/malmo/catalog/ (dev/test-qemu/catalog/whoami/ = catalog/whoami + a documents:write folder grant, kept out of the shipping catalog so the lane's bind-mount/content-survival needs don't bend the routing smoke test); host-agent drop-in sets MALMO_CATALOG_DIR + MALMO_OFFLINE_INSTALL=1, CANARY v20v21 (atop M1c's v20). brainlaunch unit tests + manifest check on the test app + bash -n + make check green; no VM boot run here. The air-gap (restrict=on) + the five assertions (dashboard, PAM login, app install end-to-end, content survives uninstall, socket-proxy boundary) are the remaining capstone, VM-gated on sudo make test-medium-qemu (realizes TESTING.md # Full-stack control-plane integration) done
qemu-fullstack-app-install.md — QEMU full-stack app-install assertions, air-gapped (M2 capstone, closes #167 / the brain+app-install arc of #161; after vm-offline-app-install-prep.md): air-gap the guest and assert the catalog-app install end-to-end. Air-gap — all three QEMU -netdev user stanzas gain restrict=on (run-medium-tests.sh): SLIRP routes no guest packet outside, so a missing bundled image hard-fails instead of silently pulling — proving the offline bundle is complete; SSH hostfwd + SLIRP DHCP still work, so the SSH/network-state assertions are unaffected. Five assertions (medium-assertions.sh new assert_app_install, second-boot, bash /dev/tcp): reuse M1c's admin → /login keeping the malmo_session cookie (install needs only a session, no elevation); POST /apps {manifest_id:whoami, scope:personal} (personal, not household — a level-0 VM has no /srv/malmo shared tree; binds the admin's ~/Documents) → 202; poll the whoami.local route through Caddy for 200 and the whoami echo body (converged install: offline-trusted image → compose up → health-wait → route flip, whoami's no-healthcheck → "none"=healthy); avahi-resolve-host-name -4 whoami.local resolves; the running container's Mounts carry /malmo/documents~/Documents (whoami is FROM-scratch → no docker exec, so assert host-side via docker inspect + the malmo.instance_id label); write a marker in the bound folder, DELETE /apps/{id}, wait for the container to vanish, assert the marker survives (files-are-first-class). Runs before assert_network_state so the net test's interface teardown can't disturb mDNS. Bring-up surfaced (and fixed) three lane bugs — a set -u ordering abort, an offline pin using a digest ref compose up couldn't resolve (→ tag ref), and the missing malmo-app/malmo-shared well-known identities (added to mkosi.postinst.chroot) — with an install_diag brain-log dump added to pinpoint the third; CANARY v20→v25. VM-boot acceptance PASSEDsudo make test-medium-qemu green on a clean VM, all five assertions, air-gapped (realizes TESTING.md # Full-stack control-plane integration — the assertion table) done
iso-mkosi-finding.mdFinding (#199, part of #196): mkosi cannot emit an ISO. #199 set out to prove mkosi yields a bootable live-session ISO (the one piece DECISIONS.md 2026-06-16 flagged unproven); inspection of mkosi 26 found the assumption is false — its OutputFormat enum (config.py:213) is {confext,cpio,directory,disk,esp,none,portable,sysext,tar,uki,oci,addon} with no iso/cdrom, and zero xorriso/iso9660/El-Torito code in the package. mkosi builds GPT disk images (Format=disk, the medium lane's artifact) meant to be dd'd to USB; ISO9660 is out of its scope. This contradicts BUILD.md # 2 ("single builder … for the install ISO"), # 6 (malmo-*.iso artifact), and the 2026-06-16 locked decision. The "live fs == installed fs" invariant survives (a Format=disk root is what the installer lays down) — only the literal .iso packaging breaks. Three forward paths surfaced; the maintainer's call (2026-06-17) is Path 1: drop the literal .iso, ship disk images — a qcow2/raw cloud VM image (priority) and a raw USB image for bare metal; CD/DVD/optical boot out of scope; "mkosi is the single builder" holds. (Rejected: a xorriso hybrid-.iso post-step, and live-build for the ISO — neither has a product need.) BUILD.md # 2/# 6/locked-decisions + DECISIONS.md reconciled to disk-image artifacts, NEXT.md Tier-1 item resolved + removed. No lane built in this PR; the cloud VM image lands first in the re-ordered #196 epic, bare-metal USB follows. (#189 affects only the contributor's Ubuntu-24 host, not the maintainer's build env.) resolved
app-tile-quick-menu.md — App tile quick menu + icon-only open: the home launcher's tile was one big click target (icon or name opened/started the app); this slice narrows the logo to open a running app only (stopped/failed tiles are inert, hover captions simplified to bare "Service stopped" / "Failed") and moves start/stop/retry into a new quick menu — a controller-only EllipsisVertical button pinned to the tile's right edge opens an AppMenuDialog popup showing the app's logo, name, and short_description (fetched lazily from GET /catalog/{manifest_id}, best-effort for Door-2 apps), then two full-width actions: a state-adaptive service control (Stop running / Start stopped / Retry failed — POST /apps/{id}/stop|start jobs + awaitJob poll + ["apps"] invalidation, like the detail page) and an App settings link to /settings/apps/<id>. The popup (centered modal, teleported to body, dismissed by backdrop/X/Escape) stays open across a Stop/Start so the user sees the control flip. Menu gated to controllers in the UI; brain re-checks authorizeAppMutation on every mutation. Frontend-only, four files (AppTile.vue restructure, HomeView.vue drops the old tile-start wiring, new AppMenuDialog.vue, DASHBOARD.md # Tile / # Quick menu / # Open-app interaction); no brain/OpenAPI/type change; make check-web green. Not yet verified on a live brain (DASHBOARD.md # Tile) done
hosted-cloud-image.md — Lean hosted cloud-image mkosi profile + hosted marker (C1b, closes #203 / part of #196; after #202/C1a marker, alongside #204/C1c slim agent, before #205/C2 boot): the repo's first product image definition. New dev/cloud/ mkosi tree — its own image (not the appliance rootfs with services disabled, ENVIRONMENT.md "absent, not disabled"), Distribution=debian/Release=trixie (locked product base, not the test lane's bookworm), Format=disk, ImageId=malmo-cloud, ManifestFormat=json, ToolsTree=default, Bootable + systemd-boot, KernelCommandLine carrying psi=1 (required so the ram-pressure detector reads real PSI — the test lane omits it) and a virtio+ext4+crc32c modules-initrd (the LUKS/dm-crypt modules deliberately absent). Lean package set (linux-image-amd64, systemd stack, docker-ce stack, ca-certificates, dbus, util-linux, kmod, udev, iproute2, sudo, base shell utils); the appliance cuts — network-manager, avahi-daemon/avahi-utils, samba, mergerfs, cryptsetup, tpm2-tools, openssh-servernot installed. No LUKS/TPM repart (ships no mkosi.repart/ → mkosi-default plain ESP+root). Marker dev/cloud/mkosi.extra/etc/malmo/profile=hosted baked via ExtraTrees (a static committed file — exactly what internal/profile.Read/#202 reads). dev/cloud/bootstrap.sh (thin analog of the test lane's): preflight → stage Docker apt repo (trixie pocket) → mkosi buildassert the cut list absent from the JSON manifest (exact-name match, so libcryptsetup12 is not a false positive) + marker = hosted. make build-cloud-image wired. Firewall posture decided, not omitted (DECISIONS.md 2026-06-19): hosted relies on the cloud provider's security groups (explicit operator requirement; the appliance nftables only LAN-scopes SSH/SMB, both dropped) — in-guest nftables backstop deferred (NEXT.md); ENVIRONMENT.md # Public-by-default made explicit. Config validated via mkosi summary under mkosi 26 (main image resolves trixie/disk/json/Repart Directories: none/lean packages); full mkosi build not exercised here — blocked by the mkosi-26/Ubuntu-24.04 PR_CAPBSET_DROP sandbox limitation (#189), same as the medium QEMU lane. Boot proof + qcow2 + control-plane bring-up are #205/C2 done
hosted-profile-marker.md — Environment-profile marker + brain reads it (C1a of #196, the cloud-VM track): the foundational, mkosi-independent seam letting ~95% of the brain (Layer 1) stay identical across the appliance and hosted profiles. New internal/profile provider package (concrete types only) exports Profile (Appliance|Hosted), DefaultMarkerPath (/etc/malmo/profile), and Read(path) — which never fails: an absent marker (unmarked appliance box / make dev), an unreadable one, or unrecognized content all resolve to Appliance (the no-op default, so an unmarked box is unchanged); surrounding whitespace/trailing newline tolerated. Logging is asymmetric — absent is the silent expected case, present-but-unreadable and present-but-unrecognized both slog.Warn (a mis-stamped image shouldn't silently run in the wrong posture). cmd/brain/main.go reads it once at startup (env MALMO_PROFILE_PATH, default the marker path) into a local prof logged via the new profile structured field (added to CLAUDE.md # Go code discipline); prof is held for future seams but consumed by none yet — no image/host-agent/behavioral-seam change here (those are C1b #203 / C1c #204). Value-matrix tests at 100% coverage; make check green (ENVIRONMENT.md # How the profile is realized) done
slim-cloud-host-agent.md — Build-tagged slim cloud host-agent (closes #204/C1c; last of the C1 split after #202/C1a + alongside #203/C1b, part of #196). cmd/host-agent-real now builds in two profiles from one tree via the hosted Go build tag: main.go boilerplate (socket bind/serve, first-boot brain bootstrap, env helpers) stays shared/untagged, and the host-op seam composition splits into wiring_appliance.go (//go:build !hosted, today's full wiring byte-for-behavior unchanged) and wiring_hosted.go (//go:build hosted, the slim cloud set). Hosted keeps real PAM verify, usermgr, the storage/services/time/resources/reboot/system + disk + per-app-log reporters (none touch the LAN/NM/DBus); drops avahipublisher (mDNS publish) and netstate.NMProvider + the NetworkManager watcher; LUKS/TPM, Samba, nftables aren't in the hosted image (#203) so there's nothing to wire. Two no-ops keep the shared mux safe: a 3-line noopPublisher (the brain already skips POST /v1/discovery/publish in hosted via the C1a marker — belt-and-suspenders) and a.Net left nil (GET /v1/discovery/state already reads nil as "not measured"; net-state seam choice = nil stub, not a kernel reader, to avoid pulling NM). Both profiles stay Linux + CGO + libpam0g-dev (PAM kept). make host-agent-real-hosted (go build -tags hosted) builds the cloud binary; #203/C1b + #205/C2 consume it. Verified slimmer: avahipublisher absent from the hosted import graph, godbus symbols 378→182, ~410 KB smaller; default appliance build unchanged (make check green); vet/build green both with and without -tags hosted across the whole module (no ripple). Known residual: netstate stays imported (godbus rides along) because the shared hostagent.NetState seam returns netstate.LANInterface and #204 forbids changing the seam / scopes to consumer-side wiring — inert (a.Net nil), filed as #217. No VM-boot proof here (that's #205/C2, which bakes + boots the image) done
hosted-setup-gate.md — Hosted first-boot seed + admin-bootstrap gate on /setup (C3a of #196, brain-side core only): a hosted box has no "whoever is physically present at first boot" trust, so the first admin's creation is gated on a one-time secret from the provisioning seed (ENVIRONMENT.md # Provisioning). New internal/profile/seed.go (Seed{box_id, admin_bootstrap_secret, enrollment json.RawMessage} from /var/lib/malmo/seed.json, override MALMO_SEED_PATH; ReadSeed→ typed ErrSeedAbsent on missing, hard error on malformed/incomplete; enrollment reserved-unconsumed for C3b). New box_meta KV in internal/store persists the box-id + the SHA-256 hex of the secret, never plaintext. cmd/brain loadHostedEnvironment ingests once at startup — appliance no-op; hosted first boot reads the seed and persists hash then box-id (box-id last = crash-safe commit marker); a persisted box-id is the install's frozen identity (seed ignored on later boots); absent/unreadable seed stays pre-setup (never falls back to appliance open). internal/api gateBootstrap runs in /setup before the empty-box check (hosted only): no seed → 503, missing/wrong bootstrap_secret body field → 401 audited setup.failure, correct → proceeds; constant-time (crypto/subtle) compare over the hash; naturally one-time via the existing empty-box guard. UserDTO.box_id,omitempty surfaced on hosted only; appliance /setup byte-unchanged. OpenAPI + web-ui client regenerated (only bootstrap_secret + box_id added). Cloud-lane seed delivery (SMBIOS/QEMU) deferred (depends on C2 #205, #189-blocked); PR is "Part of #206", not "Closes". make check green (ENVIRONMENT.md # Provisioning & first-boot) done
sandbox-one-uid-per-app-triage.md — Triage of the one-UID-per-app sandbox limitation (#193, a design/triage item filed off Formbricks #182). malmo pins one malmo-assigned UID + cap_drop: ALL + no-new-privileges on every service and rejects a numeric user: at admission; that breaks images that mutate their own image-baked files owned by a fixed non-root UID (Formbricks' nextjs/1001 migration runner — root under cap_drop: ALL can't write them either, no CAP_DAC_OVERRIDE). Verdict: no code change. The directions #193 floated (a manifest field naming a UID / "run as the image's declared USER") are foreclosed by a locked decision — APP_ISOLATION.md # Runtime identity forbids a manifest-named UID (host-namespace aliasing, THREAT_MODEL.md; DECISIONS.md 2026-06-10). Both Formbricks cases reduce to the already-deferred hardcoded-internal-UID class (same as poznote/postiz): case 2's "conflicting per-service UID" adds nothing — a bundled Postgres adopts any non-root UID via service_user, so only the main app's hardcoded 1001 blocks (case 1), and per-service distinct UIDs wouldn't help. Early detection is already covered by the mandatory curation boot smoke test. Recorded the triage in the doc homes: new nonroot-data-ownership — formbricks ledger entry, updated NEXT.md # User-namespace remap item (count-trigger now met but gated on its feasibility spike; per-service-UID note added), one-sentence tightening of APP_ISOLATION.md # What this does not cover. Docs-only PR; Formbricks stays a curation-reject pending userns-remap done
hosted-first-run-wizard.md — Trimmed hosted first-run wizard (C4 of #196, closes #208): the web-ui half consuming C3a's /setup gate, plus the brain endpoints its non-account steps need. New host seam POST /v1/system/set-timezone (protocol.SetTimezoneRequesthostclient.SetTimezonehostagent.TimezoneSetter, nil ⇒ accepted no-op 200 for the fake/dev loop) backed by new pkg internal/hostagent/timezone (real timedatectl set-timezone, zone-shape regex guard; wired in both host-agent-real build profiles). New internal/api/firstrun.go — admin-gated POST /system/{timezone,telemetry,first-run-complete} (timezone validates IANA shape→422 then pass-through→502; telemetry + first-run-complete persist to C3a's box_meta via new keys telemetry_consent/first_run_complete, no migration). GET /auth/state extended to {has_users, first_run_complete, profile}: the dashboard gates the wizard on first_run_complete (not has_users) so a half-finished wizard resumes, and profile selects the step set + bootstrap-secret field. POST /setup gained optional recovery (nil/true→code as before; false→no hash + empty recovery_code, recover-flow unredeemable). web-ui: Setup.vue rewritten as a data-driven profile-aware shell (steps [admin, timezone, telemetry, done]; B4 splices network/enrollment per profile) + web-ui/src/setup/{Admin,Timezone,Telemetry,Done}Step.vue; App.vue renders the wizard while !firstRunComplete with a !firstRunComplete && hasUsers && !currentUser → Login resume-reauth branch. Telemetry copy verbatim from TELEMETRY.md (incl. PostHog disclosure); recovery toggle per FIRST_RUN.md # Step 2a. Deviations: time-zone detect uses browser Intl zone (not IP-geo); admin step collects a Linux username (display-name→slug deferred); timedatectl exec untested (host-integration lane, usermgr convention). OpenAPI + web client regenerated. make check + make check-web green (FIRST_RUN.md # Phase 2, ENVIRONMENT.md # Provisioning) done
hosted-wildcard-cert.md — Hosted always-on .malmo.network wildcard cert + sole URL scheme (C3b of #196, #207; consumes the seam C3a/#206 left open, unblocked by cloud CL4/CL5). Box-side machinery for the hosted profile's <slug>.<box-id>.malmo.network HTTPS scheme. Typed enrollment: profile.Seed.Enrollment goes from opaque json.RawMessage to EnrollmentCredentials{subdomain,username,password} matching the cloud producer's wire format byte-for-byte; loadHostedEnvironment persists it (box_meta, hash→enrollment→box-id commit order) + reloads on frozen-identity boots so Caddy reconfigures without re-reading the ignored seed; absent/incomplete enrollment still gates /setup, just serves no HTTPS. Caddy DNS-01: new caddy.EnsureWildcardTLS writes the tls automation policy (ACME issuer, acmedns DNS provider with the seeded creds + a box-side endpoint constant MALMO_ACMEDNS_ENDPOINT=https://auth.malmo.network) and adds :443; cert subjects are <box-id>.malmo.network + *.<box-id>.malmo.network (the apex isn't under the wildcard) sharing one _acme-challenge.<box-id> record; called at startup only when hosted && enrollment.Complete(). Sole scheme: new internal/profile/appurl.go host helpers (the single place the shape is named) consumed by a profile-aware seam in lifecycle (SetEnvironment/m.hosted(): install/routeHost/publishHost/MALMO_APP_URL use the public host, skip mDNS publish) and api (toDTOhttps://<slug>.<box-id>.malmo.network); dashboard host becomes <box-id>.malmo.network; appliance .local/Avahi path byte-for-byte unchanged, ready for the future toggle to reuse the seam. Custom Caddy: stock caddy:2-alpine has no DNS module, so dev/control-plane/caddy-acmedns/Dockerfile (xcaddy + caddy-dns/acmedns) + compose.yml image ${MALMO_CADDY_IMAGE:-caddy:2-alpine}; hosted sets the var, appliance keeps stock. Unit-tested (config-gen, URL surfacing, enrollment ingest/reload, cert subjects); make check green minus PAM (pre-existing CGo). Real Let's Encrypt issuance NOT verified here — joint with cloud #6 / CL6 (no real ACME/DNS in the inner loop or QEMU cloud lane); DECISIONS.md entries for the hosted custom image + acme-dns endpoint constant; public acme-dns API face not yet deployed cloud-side (filed) done
hosted-caddy-image-bake.md — Bake the caddy-dns/acmedns Caddy into the hosted image + plumb MALMO_CADDY_IMAGE (CL6 prep; closes the deferred bake/plumbing half of hosted-wildcard-cert.md/#207). C3b shipped the wildcard-cert machinery + the custom-Caddy xcaddy recipe but nothing built it, baked it in, or set the env var — so a booted hosted box still ran stock caddy:2-alpine (no DNS-01 module) and could not obtain *.<box-id>.malmo.network, i.e. could not serve HTTPS. The missing link was Go plumbing: compose.yml already selected ${MALMO_CADDY_IMAGE:-caddy:2-alpine} and the brain's ControlPlaneUp inherits the brain env, but host-agent curates the brain container's env in brainlaunch.runSpec and MALMO_CADDY_IMAGE wasn't on the list — so the systemd drop-in alone was inert. Fix: brainlaunch.Config.CaddyImage → emitted to the brain env only when non-empty (unset = appliance stays on stock Caddy, no ACME), read from MALMO_CADDY_IMAGE in cmd/host-agent-real; conditional-emit idiom shared with MALMO_OFFLINE_INSTALL. Cloud lane bake: dev/cloud/test/bootstrap.sh docker builds the xcaddy recipe → malmo-caddy-acmedns:dev and docker saves it over the staged caddy.tar (not the shared .dev/control-plane bundle the appliance/medium lane keeps stock); the drop-in sets MALMO_CADDY_IMAGE=malmo-caddy-acmedns:dev; cloud-assertions.sh's baked-image check expects malmo-caddy-acmedns; CANARY v12→v13. Build-host-only docker build (the air-gapped VM still loads offline from the tar). Unit tests (var absent when empty, present when set) + bash -n + -tags hosted build/vet green; real issuance unverified here (air-gapped QEMU) — that's the cloud-side CL6 live run (malmoos/cloud #18); make test-cloud-qemu re-run is a no-regression gate done
cloud-qemu-lane-hosted-gate-fix.md — Cloud QEMU lane green end-to-end on the hosted /setup gate (CL6; closes the "VM-boot acceptance pending" gap hosted-caddy-image-bake.md left). First real run of make test-cloud-qemu on the post-C3a/C3b/C4 stack failed the unseeded gate (422, want 503); tracing surfaced three causes. (1) Stale brain imagebootstrap.sh reuses the shared .dev/control-plane/*.tar bundle unless MALMO_REBUILD_CP=1, and the cached malmo-brain.tar predated C3a's gate + C1a profile resolution, so the booted brain resolved appliance and /setup hit the appliance request-validation path (422), not the hosted 503 (reproduced locally: fresh image + hosted marker → correct 503). (2) Canary masked every rebuild — the .cloud-boot-ready/CANARY_VERSION gate skips the entire bootstrap when the version matches + the image exists, so a fresh tarball never re-entered the image; it keys only on its own version, not on the bundle changing underneath it. (3) Provisioned dashboard host moved — after C3b (#207) a seeded/frozen box serves the dashboard + /api + /setup at <box-id>.malmo.network, but cloud-assertions.sh hardcoded DASH_HOST=malmo.local for all boots → Caddy catch-all 404 on boots 2–3. Fix: cloud-assertions.sh resolves DASH_HOST per scenario (malmo.local unseeded; <box-id>.malmo.network from the seed for seeded and from the persisted identity in MODE for frozen) + adds failure-path diag() dumps (grep'd brain profile/seed log lines, brain bind-mount list) so this class self-diagnoses; CANARY_VERSION v13→v14 to force the clean rebuild. VM-boot acceptance PASSEDsudo -E make test-cloud-qemu green on a clean VM: unseeded 503 → seeded 401/200+box_id → frozen reboot ignores re-delivered seed. Bundle-cache staleness mitigated, not eliminated (a future brain change still needs MALMO_REBUILD_CP=1 locally; CI builds fresh); still air-gapped, so no real ACME (cloud #18) done
per-instance-resource-limits.md — Per-instance cgroup limits + the first profile-branching lifecycle seam (C6 of #196, #211, building on C1a's marker). Resolves a spec tension first: #211 / ENVIRONMENT.md's draft said limits are manifest-sourced and cap CPU on both profiles, contradicting the locked APP_ISOLATION.md # Resource limits (no manifest limit field; CPU never capped on the appliance; appliance's only cap is the optional user-set memory cap). Resolution (no locked decision flipped): keep the manifest limit-free, make the mechanism profile-divergent (appliance = memory only, hosted = memory + CPU), sync ENVIRONMENT.md. New internal/store instance_resource_limits table (PK instance_id ON DELETE CASCADE, memory_bytes/nano_cpus) + ResourceLimits/Get/Set (missing row = zero/uncapped, FK rejects unknown instance). New internal/lifecycle/resources.go: Manager.SetResourceLimits (the policy seam — validates, store.ErrNotFound on unknown, persists; no production caller yet by design), resourceLimitsStanza (renders compose deploy.resources.limits — memory both profiles, cpus hosted-only, nil/uncapped default), reapplyResourceLimits (surgically patches only the main service's deploy in the rendered override). writeOverride stamps the stanza at install; Reconcile re-applies on a drifted instance before up and recreates a live one only when the stanza changed (a clear removes it; unchanged is a no-op — fixing a nil-map-vs-null marshal bug that would have recreated every uncapped app each reconcile). cmd/brain wires the profile via C3b's SetEnvironment seam. Disk quota deferred (ext4+overlay2 can't enforce a per-container quota portably → XFS project quotas via host-agent; NEXT.md + #221). New files ≥90% covered; make check green (ENVIRONMENT.md # Per-instance resource limits, APP_ISOLATION.md # Resource limits) done
cloud-e2e-test.md — Cloud-image end-to-end test (C5 of #196, closes #209; chained on C4 #208/#229): drives the cloud lane past the /setup gate through the trimmed first-run wizard to a served, admin-owned, first-run-complete box — the "boots as a proper hosted OS" bar that C2 (#205) + C3a (#206/#220) + C4 (#208) built toward. Tooling-only (dev/cloud/): the in-VM serial-driven cloud-assertions.sh seeded scenario gains, after the existing correct-secret /setup 200, a wizard-completion block — PAM /login (verify-password against /etc/shadow, keep the malmo_session cookie) → POST /system/timezone (a real timedatectl pass-through in the hosted build, so it proves the step on the real box) → POST /system/telemetry → bodyless POST /system/first-run-complete (asserts first_run_complete:true) → public GET /auth/state reports first_run_complete:true (wizard won't reappear) → authenticated GET /me carries the persisted box_id; the frozen reboot additionally asserts first_run_complete survives the power-cycle. Added cookie-aware /dev/tcp helpers (session_cookie/auth_post/auth_get/status_of; no curl/jq in the lean image). Finding: tzdata added to the lean image (dev/cloud/mkosi.conf) — the wizard's time-zone step is real timedatectl, which rejects zones absent from /usr/share/zoneinfo, and Install-Recommends=false had dropped systemd's tzdata recommendation (not on the appliance cut-list, lean-check still passes). run-cloud-tests.sh narration extended (no driver logic change); CANARY_VERSION v14→v15 (rebased over CL6, which had taken it to v14). bash -n clean; VM-boot acceptance pending on the maintainer env (#189 blocks mkosi/QEMU on this box) — make test-cloud-qemu (first run needs MALMO_REBUILD_CP=1 so the baked brain includes C4's /system/* endpoints) (TESTING.md # Full-stack control-plane integration, FIRST_RUN.md # Phase 2/3) done
cloud-image-recommends-pin.md — Pin apt recommends off for the hosted cloud image (closes #237, a maintenance fix on C1b hosted-cloud-image.md/#218). make build-cloud-image started failing its own lean check (nftables present) with no change to the check or the spec: nftables is in the manifest only as iptables' Debian Recommends (docker-ce hard-Depends iptables; iptables only Recommends nftables; nothing else in the 133-package image hard-depends it), so apt recommends were being installed — and weren't at #218 (proof: systemd-boot-efi/tzdata, both Recommends, are listed explicitly "or the build fails", which only matters with recommends off). The config never set the knob and rode on mkosi's default; mkosi here is a moving dev build whose default re-enabled recommends — pure tooling drift, no malmo code change. Fix: one mkosi-native knob in dev/cloud/mkosi.conf [Content]WithRecommends=no, which mkosi maps straight to -o APT::Install-Recommends=false (installer/apt.py emits it unconditionally), pinning recommends off regardless of the default; the boot-proof test lane inherits it via Include=... Drops nftables while keeping iptables (the hard dep Docker needs); no spec change (ENVIRONMENT.md already lists nftables as a cut). Two adjacent comments that attributed recommends-off to mkosi's default updated to point at the pin. Verified at the config layer: mkosi summary accepts WithRecommends=no (typo control WithRecommendz rejected as Unknown setting); the apt-mapping read from mkosi source. Full mkosi build/make test-cloud-qemu not run here (#189 PR_CAPBSET_DROP blocker, confirmed sandbox-independent) — green build + Docker-serves boot land at the joint cloud CL6 live run per the issue's "Done when". CI lean-check guard (the issue's "Also") filed as a follow-up (#238) done
acme-dns-public-face-live.md — Cloud acme-dns public face live; close the cross-repo gap C3b left open (doc-sync; malmoos/cloud #14 / cloud PR #29). C3b (hosted-wildcard-cert.md, #207) shipped the box-side wildcard-cert machinery against a box-side constant MALMO_ACMEDNS_ENDPOINT=https://auth.malmo.network, but the cloud deploy bound acme-dns's HTTP API to 127.0.0.1:4443 (/register only) — no public face, so the default was a chosen value and no box could renew (filed malmoos/cloud #14). Cloud #14 now fronts acme-dns with the shared Caddy over real Let's Encrypt TLS for auth.malmo.network (only /update + /health exposed; /register loopback-only, 404 on the public face) with the authoritative :53 face delegated in Google Cloud DNS and answering publicly — https://auth.malmo.network is a confirmed live endpoint, box-side default unchanged. OS-side is doc reconciliation, no code change (box-side wiring already complete): ENVIRONMENT.md # Networking & discovery — as built narrows the gap to real issuance; DECISIONS.md (2026-06-21 endpoint-constant) gap bullet closed; NEXT.md item marked deployed. The frozen C3b/CL6 entries are left untouched — this is the where-we-are-now record. Real issuance still unverified — the joint cloud #6 / CL6 live on-ramp is the first and only real test (no real ACME/DNS in the OS inner loop or air-gapped QEMU lane) done
cloud-image-nftables-hard-dep.md — Accept nftables in the hosted cloud image permanently (closes #241; the proper fix for the lean-check break #243 unblocked temporarily, distinct from #237's recommends path). make build-cloud-image failed its own lean check (nftables present), but not the #237 regression: docker-ce bumped to 5:29.6.0 whose control file reads Depends: ... iptables, nftables — two separate hard deps (Docker 28 moved its firewall backend to nftables; 29.x made the package mandatory). Recommends-off (WithRecommends=no, #237/PR #239, still correct) can't exclude a hard dep, and the hosted image must run docker (the four control-plane containers), so nftables is unavoidable. Decision (issue option 1, recommended): accept it permanently — the package's presence is docker's, not appliance LAN machinery. dev/cloud/bootstrap.sh: #243's temporary "TEMPORARILY removed … re-evaluate before this lands" comment replaced with a permanent rationale, and the stale nftables included step-4 header corrected to nftables is intentionally NOT cut; the executable cuts set is byte-for-behavior unchanged (comment-only edits — build behaves exactly as green post-#243 main). ENVIRONMENT.md # How the profile is realized (nftables moved out of the "not installed" list, reframed as docker's hard-dep backend with no malmo ruleset) + # Public-by-default ("in the cut list" → "present, no malmo ruleset"; provider-security-groups substance unchanged). New DECISIONS.md 2026-06-23 entry (the 2026-06-19 decision's substance holds — only its "ships no nftables" premise flipped; docker-ce < 28 pin rejected as shipping an EOL'd Docker). NEXT.md backstop item now a ruleset + host-agent seam only (package already present). Verified comment-only via bash -n + python heredoc compile; full mkosi build/make test-cloud-qemu not run here (#189 PR_CAPBSET_DROP blocker) — no behavioral delta, so green build + Docker-serves boot land at the joint cloud CL6 live run per the issue's "Done when". Composes with the #238 leanness-regression CI guard done
cloud-image-first-boot-wiring.md — Promote the hosted first-boot wiring from the test lane into the production image (closes #242, the OS half of cloud#6/CL6). Surfaced by the first real-cloud boot: a box from make build-cloud-image (the snapshot provisioning uploads) booted completely unreachable/etc/systemd/network/ empty (NIC never got an IP) and none of the malmo wiring present, because all of it lived only in the test lane (dev/cloud/test/); the lean production config (dev/cloud/) was built "with NO postinst/control-plane images" and had never been booted (C1b only asserted leanness; C2 booted the test image). Tooling-only pure relocation (the test image already booted+served at #205/#209 — its wiring was just in the wrong dir): new dev/cloud/stage-control-plane.sh (one sourced stage_control_plane() both lanes call — slim host-agent + units + drop-in, PAM, control-plane bundle + acmedns Caddy + loader, compose/caddy.json, seed materializer; can't drift) + new dev/cloud/mkosi.postinst.chroot (enable docker/networkd/host-agent/load-images/seed, 05-docker-unmanaged+20-dhcp .network, malmo identities, mask firstboot) + ExtraTrees=mkosi.extra.wiring (generated, gitignored) beside the committed mkosi.extra marker. dev/cloud/bootstrap.sh now stages the wiring before the unchanged lean check (needs root → make build-cloud-image self-sudos); dev/cloud/test/{bootstrap,mkosi.postinst.chroot} slimmed to add only the serial self-check on top (it inherits the production postinst+wiring because Include=.. makes mkosi chdir into dev/cloud/ and re-run its auto-detection — verified in mkosi source). malmo-seed-{materialize.sh,service} git mvd test→prod. Test image stays byte-identical, so #205/#209's boot still applies. Real-cloud seed channel split to #246 (SMBIOS-only here; Hetzner injects via metadata, needs a cross-repo wire contract + first-boot net ordering). bash -n clean; build/boot acceptance is the CL6 live run (#189 blocks mkosi/QEMU here), same deferral as C3a/C4 (ENVIRONMENT.md # Provisioning & first-boot, TESTING.md # Hosted cloud variant) done
mkosi-userns-ci-lane.md — CI cloud-image build lane + mkosi unprivileged-userns fix (closes #189; lands the CI lean-check guard #238 wanted): root-causes the make build-cloud-image / make test-medium-qemu build failure the whole cloud track carried. The cause is not sudo (as the issue guessed) but Ubuntu 24.04's kernel.apparmor_restrict_unprivileged_userns=1: mkosi 26 builds rootless (unshare userns → drop_capabilities), and the restricted userns has no CAP_SETPCAP, so PR_CAPBSET_DROP EPERMs at sandbox bring-up. Reproduced unprivileged (no sudo) and via unshare -Ur failing the uid_map write. Fix: a new .github/workflows/ci-cloud-image.yml (ubuntu-24.04, path-filtered dev/cloud/**) sets the sysctl knob, installs pinned mkosi==26 + Debian tools-tree tooling, runs a fast mkosi sandbox smoke (isolates the fix), then make build-cloud-image — the first automated cloud-image build, which also runs the manifest lean check (#238 option 1). Dev UX: dev/cloud/bootstrap.sh hard-probes mkosi's sandbox and exits with the remedy on failure; dev/test-qemu/bootstrap.sh warns above the build; docs/dev/running-locally.md documents the knob + a scoped AppArmor-profile alternative. Gaps: the medium lane's KVM-needing boot half stays out of CI (only the build is unblocked); #238 option 2 (full expected-package-set diff) left as an optional follow-up done
cloud-image-lean-allowlist.md — Widen the cloud-image lean check to a full expected-package-set diff (closes #238/#196; option 2 after mkosi-userns-ci-lane.md/#189 delivered option 1). The lean check (dev/cloud/bootstrap.sh step 5) was a denylist — it only failed if one of eight named appliance packages (network-manager, avahi-daemon/avahi-utils, samba, mergerfs, cryptsetup, tpm2-tools, openssh-server) appeared, so it was blind to any bloat it didn't name; #237's nftables recommends-drift was caught only because nftables happened to be listed, and #241 then had to accept nftables permanently (docker-ce hard-Depends it) so the old denylist no longer even covered that case. Fix: a new checked-in dev/cloud/expected-packages.txt — a lockfile-style allowlist (one package name per line, # comments, names only so version bumps don't churn it) of the exact lean set, generated from the first green CI build's authoritative manifest. bootstrap.sh now diffs the manifest's package names against it and fails on either an off-list package (UNEXPECTED, bloat) or an expected-but-absent one (MISSING, silent removal/rename), printing offenders one-per-line so regeneration is copy-paste. The old cut list moves into the allowlist header as intent. Comment-sync on bootstrap.sh's sequence header + mkosi.conf's ManifestFormat line. No spec change (ENVIRONMENT.md # How the profile is realized already defines the lean posture — this makes enforcement total, not partial). Diff logic verified locally on synthetic manifests (off-list→fail, exact→pass, missing→fail, comments/blanks ignored) + bash -n; end-to-end green on the ubuntu-24.04 CI lane (make build-cloud-imagelean check passed — manifest matches expected-packages.txt exactly) done
cloud-image-real-cloud-seed-channel.md — Ingest the first-boot seed from the real-cloud (Hetzner) metadata/user-data channel, not only SMBIOS (closes #246/#196). SMBIOS-first short-circuit (air-gapped lane untouched) → HTTP /dev/tcp fallback from 169.254.169.254 with a bounded DHCP-race retry + passive After=network.target; fetch/parse/retry/404 unit-tested against a mock in ci-go. Metadata-secret SSRF exposure split to #251. CL6-verified.
cloud-image-live-onramp-fixes.md — Cloud-image live on-ramp fixes (after #246, surfaced by the malmoos/cloud #6 / CL6 live run, which then passed green). Two box-side bugs that only appear on a real Hetzner provision→cert→admin cycle: (1) the seed-fetch dropped a good 200 when the metadata server held the socket open (ignoring Connection: close), because http_get keyed success on the timeout-kill exit code rather than the already-captured bytes — [ -n "$resp" ] now decides, with a regression test driving a raw keep-alive listener the polite httptest mock never exercised; (2) the lean image enabled networkd/DHCP but shipped no systemd-resolved and no /etc/resolv.conf, so the box got an IP but resolved no name — acme-v02.api.letsencrypt.org/auth.malmo.network were unreachable and the *.<box-id> wildcard cert never issued — first-boot now writes a static /etc/resolv.conf (ENVIRONMENT.md # Networking & discovery). Real Let's Encrypt issuance via acme-dns DNS-01 confirmed on the live box done
strip-godbus-hosted-build.md — Strip godbus from the slim -tags hosted host-agent (closes #217, the "no DBus deps" follow-up slim-cloud-host-agent.md/#216 filed). #216 dropped avahipublisher from the hosted host-agent-real but netstate still dragged godbus in: the shared hostagent mux's NetState seam returns netstate.LANInterface, so every mux consumer imports netstate, and nm_linux.go (//go:build linux) compiled its godbus-backed NMProvider into the hosted build even though nothing wires it (a.Net nil) — ~182 inert godbus symbols. Fix (issue Option B, provider-side, two build-tag lines, no new file): retag the real provider nm_linux.go //go:build linuxlinux && !hosted, and widen the existing godbus-free stub nm_other.go //go:build !linux → `!linux
hosted-metadata-egress-block.md — Block app-container egress to the cloud metadata endpoint (closes #251/#196; closes the SSRF residual risk #246 deferred). On a hosted box the first-boot seed (admin-bootstrap secret + acme-dns password — the credential to rewrite _acme-challenge.<box-id> and mint/MITM the *.<box-id>.malmo.network wildcard cert) stays retrievable at 169.254.169.254 for the server's life, and Docker NATs container egress out the host NIC, so any untrusted app container could read it (classic cloud-metadata SSRF, cf. Capital One 2019). Fix: a standing in-guest nftables forward-hook DROP of egress to 169.254.169.254 (own inet malmo_metadata table, policy accept, counter, idempotent), shipped as committed static files under dev/cloud/mkosi.extra/ (etc/malmo/metadata-firewall.nft + a malmo-metadata-firewall.service oneshot) and enabled by mkosi.postinst.chroot for both the production image and the boot-proof lane. Forward hook, not output: the seed materializer fetches the endpoint as host root in the host netns (OUTPUT path), so it is untouched; every container — apps and the brain (which reads the seed from disk) — takes the FORWARD path and is dropped, a standing always-on policy with no unblock-until-seeded timing. The first malmo-owned in-guest rule (general default-deny backstop stays deferred). cloud-assertions.sh step 6b proves it: a probe from the brain netns must hit the drop counter while no OUTPUT hook exists (host seed path safe). bash -n clean; live build/boot deferred to cloud#6 / CL6 (nft/mkosi/QEMU need root + the apparmor-userns sysctl; #189 now closed) (ENVIRONMENT.md # Provisioning & first-boot, DECISIONS.md 2026-06-25) done
mongodb-compat-spike.md — FerretDB v2 compatibility spike → decision: malmo will not offer a managed MongoDB type (#253). Stood up the two-container FerretDB v2 engine (ghcr.io/ferretdb/ferretdb:2 proxy + postgres-documentdb:17 store, MongoDB-7.0-wire) by hand and measured it with a self-built Apache-2.0 mongodb-mongosh client, cross-checked against FerretDB/DocumentDB issue trackers + each candidate app's docs. Two independent measured findings sink the managed-service path. (1) Ceiling: no change streams ($changeStream unimplemented at the engine, documentdb#50), no oplog, no MongoDB replica set (replSetGetStatus CommandNotFound; v2 "replication" is Postgres WAL), no multi-doc transactions (commitTransaction CommandNotFound) → Rocket.Chat hard-blocked, Habitica/Appsmith blocked, Wekan degraded (Meteor poll-and-diff), short of the 3+ bar. (2) Isolation: authentication enforced but no per-database authorization (proxy has only --auth, no RBAC) — a user scoped dbOwner on its own db freely read+wrote another app's db; a read-role user wrote — so the SERVICE_PROVISIONING.md # Per-app isolation contract can't be met on a shared instance. Decided, not deferred: no managed type: mongodb (no FerretDB engine, and no alias to it by analogy to redis→valkey — FerretDB is not a drop-in); the real engine is SSPL and on the avoid-list when malmo serves it. Mongo apps instead bundle their own engine (the Umbrel/CasaOS pattern, which ships real MongoDB and sidesteps both findings) and are accepted by curation as long as the app uses Mongo internally and does not serve the database itself as a service. No code changed (manifest_test.go's mongodb unknown-type-rejection case is correct-and-intentional and now load-bearing). DECISIONS.md 2026-06-25; SERVICE_PROVISIONING.md # Post-v1 candidates + NEXT.md # Store catalog curation policy carry the rule done
setup-secret-prefill.md — Hosted /setup prefills the admin-bootstrap secret from the portal link's URL fragment (<box>/setup#secret=…, never a query string → out of access logs/Referer) and strips the hash on mount; the form now shape-validates the secret (43-char base64url) before the round-trip, so a truncated paste gets an actionable message instead of the gate's opaque 401. A live box rejected a correct secret — the failure was the manual hand-off, not the gate (gateBootstrap unchanged). Two-side seam co-owned with malmoos/cloud (dashboard.js produces the link); ENVIRONMENT.md # Admin bootstrap, DECISIONS.md 2026-06-26 done
manifest-config-block.md — Manifest config: block for user-supplied app configuration (closes #264 + the operator-env-config catalog gap; implements the D4 spec #265 landed). An app declares values only the user can supply (an API token, an external connection string, a provider/model selector); the brain renders a form at install and a Settings editor on the app's page and injects each answer directly under the app's own env-var name in the target service's compose override — no MALMO_* indirection, because the value is the app's own native variable. Manifest: Config []ConfigField (app_env/title/description/secret/required/type text|enum|bool/options/default/service); validateConfig makes app_env a security boundary (bare uppercase identifier, never the MALMO_ prefix, never a loader var PATH/HOME/LD_PRELOAD/… via reservedConfigEnv, unique), options required iff enum, secret may not carry a default, bool requires a true/false default; service backstopped at install (validator has no compose). Store: new instance_config table (PK (instance_id, app_env) ON DELETE CASCADE, value+secret), SetInstanceConfig/GetInstanceConfig, plaintext at rest like instance_secrets (encryption deferred, NEXT.md). Lifecycle (new config.go): writeOverride stamps each value verbatim into the target service's environment: in the override (not the MALMO_* interpolation .env), SetConfig re-stamps + compose ups a running app (brain-commits-first), restampConfigEnv wholly owns the override env block so a cleared value stops injecting, validateConfigServices catches a service: naming no real compose service; Install gained a config param (Door-2 passes nil). API (new appconfig.go): install-plan returns the schema (no values); POST /apps validates config.fields422 per-field + audits app.install failure, persists+stamps before first compose up; GET /apps/:id/config (owner-or-admin) masks secrets to set:true|false and pre-fills non-secret defaults; PUT /apps/:id/config is a partial update (absent keeps, "" clears optional, required checked against the resulting state so a secret can be replaced never blanked), rewrites override + restarts as a job, audits app.config.update success and failure (new ActionAppConfigUpdate). web-ui: install dialog Setup step (monospace Sets <APP_ENV> hint, masked secret / enum select / bool toggle / text; required fields disable Install; optional blanks omitted) + per-app Settings editor (non-secret inline-editable, secret shown set/not-set with a Replace box, partial save → restart); generated client regenerated. make check + make check-web green done
servicehealth-profile-aware-allowlist.md — Make the service-down detector's host-unit allowlist profile-specific and drop the phantom caddy.service unit, fixing four false service-down errors on a freshly provisioned hosted box (avahi-daemon/caddy/chrony/smbd all "inactive" while the box was healthy — observed live on pine-peak.malmo.network). Two defects: (1) Caddy is a brain-managed container (malmo-caddy), not a host systemd unit, on every profile (CONTROL_PLANE.md; HEALTH.md # Locus C — "there is no caddy.service"), so systemctl is-active caddy.service raised a permanent false positive — the code's CoreUnits had diverged from the spec's locus-B allowlist (HEALTH.md line 172), which already omits caddy; (2) the lean cloud image cuts Avahi/Samba/chrony (ENVIRONMENT.md # How the profile is realized, dev/cloud/expected-packages.txt), but wiring_hosted.go/wiring_appliance.go both called the same servicehealth.New(), so a hosted box watched three units that never exist. Fix: CoreUnits → exported ApplianceUnits (docker, avahi-daemon, chrony, smbd) + HostedUnits (docker only); New()New(units []string); each wiring passes its set. HEALTH.md locus-B row records the allowlist as profile-specific (caddy never in either set — locus C). New TestAllowlists_NoPhantomOrSelfUnits pins no caddy/host-agent in either list + hosted = docker-only. Caddy liveness stays the deferred locus-C check. go test/go vet ./internal/hostagent/servicehealth/ green, gofmt-clean; cmd/host-agent-real link deferred to a PAM box / nspawn-qemu lane (pre-existing libpam0g-dev gap). Box-side clears on the next make deploy-image + redeploy in malmoos/cloud done
hosted-image-bake-catalog.md — Bake the Door-1 app-store catalog into the hosted cloud image, fixing a live box that couldn't load the store (read catalog: open /var/lib/malmo/catalog: no such file or directory). host-agent-real defaults CatalogDir to <DataDir>/catalog = /var/lib/malmo/catalog (cmd/host-agent-real/main.go) and passes it to the brain (brainlaunch), but dev/cloud/stage-control-plane.sh's stage_control_plane() staged the control-plane bundle/compose/caddy.json/seed materializer and never created or populated /var/lib/malmo/catalog — only the appliance/test lanes staged a catalog (a test-only whoami). Fix: add var/lib/malmo/catalog to the wiring mkdir + cp -r "${REPO_ROOT}/catalog/." "$WIRING/var/lib/malmo/catalog/" in the shared function both dev/cloud/bootstrap.sh (production) and dev/cloud/test/bootstrap.sh (cloud-test) source — bakes the real shipping catalog (~15 MB, 22 apps: manifests + icons + screenshots), riding the brain's existing read-only /var/lib/malmo bind mount (no new mount/env; the brain's default path already resolves to it). Catalog is data, not apt packages → lean check (expected-packages.txt) unaffected. Door-1 stays local (a catalog update is an image rebuild + redeploy, like the control-plane bundle); a hosted catalog-sync path is separate future work. bash -n clean; build/boot acceptance is the make deploy-image + redeploy in malmoos/cloud (#189 env for the image rebuild), same deferral as the other dev/cloud build-script entries done
portal-box-sso.md — Box side of the portal-to-box SSO handshake (#275, lockstep with cloud #51/PR #52). Replaces the hosted admin-bootstrap-secret /setup with: the portal redirects the owner to GET /_malmo/sso?token=... with a short-lived Ed25519-signed ownership assertion; the box verifies it (new leaf internal/assertion, mirroring the cloud reference — minimal signed token, not a JWT), applies box-side policy (iss/box/single-use jti via a used_assertions ledger), auto-creates a passwordless PAM admin on the first valid owner assertion (owner-only thereafter), mints a host-only box session (cookie not Domain-scoped → never reaches app subdomains), and 303s to the dashboard. Seed admin_bootstrap_secretassertion_verification_key (persisted in box_meta, key→enrollment→box-id commit order); cmd/brain decodes it to an ed25519.PublicKey and hands it to api.SetEnvironment. Hosted /setup disabled (403, audited); gateBootstrap/bootstrap_secret removed. Caddy dashboard brain leg now matches /_malmo/* too. web-ui: hosted has no login/setup page — unauthenticated bounces to the malmo.network portal; AdminStep loses the hosted secret field. New audit actions sso.success/sso.failure. Appliance unchanged. Live portal→box run is the joint cloud on-ramp acceptance.
cloud-wildcard-tls-assertion.md — Close the #278 CI gap: the air-gapped cloud lane (dev/cloud/run-cloud-tests.sh) delivered a seed with no enrollment block, so cmd/brain's EnsureWildcardTLS was gated off (enrollment.Complete() false) and the hosted wildcard-TLS pass — Caddy's acme-dns DNS-01 issuer for *.<box-id>.malmo.network + the :443 listener — was never exercised in any test; a hosted box failing to bind :443 / obtain its wildcard cert shipped green. Fix (test-lane only, no brain/UI behavior change): seed_cred() now adds a complete (inert, air-gapped) enrollment, so the brain runs its wildcard-TLS pass; cloud-assertions.sh adds two seeded-boot checks — (a) the brain logs caddy: wildcard TLS configured (config reached + applied), (b) a TCP connect to 127.0.0.1:443 succeeds (the listener bound — the issue's ":443 never binds" symptom, asserted positively). Real Let's Encrypt issuance stays the cloud live on-ramp's job (air-gapped restrict=on, no ACME reach). In the publish gate (seeded boot). Brain ReadSeed confirmed to accept the new wire shape (Complete() true). Original #278 boot did not reproduce on re-test (box reachable, SSL fine) — the durable fix is preventing the regression class. TESTING.md # Hosted cloud variant updated done
oatmeal-theme.md — Adopt cloud's Oatmeal design system in the OS dashboard (closes #260): olive OKLCH palette + Inter / Instrument Serif lifted verbatim from cloud's input.css into web-ui/src/style.css's @theme, with the existing shadcn-vue semantic tokens (background/foreground/accent/muted/border/card/…) repointed onto olive values so the whole app recolors from one file (no ~1,000-site find-and-replace). Accent is monochrome dark olive (matches cloud's bg-olive-950 CTAs); destructive/success stay semantic red/green (olive is monochrome; cloud keeps red for errors too). Fonts self-hosted, not CDN (divergence from cloud + the Oatmeal README): the .local HTTP LAN may be offline, so Inter (400/500/600) + Instrument Serif (400) ship as bundled woff2 under web-ui/src/assets/fonts/ with SIL OFL 1.1 notices, fingerprinted by Vite — production bundle emits all four faces + makes zero network font requests. @font-face hosted in a separate fonts.css (imported from main.ts) because Tailwind v4's Lightning CSS transform silently drops the first @font-face in the @import "tailwindcss" entry. Auth surfaces restructured to the theme's composition (per maintainer direction, beyond the token-only scope): Login/Setup/steps/Recover share the central .auth block, so restyling it once — Instrument Serif wordmark, olive-50 cards, rounded-full inputs/buttons, dark-olive CTA, zero raw hex — propagates to all seven auth screens with no per-file churn. Home/Settings recolor via tokens; full restructure is a follow-up. make check-web green; theme rendered + verified offline headlessly done
oatmeal-element-idioms.md — Apply Oatmeal's element idioms on top of #260 (closes #261, stacked on feat/260-oatmeal-theme): two shared components written fresh from Tailwind utilities (no Oatmeal .tsx copied) — components/ui/Button.vue (pill rounded-full idiom; primary/secondary/ghost variants, sm/md/icon sizes, olive tokens, hover:bg-olive-800) and components/ui/Heading.vue (font-display Instrument Serif, tracking-tight, level→tag+size). Auth unified onto the components: #260's central .auth button/wordmark CSS became drift, so .auth button[type=submit] / .auth button.copy / .auth h1 were deleted and every auth submit button (Login + RecoverView + the 4 setup steps), the two Copy buttons (→ secondary), and the malmo wordmark (→ <Heading level=1>) now flow through the shared components — one source of truth for the pill + display heading. Chrome: TopBar.vue avatar → round dark-olive <Button size=icon>; Dock/AppShell/SettingsLayout are RouterLink nav rails needing no idiom change (link-styled toggles stay links). Home/Store/Settings-section buttons + section headings are out of scope → #290 (composition restructure), which consumes these components. make check-web green; idioms rendered + verified headlessly done
oatmeal-token-sweep.md — Sweep the remaining component <style> blocks + raw hex onto the olive tokens (closes #262, stacked on feat/261-oatmeal-idioms). Added two muted status tokens to @theme--color-warning (oklch(54% .13 70)) + --color-info (oklch(52% .12 245)) — realizing #260's foreshadowed warning token; the four status tokens now back the full severity vocabulary (NOTIFICATIONS.md): info→info, warning→warning, error/critical→destructive, resolved→success. Converted seven <style> blocks: NotificationBell + LiveResources chrome → card/border/muted/foreground (gauge fill → accent dark-olive, replacing blue #4dabf7); ToastHost + HealthBanner error/success tints → color-mix(<token> 12/35%, surface) + solid-token stripe/text; HomeView health-dot/badge severities → status tokens; AppDetailView markdown :deep() fixed — its var(--accent,#2563eb)/var(--muted,#f1f3f5) fallbacks named non-existent tokens so the hex rendered (blue links, gray code) → real --color-accent/--color-muted. HealthBanner wasn't in the issue's named-seven but carried hex a grep had silently skipped (robust Python rescan caught it). Radius reconciled to --radius on popovers/icon buttons. Repo-wide hex scan now returns only Login.vue's six intentional per-user glyph hues. make check-web green; severities/tints/bar verified headlessly against the built CSS done
catalog-remote-thin-client.md — Make the box a thin HTTP client of the control plane's catalog API on every profile, with a last-good on-disk cache, and complete the cutover so no catalog/ ships in the OS image (OS half of cloud #62; supersedes hosted-image-bake-catalog.md). internal/catalog.Catalog is a thin facade over a private source interface, so internal/api + internal/lifecycle (and every test) are untouched: NewRemote(opts) is the control-plane client production uses; New(root) (the disk reader, disk.go) is retained only as the constructor those packages' tests build a catalog with. remote.go fetches GET /catalog/sync (the whole snapshot in one shot), verifies its integrity digest (wire.go — a byte-faithful mirror of cloud CatalogFile/App, reusing the os manifest types the cloud mirrored, recomputing hex SHA-256 over json.Marshal(apps) against the stamped index_sha256 + schema), swaps an indexed in-memory snapshot in under a lock, writes verified bytes through to CacheDir/catalog.json (atomic). Reads project the six methods locally under an RLock — never blocking on the network; a synced-then-offline box browses last-good, a never-synced box is an empty store (accepted); If-None-Match gives a cheap 304. Env filter = the resolved profile; Load re-parses app.manifest with the box's own manifest.Parse; IconPath/ScreenshotPath proxy+cache the CP asset behind the UI's hard-coded /api/v1/catalog/{id}/icon+/screenshots/{n} routes (box origin preserved). Cutover: every box constructs NewRemote (new env MALMO_CATALOG_URL default https://malmo.network, MALMO_CATALOG_CACHE_DIR, MALMO_CATALOG_REFRESH 15m); MALMO_CATALOG_DIR retired across cmd/brain, brainlaunch (→ CatalogURL/CatalogCacheDir), host-agent-real, Makefile; the repo-root catalog/ dir deleted and the bake steps in dev/cloud/stage-control-plane.sh + dev/test-qemu/bootstrap.sh removed. The air-gapped QEMU lane pre-seeds the last-good cache with a whoami snapshot generated by the new dev/test-qemu/mkcatalog (reused in remote.go's loadCache path) instead of a stub server. No Ed25519 signing — TLS to the CP + the integrity digest are the trust story (owner call, DECISIONS.md 2026-07-02); dropped, not deferred. Tested: digest reproduction against a pinned real dist/catalog.json, verify refusals, projections/env-filtering, last-good fallback, tamper refusal, asset cache-hit, 304; api/lifecycle/brainlaunch/brain green; bash -n + generator-vs-wire.go verified. Boot-acceptance of the QEMU/cloud lanes deferred to make deploy-image + redeploy (root+KVM, not in the normal loop), as with every sibling dev/cloud entry done
catalog-segmented-store-ui.md — Move the box's Vue store UI off "load the whole catalog, filter client-side" onto the control plane's segmented browse model (cloud #63, staged after catalog-remote-thin-client.md). Box-only, no cloud change: the box already holds the whole snapshot locally, so it mirrors the CP's segmented shapes on the brain and serves them from that snapshot (not by re-proxying each hit upstream, which would break step 3's offline browse). internal/catalog: new Home{Categories,Featured} + Category{Category,Apps,Featured} (mirrors of cloud catalog.Home/Category) and Catalog.Home()/Category(cat)/Search(q) on the facade — Home = sorted category union + featured row, Category = case-insensitive (containsFold) List filter (ErrNotFound ⇒ clean 404), Search = name+tagline+categories substring (blank ⇒ empty). The one input not derivable from List — featured — is a new private source.featured(): remoteSource reads Featured/Rank off the snapshot (env-filtered, ascending rank), diskSource (test-only) returns nil. Flat List/Entry/Detail/Load untouched, so internal/api+internal/lifecycle are unchanged. internal/api: GET /api/v1/catalog/home, /catalog/category?name=, /catalog/search?q= (huma), served from the local snapshot. home/search are literal segments (win over /catalog/{id}); category takes ?name= not /category/{cat} because a path /catalog/category/{cat} panics at registration against the existing /catalog/{id}/install-plan under net/http mux precedence (both match /catalog/category/install-plan, neither more specific — confirmed empirically) — a brain↔box-UI path, not the box↔cloud contract, so free to reshape. OpenAPI + TS client regenerated. web-ui/StoreView.vue: three mutually exclusive useQuery views — landing (pills + featured, no full grid), category (that category + featured), debounced search (results only); search > category > landing, pills clear search and vice versa; pill taxonomy from /catalog/home. Tested: segmented_test.go (union/featured-rank/env-filter/fold/404/blank/empty-store) + catalog_segmented_test.go (3 endpoints e2e + auth-gated); go test catalog+api green, vue-tsc+vite build green. Live boot-acceptance rides the next make deploy-image + redeploy done
os-capabilities-manifest.md — Publish a machine-readable capabilities manifest (docs/dev/capabilities.yml + governing docs/specs/CAPABILITIES.md) so catalog curation stops depending on someone remembering which platform gaps have closed. A versioned list of shipped capabilities, each {id, since, ref, summary} where id is the gap-class tag from catalog-import-gaps.md reused verbatim — one vocabulary across the manifest, the ledger, and a curation record's blocker reference. Seeded with the seven implemented/resolved gap-classes (secret-injection, oneshot-job-restart #92, managed-mysql #108, managed-redis #159, service-user #147, smtp-relay #122, operator-env-config #264); since: "0.x" for all (no brain version cut yet — RELEASE_MANIFEST.md), ref carries provenance. Append-only discipline (a shipped capability never un-ships); a partially-closed gap-class lists only the capability that actually shipped (service-user, not the still-open userns-remap facet). The catalog-import-gaps.md triage step now adds the gap-class here + bumps version when an entry flips to implemented — the data edit that mechanically fires the downstream re-screen. Docs-only; no brain/UI code, one-directional (names capabilities in OS terms, not its off-repo consumer) done
install-open-flip-state-gate.mdCloses #297. Fix the app detail page's Install button flipping to "Open" ~1s in before the app is actually up (the #150 phase spinner never got to show). The brain creates the instance row in state installing and emits app.state_changed at the start of the install job (internal/lifecycle/lifecycle.go), so the dashboard's SSE listener refetches ["apps"] and the row lands within ~1s — but useInstall's householdInstance/ownPersonalInstance matched on manifest_id/scope/owner and ignored state, and AppDetailView rendered "Open" off mere presence (v-else-if="ownPersonalInstance"), unmounting the loading SplitButton. So the installing-state row instantly replaced the phase spinner with a dead "Open" link pointing at an app that wasn't running (AppTile already gates open on state === "running"; the detail page didn't). Fix (frontend-only, two files): installing also reads true while the caller-relevant row is state === "installing" (covers the SSE-before-mutation-resolves window + mid-install reload); the header "Open"/"Open shared app" links now require state !== 'installing' and the SplitButton shows while the own instance is still installing. Button now holds its live phase label for the whole download/start window and flips to "Open" only when the app is actually up. make check-web green done
wildcard-cert-serialize-acme-orders.md — Fix a concurrency bug where the hosted box got its base cert but never its *.<box-id> wildcard, so every app failed TLS while the dashboard worked. Caddy issues one cert per SAN, so EnsureWildcardTLS listing both subjects in one automation policy spawned two concurrent ACME orders both solving DNS-01 at the same _acme-challenge.<box-id> record against one acme-dns account, whose TXT store keeps only a few recent values, so a third write from either order (a retry/recheck) silently evicted the sibling's unvalidated value. Fix: serialize them — issue the wildcard alone first, waitForCert (a certReady seam; real impl dialCertReady dials the box's own :443 and checks the presented cert's SANs) until Caddy holds it, then append the base subject as its own policy, so only one order ever writes acme-dns. cmd/brain gives the now-sequential call its own 3-min budget. Corrects the prior hosted-wildcard-cert.md "single combined cert" claim. Hermetic tests assert the two-phase sequencing; real issuance proven on a real hosted box done
hosted-wildcard-cert-automate.md — Corrects the #300 root-cause misdiagnosis. Same symptom (hosted box gets its base cert but every app fails TLS with ERR_SSL_PROTOCOL_ERROR), real cause found by reading a failing box's Caddy log off disk via Hetzner rescue mode: Caddy never placed the wildcard order at all (zero *.<box-id> obtain attempts; only the base cert on disk). A Caddy automation.policies[] entry only says how to manage a matching name — it does not schedule issuance; Caddy obtains only names in certificates.automate or named by a route Host. The wildcard was in neither, so it sat idle while each app's exact host forced a per-app dns-01 order at the undelegated _acme-challenge.<slug>.<box-id> name (could not determine zone → forever). Fix: EnsureWildcardTLS names the wildcard in certificates.automate so Caddy proactively obtains it at the delegated apex _acme-challenge.<box-id>; the apex stays on Caddy's default issuer (tls-alpn-01), so only the wildcard touches acme-dns — one order, no race. Removed the now-purposeless #300 machinery (addBaseSubjectAfterWildcard/waitForCert/dialCertReady/tlsAddr/certReady seam). Unit test now guards the certificates.automate shape; make check green. Real-box acceptance is the follow-up done
reconcile-pending-recreate.md — Make the reconcile pass converge env-restamping drift on a running container (closes #268), the gap manifest-config-block.md (#264) documented and RebindMail shared. SetConfig/RebindMail are brain-commits-first: store + override/.env, then compose up -d a running instance. A failed compose up left a container that kept running on its old env — reconcile re-created an already-running container only on resource-limit drift, never env drift — so it stayed stale until the user retried (a fallen-over container or brain restart already converged via the "no containers" branch). Fix: a pending_recreate boolean on instances (column + idempotent migration + Instance.PendingRecreate + SetInstancePendingRecreate). New recreateRunning(ctx, inst) helper wraps the running-instance edit's compose up: on failure it marks the instance pending (the committed override/.env is the reconstructible intent), on success it clears the marker; SetConfig/RebindMail route through it. Reconcile's already-up branch now recreates when resource-limit policy drifted or the marker is set — one compose up -d converges both (env read at container-create) — and clears the marker on success, with the restore() rewind guarded to the resource-stanza patch while the marker (not a file rewind) makes the env recreate retryable; the "no containers" branch also clears the marker after a bring-up so a pending-and-fallen-over instance is satisfied without a redundant later recreate. Start also clears the marker on a successful recreate, so a Stop→Start cycle doesn't leave a stale marker for reconcile to redundantly retry (a self-review catch). Covers config + mail + any future env-restamping op in one place (not special-cased to config). APP_LIFECYCLE.md's reconcile drift list gained the pending-recreate case as a fourth bullet. Startup-pass cadence unchanged (no timer; converges on next brain start) — matches the issue's "self-healing-on-restart edge" framing and the spec's no-reconciler-loop stance. make check green done
hosted-grow-root-disk.md — Grow the hosted image's root filesystem to fill the whole provider disk on boot. The image bakes a fixed 8 GiB root (sparse raw stays small) but nothing grew it onto the far larger provider disk, so a box ran on ~8 GiB — docker image storage + the brain's SQLite store share that one volume, so a single app install can fill it and the brain's first store write 500s login (the box looks offline though it is up). Fix: a runtime repart.d definition (Type=root, GrowFileSystem=yes, no size cap) + a malmo-grow-root.service oneshot running systemd-repart --dry-run=no, ordered Before=docker.service host-agent.service and fail-closed (Requires= from both, via a docker drop-in and host-agent's unit) so the write-heavy services refuse to start on an un-grown root. Build-time root stays pinned at 8 GiB; the two repart definitions are deliberately separate. Lean set gains systemd-repart + libfdisk1 only (no libcryptsetup, cryptsetup cut preserved). Boot-proof asserts the tool is present and the unit reached active; real full-disk growth is a provider-box acceptance step. Known: the stock systemd-repart.service also runs the same config (harmless double execution — idempotent; consolidation deferred pending a live re-test), and a pre-existing unrelated /setup 503 boot-proof failure reproduces on clean main, tracked separately done
hosted-setup-boot-proof-race.md — Close the /setup 503 boot-proof failure hosted-grow-root-disk.md tracked separately, and confirm the wildcard-TLS/:443 path is green. The unseeded boot polled POST /api/v1/setup for 403 but broke on any of 403|503|409|200, so a transient 503 — Caddy answering "no ready /api upstream" in the first second after the control-plane stack comes up, before the brain's listener + dashboard route land — ended the loop and failed the proof, though the box is correct (the brain returns 403 unconditionally on hosted, no 503 path; the diag showed all four containers "Up <1–2 seconds"). Fix: break only on a definitive 403|409|200 and ride through 502|503, exactly as the /api/v1/me poll above already does; a genuinely stuck /setup still fails after the 30s window. The louder ":443 never binds" symptom was a broken-build artifact, not a live regression: the build-and-boot CI job (CI / Cloud image, publish=false) passed both the unseeded and seeded boots (seeded hard-asserts :443 bound + caddy: wildcard TLS configured), and every known real-box root cause (certificates.automate #301, seed-fetch keep-alive, static resolver) is already fixed in-tree — so no product change. Adds docs/dev/hosted-boot-proof.md (runbook: happy-path flow, brain-log milestones, symptom→where-to-look, how to run) and de-stales TESTING.md's /setup gate description (the as-built SSO/403, superseding the secret 401/200). Test-lane + docs only done
hosted-grow-root-fs.md — Corrects hosted-grow-root-disk.md's "verified on a real provider box" claim. A live acceptance run found the root partition grew to the full disk (systemd-repart extended it, GPT GROWFS bit set) but the ext4 filesystem inside it stayed at the original 8 GiB — the disk-full/500 failure mode was not actually closed. Found via the same Hetzner rescue mode technique as hosted-wildcard-cert-automate.md: booting the (still-running) probe box into rescue and reading its disk + persistent journal directly. Root cause: GrowFileSystem=yes only sets the GPT GROWFS attribute bit; actually growing the mounted root filesystem is normally systemd-growfs-root.service's job, which systemd-gpt-auto-generator wires up only when booted with root=gpt-auto — Hetzner (and cloud providers generally) boot with an explicit root=PARTUUID=... instead, so that generator, and the growfs service, never ran; zero mention of growfs anywhere in the boot journal. The existing boot-proof assertion couldn't have caught this: on the QEMU disk (fixed-size, no spare space) growing the filesystem is a no-op regardless of whether the growfs step runs. Fix: malmo-grow-root.service's ExecStart now calls systemd-growfs / (the exact call the generator-driven service would have made) after systemd-repart, in the same command rather than a separate ExecStartPost — a review catch found ExecStartPost runs even on systemd-repart's accepted 76/77 "nothing to grow" exits and has no visibility into which code fired, so it would fail the fail-closed unit on those harmless cases; the merged step only calls growfs when systemd-repart exits 0 and passes 76/77 through as success. cloud-assertions.sh also checks systemd-growfs is present in the lean image, and its pass message no longer claims real growth is verified by this lane. Live acceptance: re-provisioned onto a real box with the fix; root volume reported 79.99 GB total / 78.21 GB free (was 8.35 GB pre-fix) — filesystem grow confirmed on real hardware. ENVIRONMENT.md # Storage corrected to describe the grow as the two explicit steps it actually is done
hosted-cx-bios-boot.md — The hosted cloud image booted UEFI-only, so it bricked (Booting from Hard Disk hang) on Hetzner CX/Intel, which boots VMs under legacy BIOS, while CPX/AMD boots UEFI (#277). Made the image dual-firmware — Bootloader=systemd-boot (UEFI, unchanged) + BiosBootloader=grub with a 1 MiB BIOS Boot Partition (Type=21686148-…) and grub-common+grub-pc-bin (lean-guard lockfile updated); the boot-proof lane gained a legacy-BIOS (SeaBIOS, no OVMF) bios scenario — the gap that let this ship — now in the CI publish gate. Config validated locally with mkosi v26 (BIOS Bootloader: grub); full boot proof + real-CX box are CI/live done
hosted-forward-auth-verify.md — First slice of the hosted per-app access-restriction epic (#304), closes #305: the brain side of per-app forward_auth. Mints a second, lower-privilege cookie at every hosted session-establishment path — malmo_forward_auth, scoped Domain=<box-id>.malmo.network so the browser carries it to app subdomains, alongside the unchanged host-only malmo_session. Its value is a distinct random token on a new sessions.fa_token column (reverse-looked-up by GetSessionByForwardAuthToken, empty-token rejected), so replaying it as malmo_session never resolves to a dashboard session — the load-bearing "can't be upgraded to admin" property. New raw GET /_malmo/forward-auth/verify (internal/api/forwardauth.go, outside OpenAPI, public to the middleware, hosted-gated) validates the cookie via auth.ValidateForwardAuth (reuses Validate's lifetime/user checks via a shared resolveSession(sess, touch) but with touch=false — the per-request Caddy hook must not amplify last_seen_at writes), enforces owner-only v1 (BoxMetaOwnerUserID, fail-closed), and returns 200 + X-Malmo-User/X-Malmo-User-Id or 401. login/logout Set-Cookie became []string to emit both cookies on hosted (appliance byte-unchanged); ssoLanding mints it too; SetEnvironment wires ForwardAuthDomain. Caddy route wiring + strip, exposure state, dashboard toggle, e2e + default-flip are #306/#307/#308. make check green done
hosted-forward-auth-route.md — Second box-side slice of the hosted access epic (#304), closes #306, builds on #305: wires the box Caddy to the verify endpoint + cookie. New per-instance exposure column (restricted/public, DDL + idempotent ALTER defaulting existing rows to public, Create-normalizes empty→public, SetInstanceExposure). caddy.AddRoute now takes a RouteConfig{StripCookie, ForwardAuth}; forwardAuthHandler renders the native-JSON Caddyfile-forward_auth shape (reverse_proxy to the verify endpoint rewritten to GET, handle_response: 2xx copies X-Malmo-User/-Id + falls through, catch-all 302→box login) — the strip is on the app proxy so the subrequest still carries the cookie to the brain. One central buildRouteConfig (the safety boundary) reused at all three flip sites (install/Start/reconcile) so a restart never drops the gate; hosted always strips the whole Cookie header on both modes (public app leak closed), appliance stays a byte-for-byte plain reverse_proxy. SetExposure (lifecycle) persists + re-applies; PUT /api/v1/apps/{id}/exposure (owner-or-admin, hosted-only 404, audited app.exposure.set) is the toggle backend; InstanceDTO.exposure surfaced. Hosted default flipped to restricted now (not gated on #308) and the whole-header strip are two product calls logged in DECISIONS.md. profile.ForwardAuthVerifyPath is the shared verify-path const. Dashboard toggle is #307; real-Caddy e2e + cookie-leak probe is #308. make check green + openapi/TS client regenerated done
hosted-access-toggle.md — Third slice of the hosted access epic (#304), closes #307, builds on #306: the dashboard control, no new backend. Adds an Access section to the installed-app detail page (InstalledAppDetailSection.vue, /settings/apps/<id>) — a segmented Only me / Public toggle in a card matching the page's other management sections, with a live-effect subtitle. Gated to isHosted() && canControl: hidden on the appliance (no public app subdomains; the endpoint 404s there) and for non-controllers (the existing owner-or-admin gate; brain re-checks via authorizeAppMutation). The mutation PUTs { exposure } to /api/v1/apps/{id}/exposure, which echoes the updated instance synchronously (no job), so onSettled: invalidate refreshes detail + list; buttons disable in-flight, failures surface inline. Exposure = Instance["exposure"] derives the literal union from the generated schema (#306 declares the huma enum), so the two values live in one place. Real-Caddy e2e + cookie-leak probe is #308. make check-web green done
hosted-access-e2e.md — Final slice of the hosted access epic (#304), closes #308, builds on #305/#306/#307: proves both access modes end-to-end through real Caddy in a booted hosted box + the cookie-leak probe of the whole-header strip, the coverage #306 shipped its restricted default ahead of. The air-gapped box-only cloud lane held no portal key, so it couldn't mint the owner session every per-app assertion needs (to install an app and to prove the restricted click-through). The harness now plays a test portal — generates a throwaway Ed25519 keypair, seeds the box with the public key, mints a valid owner assertion with the private key (which never enters the VM) — reproducing the real trust model with zero brain changes (the brain already verifies real assertions + installs for an authenticated owner). New dev/cloud/mkassertion Go tool (prints the seed pubkey + signed token; main_test.go round-trips through the real assertion.Verify on the exact standard-base64 wire path). New access boot in run-cloud-tests.sh on its own fresh overlay/box-id (independent of the A/B chain), seeded with the test-portal key (seed_cred_keyed) + the token over a second SMBIOS credential (malmo.sso_token); in the default full run and in the CI / Cloud image boot gate (unseeded seeded bios access) — unlike frozen, because the per-app gate is on by default and this is its only real-Caddy net. cloud-assertions.sh access scenario: drives GET /_malmo/sso once (jti single-use) capturing malmo_session + malmo_forward_auth, installs whoami air-gapped, then asserts on :80 — restricted+fa-cookie ⇒ proxied through (200, X-Malmo-User forwarded, no Cookie at the upstream), restricted+no-session ⇒ 302 to the box login, public (after the exposure toggle) ⇒ reachable no-session, public+fa-cookie ⇒ still stripped (a throwaway extra cookie in both probes proves a whole-header strip). Test-lane-only fixtures (dev/cloud/test/mkosi.extra, never the lean image): the whoami image tar, a mkcatalog catalog snapshot from a minimal folder-less hosted whoami package, and an offline-install host-agent drop-in. TESTING.md # Hosted cloud variant updated to document the access boot + test portal (a deliberate, scoped extension of the box-only lane's prior no-portal boundary; the real-portal round-trip stays the cloud on-ramp's job). Go plumbing + bash parsing unit-tested in make check / against synthetic responses; the full boot runs in the local make test-cloud-qemu default set and in the CI / Cloud image boot gate (unseeded seeded bios access), so a cookie leak fails the gate and never publishes. Multi-user access remains the epic's deferred follow-up done
door1-pull-promised-digest.mdFixes #331. A Door-1 install failed in the field (catalog digest mismatch for nextcloud:34.0.1-apache) after Docker Hub rebuilt the tag to absorb base-image updates: same version, same Dockerfile, 21 of 22 layers new. Root cause was a spec contradiction the code picked the wrong side of — APP_STORE.md # Trust model says the box pulls by digest and an upstream tag move "doesn't affect it", while APP_LIFECYCLE.md said the brain refuses to install when the post-docker pull digest differs from the promise; resolveImages implemented the latter, so every routine upstream rebuild became a failed install. Now the promised digest is the address: pullAndResolve pulls repo@sha256:… and never consults the tag, the mismatch comparison is gone (a content-addressed pull cannot return other bytes — nothing left to compare) and ImageInspect is off the Door-1 path entirely. Door-2 / TOFU (empty Images map) still resolves via the tag; the offline path is behaviorally unchanged and now agrees with the online one instead of diverging. One narrow failure added: a compose pinned name@sha256:… disagreeing with the images promise for the same ref — the catalog contradicting itself, a curation bug with no safe pick. Side effect worth naming: installs of one catalog version are now byte-identical across boxes, where before the bytes depended on when you installed. TestInstallDoor1IgnoresUpstreamTagMove is the regression test (verified to fail against the pre-fix code with the exact production error); TestInstallDigestMismatchRollsBack deleted as unreachable; fakeDocker.pullErrAll added because the offline tests keyed "registry unreachable" by tag, which would have let a digest-ref pull succeed against a registry the test says is gone. APP_LIFECYCLE.md # Locked: image digest pinning rewritten to match APP_STORE.md, plus a DECISIONS.md entry (2026-07-16): the code matched the 2026-06-16 (#167) entry exactly ("a box with a registry is unchanged: it pulls and verifies as before"), so this flips a locked decision and supersedes that clause done
pin-ref-canonical-tag-digest.mdFixes #333 (found reviewing #332; pre-existing, not a regression from it). repoOf returned early at the @, so a ref carrying both a tag and a digest kept the tag and the constructed pin came out name:tag@sha256:… instead of the canonical name@sha256:… that APP_LIFECYCLE.md # Locked: image digest pinning states every install pins to. Cosmetic, not a break — that ref is valid and the digest still decides the bytes, so the override stayed byte-deterministic — but it restates the tag in the one artifact where #331's reasoning says it least belongs (the digest is the address; the tag is a label). Reachable only from a hand-authored Door-2 compose that writes both forms; catalog-built manifests don't emit that shape, which is why it went unnoticed. repoOf now strips the digest then falls through to the existing tag branch (port-colon rule unchanged, now running on the string it was always meant to see). TestRepoOf gains the two tag+digest cases that were the gap, and new pinning_test.go / TestInstallTagAndDigestPinsCanonicalRef asserts the shape where the spec states it — a full install writing the pin into compose.override.yml. Both verified to fail against the pre-fix code with the exact strings #333 describes. make check green done
hosted-cookie-strip-per-cookie.mdCloses #335. Hosted app routes deleted the whole Cookie header (#306), which also deleted the app's own session cookies: a third-party app with a browser login was unusable on hosted, and unusable in a way that reads as a credential bug — Nextcloud renders its login page, is handed correct credentials, and rejects them, because with no Cookie reaching it, it mints a fresh session every request so the login POST arrives with no session and no CSRF binding. This is the follow-up the #306 decision named for itself ("per-cookie surgical stripping is the follow-up if a real app needs its own cookies"); the app turned out to be the common case (Nextcloud, Gitea, Memos, Actual Budget, Calibre-web), not an edge one. RouteConfig.StripCookie boolStripCookieName string sourced from auth.ForwardAuthCookieName (a rename can't silently stop the strip and leak the token); AddRoute emits headers.request.replace on Cookie via stripCookieReplacements — `(^ ;\s*)=[^;]then^;\sto clean the leading separator, nameregexp.QuoteMeta'd. The **anchor is load-bearing**: the obvious unanchored form silently mangles any cookie whose name merely *contains* the forward-auth name (evil_malmo_forward_authevil_). Correctness now lives in a regex, so the StripCookie boolassertion was replaced by a **behavioural adversarial table** driving the *emitted* config (duplicate names both orders, prefixed/suffixed, token-only, empty value), **mutation-checked** to fail against both an unanchored regex and a non-global replace. Verified against **real Caddy carrying the emitted JSON** in front of a stocknextcloud:34.0.1-apache: two sequential GET /loginreturn the **same** session id (different every request before), the token never reaches the upstream, and app cookies arrive byte-for-byte. Appliance byte-for-byte unchanged; the forward-auth subrequest still carries the cookie to the brain. **Accepted cost:** apps can now tossDomain=cookies at sibling apps (inherent to subdomain-per-app hosting; the whole-header strip blocked it only by leaving no app working).ENVIRONMENT.md# Public-by-default updated on three points incl. correcting the "standard oauth2-proxy / Authelia shape" claim (those strip their *own* cookie and pass the rest), plus aDECISIONS.md` entry (2026-07-16) narrowing the first shape call of the #306 entry
hosted-cookie-strip-e2e-assertion-fix.md — Follow-up to hosted-cookie-strip-per-cookie.md (#335). Merging that PR broke the CI / Cloud image boot gate (MALMO_CLOUD_ASSERTIONS: FAIL: access: COOKIE LEAK): dev/cloud/cloud-assertions.sh's access scenario still asserted the superseded whole-header-strip invariant (no Cookie header at all reaching the app upstream), while the merged code now deliberately lets an app's own cookies through and strips only malmo_forward_auth, so the probe cookie the script itself sends (probe=leakcheck) tripped the old blanket check. Both the restricted and public probes now assert the narrower pair: malmo_forward_auth absent from the Cookie header reaching the app, and probe=leakcheck present — so the gate still fails on a real leak and no longer fails on the by-design pass-through done
repo-version-and-compat-range.md — Phase 2 of the release-flow work (Phase 1: CI gating dev->main + the cloud-image tag trigger, dev-tooling-only, no progress entry). Two decision flips: one repo VERSION for the whole monorepo, not independent per-component SemVer, and the image inherits that SemVer, dropping the planned CalVer. New internal/version (dumb — two vars + String()) is stamped via -ldflags -X into cmd/brain, cmd/host-agent-real, and cmd/host-agent (Makefile + cmd/brain/Dockerfile, the latter via a --build-arg since .git is excluded from the Docker context); each gained a --version flag, verified against the actually-built binaries (malmo 0.4.0 (g<sha>)). internal/hostagent.AgentVersion (previously the hardcoded "0.0.1-fake") now derives from the same stamped version.Version, deliberately with no -fake suffix — the handler it feeds is shared, unfinished code compiled into both the fake and the real binary alike. The version-mismatch health detector moved off exact-equality to a compatible range: cmd/brain's expectedAgentVersion constant is replaced by minimumAgentVersion, and only an agent older than the floor raises (UPDATES.md # 7's minimum_host_agent model; a newer agent mid-rollout is normal per # 1's update ordering). Comparison via golang.org/x/mod/semver (already resolved in the module graph, no new supply-chain entry), core-only (prerelease/build suffix stripped) so a dev build's own suffix can't self-flag as a mismatch. New GET /api/v1/system/version on the existing system.go home. make check and make check-web both green done
hosted-agent-build-stamp.md — Follow-up to repo-version-and-compat-range.md; the first bug found on a real box running a tagged release. A v0.4.0 Hetzner box raised version-mismatch and blocked app installs while otherwise healthy: dev/cloud/stage-control-plane.sh built the hosted host-agent with a bare go build and no -ldflags — the only unstamped build in the cloud-image path (brain/UI go through Docker, which reads cat VERSION itself) — so it shipped internal/version's "dev" default, which semver can't parse and therefore sorts before every release. The >= comparison was never at fault (a stamped 0.4.0 satisfies a 0.4.0 minimum by equality); the bug was entirely in the build. stage_build_go now applies the Makefile's LDFLAGS (recomputed, not inherited — bootstrap.sh runs under sudo -E, so make variables never reach it; git runs as CALLER to dodge dubious-ownership, commit degrading to unknown like the Makefile's). Boot-proof step 1c asserts the baked binary reports malmo X.Y.Z (g<sha>) — verified to fail on the real unstamped v0.4.0 binary, the only place this is observable (a go test binary is unstamped by construction; the release tag-assert checks the file, not the binary). No spec change: BUILD.md # Versioning already required the stamp. v0.4.0 images cannot self-heal — needs a VERSION bump to 0.4.1 and a re-provision done
release-cloud-image-asset.mdCI / Cloud image now attaches the built hosted cloud image to the tagged GitHub Release, not just Hetzner. New step, gated on the identical SHOULD_PUBLISH condition as the existing Hetzner snapshot publish, reuses the .raw.xz that step already compresses (new raw_xz step output — no double compression), names it malmo-vX.Y.Z-amd64.raw.xz + a .sha256 sidecar per BUILD.md # 6's per-release naming scheme (.raw.xz since this lane emits .raw, not the doc's planned .qcow2), resolves the release by v$(cat VERSION) with create-if-missing, and uploads via gh release upload --clobber (idempotent re-runs). New root-level permissions: contents: write; no new secret — uses the job's own github.token. Purely additive: the Hetzner publish path and HCLOUD_TOKEN are untouched, both now run on every real publish. BUILD.md # 6 gained an as-built bullet documenting the divergence from the doc's .qcow2 naming. Verified via YAML parse only; a real tagged release is the next end-to-end proof done
drop-cloud-image-hetzner-publish.md — Removes the provider-snapshot publish from CI / Cloud image so a release emits one artifact, the GitHub Release asset, not two. Deleted the Set up Go/Install hcloud-upload-image/Publish image ... + resolve snapshot id steps (the last held the only provider credential); folded the .raw discovery + single xz -T0 compression the release-asset step used to borrow from them into that step itself (malmo-vX.Y.Z-amd64.raw.xz + .sha256, gate/tag-resolution/clobber logic unchanged); dropped the dangling image_id output and scrubbed the header/input-description/secret notes that named a hosting provider. Runs on the job's own github.token only now. YAML-parse verified; a real tagged release is the end-to-end proof done