Skip to content

Latest commit

 

History

History

progress

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

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. A/B OS update (#486). ab-os-update-design.md designed it; control-plane-version-line.md (#559) and os-package-lock.md (#560) built the version split and the package lock. hosted-ab-layout.md (#561) built the hosted image in the A/B layout with the state inventory, and rauc-bundle.md (#562) a signed RAUC bundle per OS release. Before the next OS release the maintainer sets up the offline root CA and the os-release signer (../dev/rauc-signing.md); until then release.yml tags nothing for a VERSION bump. host-agent-os-update.md (#563) made host-agent apply OS updates on hosted: install into the other slot, switch in the window, trial boot, revert. uefi-grub-try.md (#575) fixed the boot lane's OVMF so GRUB's try flag holds under UEFI, and proves a slot that panics before userspace is skipped. bake-released-control-plane.md (#566) made an OS-only release bake the last released control plane from ghcr; the released 0.15.0 control plane is older than #563, so the next OS release must also bump CONTROL_PLANE_VERSION (or follow a control-plane release). Next: the maintainer adds the os part to the control plane's update-target answer (the exact wire is in that entry); until then no production box moves its OS. Then #486's proof on a provisioned box, and #564 (appliance).
  2. Persistent journal. container-logs-journald-driver.md made container logs reach journald, but neither image creates /var/log/journal or sets Storage=persistent, so the journal is volatile and app-log scrollback is lost on every reboot, while LOGGING.md # Per-app logs promises "scrollback up to journald's cap". Ship the journald drop-in from LOGGING.md # Tuning (Storage=persistent, SystemMaxUse=1G, RuntimeMaxUse=128M) on both images, sized against the partition /var/log lives on (on hosted, the state partition that grows at first boot, since #561). Also restores the size-based backpressure # Tuning assumes now that docker's per-unit rate limit is off.
  3. 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.
  4. Tag the store manifests with role and requires. ai-bindings-after-install.md finished the os side of the install setup plan, but no real app shows the LLM provider pickers until its manifest in onmoose/store carries roles (APP_MANIFEST.md # D4 # Roles and requires, docs/dev/authoring-apps-with-an-agent.md). The last step of INSTALL_SETUP.md.
  5. Try the install setup work on a running box. ai-bindings-after-install.md and install-setup-doc-gaps.md: the settings screen, the LLM providers screen, a key change and a user delete against a running brain with a real app, in a browser.
  6. Finish the install steps. install-steps.md built steps 1 to 3 of INSTALL_STEPS.md. Left: a real send with the Gmail and iCloud presets, a test of the built flow with 3 to 5 non-technical people, then step 4 (recommends) and tagging the store apps.

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.md — GET /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.md — malmo 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-gen → api.OpenAPIDocument, committed api/openapi.{json,yaml}) + make openapi-check freshness gate (CI), and a generated TS client (openapi-typescript → web-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.md — clock-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.md — ram-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.md — malmo 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.md — reboot-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.md — service_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.md — malmo 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; UsersView→UsersSection, ActivityView→ActivitySection (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.local → 172.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.local → 192.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 FakeGPUReporter — cmd/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 0o2000—os.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.md — malmo-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-bookworm → debian: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 build → caddy: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=guess→SizeMinBytes=8G for the docker-load headroom (CANARY v17→v18). 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_compose→resolving_digests→compose_up→waiting_healthy→flipping_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 owns — Manager.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 route — caddy.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 v18→v19, 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-qemu — medium-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 /login → 200 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 bundle — bootstrap.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 v20→v21 (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 PASSED — sudo 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.md — Finding (#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-server — not 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 build → assert 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
cloud-vm-boot-proof.md — Hosted cloud VM image: emit + boot the control plane (C2 of #196, closes #205; after C1a #202 / C1b #203 / C1c #204 and C3a #206). The first real build of the lean cloud image: it is emitted, converted to the malmo-vX.Y.Z-amd64.qcow2 cloud artifact (BUILD.md # 6), and booted once in QEMU under OVMF UEFI — no swtpm, no LUKS, no installer (ENVIRONMENT.md # Provisioning: "the disk IS the installed system"). New dev/cloud/test/ boot-proof image Include=s the lean dev/cloud/mkosi.conf rather than copying it, so what boots is the lean image plus instrumentation, never a divergent rebuild; dev/cloud/run-cloud-tests.sh boots it air-gapped (restrict=on, so a stray pull hard-fails and the offline bundle is proven complete) with snapshot=on and serial capture; a first-boot oneshot writes `MALMO_CLOUD_ASSERTIONS: PASS FAILto the **serial console** (hosted ships no sshd) and the driver greps it. Assertions: userspace converged with no failed control-plane unit, PSI live (pairs with C1b'spsi=1), systemd-networkdactive + NetworkManager absent, docker active with the four baked images loaded, the four control-plane containers running, the proxy boundary (brain has no rawdocker.sock), the dashboard answering through Caddy, and the hosted /setupgate returning **503**. **Integration bug this caught:** the brain container mounts only the agent socket dir +/var/lib/malmo, neither covering /etc/malmo— soprofile.Readinside the container always returnedApplianceand hosted/setupwould have run **open**, the exact public-by-default hole C3a's gate exists to close (C3a's unit tests overrideMALMO_PROFILE_PATH, so they could not see it). Fixed host-agent-side: brainlaunch.Config.ProfileMarkerPath bind-mounts the marker read-only at the same path, set only when it exists as a regular file. Two lean-package gaps also surfaced on the first real build (passwd, systemd-boot-efi— both arrived transitively via packages the hosted image cuts).make test-cloud-qemu` wired. Seed delivery (C3), the trimmed wizard (C4) and the end-to-end (C5) stay out of scope — C2 asserts the brain serves, pre-setup
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
cloud-seed-delivery.md — Hosted first-boot seed delivery + /setup gate end-to-end (closes #220; completes the deferred half of C3a #206, hosted-setup-gate.md, on the C2 harness cloud-vm-boot-proof.md). C3a built the brain-side seed ingestion and the admin-bootstrap gate and unit-tested it, but nothing ever delivered a seed.json onto a booted VM, so the gate had never run end-to-end. No brain/Go change — the brain already reads /var/lib/malmo/seed.json on hosted, and brainlaunch.runSpec already binds /var/lib/malmo into the brain container, so a seed written to that host path before host-agent launches the brain is what the containerized brain reads. New dev/cloud/test/malmo-seed.service (first-boot oneshot, Before=host-agent.service, ImportCredential=malmo.seed) + malmo-seed-materialize.sh (install -m 0600 root:root; no-op when nothing was delivered, so an un-seeded boot stays unprovisioned). run-cloud-tests.sh refactored into a run_boot <phase> <mode> [seed-cred…] helper driving a 3-boot sequence over one persisted qcow2 overlay (box-id + first admin survive boot→boot; the base artifact stays pristine), the seed delivered as an SMBIOS type-11 systemd credential (binary/base64 so the JSON is comma- and newline-safe). The three boots: un-seeded ⇒ /setup 503; seeded ⇒ wrong secret 401 + correct secret 200 with box_id=cindy-fox; frozen-identity reboot ⇒ a re-delivered different seed (rusty-hawk) is ignored and /login still reports cindy-fox (the materializer deliberately overwrites, so that assertion is non-vacuous — the identity lives in SQLite, not the file). Note the delivery channel is a QEMU-lane mechanism: a real box is seeded once as metadata user-data at VM creation (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.SetTimezoneRequest → hostclient.SetTimezone → hostagent.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 (toDTO → https://<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 image — bootstrap.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 PASSED — sudo -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-image → lean 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 linux → linux && !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.fields → 422 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_secret → assertion_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.md — Closes #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.md — Fixes #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.md — Fixes #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.md — Closes #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 bool → StripCookieName 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_auth→evil_). 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.md — CI / 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
managed-postgres-17-18.md — Closes #354. Managed Postgres offered only 15/16 and the catalog hit the first app that needs neither: uptimepage's migrations use uuidv7(), an 18 built-in, as a column DEFAULT. Adds 17 and 18 to serviceVersions, keeps 15 accepted but deprecated in the docs (no cross-version migration path exists yet, so removing it would strand a box already on it), and pins PGDATA: /var/lib/postgresql/data in postgresServiceCompose because the image default moved in 18 (/var/lib/postgresql/18/docker). Two of the issue's premises were corrected against real containers: the unpinned 18 bind does not silently eat data — the image guards it and refuses to start (there appears to be PostgreSQL data in: /var/lib/postgresql/data (unused mount/volume)), surfacing as a failed readiness wait and a rolled-back install; and the proposed parent-directory bind (/var/lib/postgresql) does not work at all, since the 18 entrypoint drops to uid 999 before creating $PGDATA and cannot mkdir inside the 0700 dir writeServiceDir creates. Pinning needs no version branch and keeps one on-disk layout across every major. New TestLivePostgres18Persistence (dockerlive) writes a uuidv7()-defaulted row, destroys the container (a docker restart keeps the container filesystem and would pass against a broken bind) and reconciles it back through the brain's own boot path before asserting the row survived — mutation-checked to fail without the pin. Extensions decided and documented, not implemented: new SERVICE_PROVISIONING.md # Database extensions states that trusted contrib extensions already work (the per-app role owns its database), untrusted contrib is superuser-only and unavailable, and third-party (pgvector, postgis) is a different image and therefore a future managed type, not a version done
trusted-proxy-client-ip.md — Closes #329. The per-IP rate-limit key was caller-controlled: clientIP returned the first X-Forwarded-For hop, the one the original client writes, so a rotating forged header minted a fresh bucket per request and both per-IP throttles — POST /login's 10/min bucket and plane 2 of the request limiter — stopped throttling the adversary they exist for. clientIP moves to internal/api/clientip.go and becomes trust-aware: X-Forwarded-For is read only when the immediate peer is a configured trusted proxy, and then the chain is walked right to left to the last untrusted hop; an unparseable hop stops the walk (fail closed to the peer), hops are canonicalised so one client can't spell itself three ways, and repeated header lines are flattened. MALMO_TRUSTED_PROXIES configures the set — unset ⇒ loopback + Docker's bridge pool (172.16.0.0/12), empty ⇒ trust nothing, unparseable ⇒ fatal at startup rather than a silent widening. The default deliberately excludes 10/8 and 192.168/16: a trusted hop is skipped during the walk, so trusting the ranges LAN clients live in collapsed every household device onto Caddy's address and one shared bucket — caught by review, reproduced, and now pinned by a test against the shipped default. Caddy's half: EnsureServer plus both bootstrap caddy.json files now declare an empty trusted_proxies (POST, because Caddy answers PUT on an existing key with 409 — verified live). Tests drive the real middleware chain with N forged headers from one peer on both the allowlist plane and the login path, and assert the mirror case that two LAN clients behind a trusted Caddy keep separate buckets. The issue's premise was corrected against a real caddy 2.11.4: stock Caddy with no trusted_proxies already replaces a forged chain with the peer address (it appends only for a trusted peer), so the smuggling path needed a peer that reaches the brain without traversing Caddy; the clientIP bug is real regardless, and pinning Caddy's undeclared default is why both halves shipped done
store-curated-landing.md — The box's app store landing carried none of the authored home.yml (spotlight app + category groups) that the control plane's own store surface already rendered — just pills and a flat Featured strip. Carries the block onto the box: internal/catalog/wire.go mirrors the control plane's HomePage/HomeGroup shape (byte-faithful, confirmed to not affect indexDigest since that hashes Apps only); catalog.Home gains Spotlight/Groups (new HomeGroupView); remote.go projects both filtered to the box's environment exactly like featured() — an unadvertised spotlight goes nil, an emptied group is dropped — mirroring the control plane's own landing-page projection; the disk source (tests only) returns neither. web-ui/src/lib/storeLayout.ts ports the control plane's row-packing algorithm (packRows, groups packed two-or-more to a row) plus its written-out (not interpolated) Tailwind span/cols classes, and a categoryLabel display stopgap (hyphens to spaces) so group headings, the category-page heading, and the pills agree with each other and with the control plane's own labeling on the one hyphenated category id (developer-tools); new StoreSpotlight.vue banner component; StoreAppCard.vue gained the tagline line and a wider icon; every store grid widened sm:grid-cols-4 lg:grid-cols-6 → sm:grid-cols-3 lg:grid-cols-4. Fallback chain preserved: authored home → flat Featured → "pick a category or search". dev/mkcatalog (a third, standalone mirror of the wire shape) gained the home block, a repeatable -pkg flag, and a -home flag that fatals on an unseeded app id — make seed-catalog takes APPS="<id> ..." + HOMEFILE=<path> alongside the existing single-app APP=<id>. Verified end-to-end against a real running brain (not just go test): seeded 17 real store apps + a real curated home.yml, and GET /api/v1/catalog/home returned the spotlight and all seven groups. Two review follow-ups on the same PR: the curated Featured row no longer leaks onto a category view (landing-only, matching the control plane's own rendering; catalog.Category keeps Featured on the wire for parity, the UI just stops rendering it, doc comment corrected); dropped the "all" pill — pills are real categories only, the landing sentinel is "recommended" (checked against the catalog's category ids: no collision), clicking the active pill toggles back to the landing, "Back to all" reworded to "Back to recommended". Both verified in a headless-Chrome/Playwright browser session against the real seeded stack (screenshots: landing with no Featured heading and no All pill, a category view with no Featured/Spotlight, toggling the pill back to the landing, and a search-while-a-pill-is-active transition). New docs/specs/APP_STORE.md # Landing page section. make check and make check-web both green done
catalog-wire-guards.md — Follow-up to store-curated-landing.md, which showed the box had no guard against a new top-level field on the published catalog snapshot reaching it and being silently dropped: TestVerifyRealSnapshot's digest check covers only the apps array, and the pinned fixture was already stale (missing both home and os_capabilities_version). New TestNoUnmodeledFields (internal/catalog/wire_test.go) parses the fixture into a generic map[string]any and fails on any top-level or per-app key (plus nested footprint/author/links/images) that the box's wire types don't declare a json tag for — the known-key set is derived by reflection, not hand-listed. os_capabilities_version is the one deliberate omission, recorded in ignoredTopLevelKeys with a reason (publish-side provenance stamp, no box-side meaning); every other field in the refreshed fixture, including home, was already modeled. testdata/snapshot.json refreshed from the current published snapshot in the same change, so the guard isn't vacuous. Also deleted the third hand-maintained copy of the wire shape: dev/mkcatalog used to re-declare catalogFile/app/wireHomePage/wireHomeGroup byte-for-byte and had already drifted once (left behind home, caught only by review). internal/catalog now exports SnapshotApp/SnapshotHome/SnapshotHomeGroup (type aliases of the wire types, not copies) plus BuildSnapshot(apps, home, storeRef) ([]byte, error), which stamps the schema version, computes the index digest, and marshals; dev/mkcatalog builds []catalog.SnapshotApp and calls it instead of re-declaring the shape. CLI surface unchanged. Verified against real data: seeded 17 real store apps + the real home.yml through both go run ./dev/mkcatalog directly and make seed-catalog, and confirmed the output passes the box's own parseSnapshot (the exact function remote.go's loadCache calls at boot) — the same proof repeated for the QEMU-lane single-pkg-no-home form. make check and make check-web both green done
catalog-category-labels.md — Render the authored category label instead of inventing one from the id, closing the gap store-curated-landing.md recorded and catalog-wire-guards.md left open. An app's categories: holds ids, so something must turn developer-tools into a heading — and every surface did it itself, so they disagreed ("Developer-tools" on one store, "developer tools" on the other). The storeLayout.ts stopgap made the two agree without making either correct, and ai is the case that proves derivation cannot work: no rule over the id yields "AI". Now the snapshot carries a categories vocabulary ([{id,label}], authored order, outside the index digest like home) and the box renders the label on pills, landing row headings, the category page, and the app detail info panel. The facade's source gains categories() (remote reads the snapshot, disk returns nil); Home.Categories goes []string→[]Category; the view type Category becomes CategoryPage since Category is now the authored datum. Pills follow authored order, not the sorted union — the vocabulary is a curation artifact like home.yml. Every capitalize class on a category is gone: authored "Developer tools" must not render as "Developer Tools". dev/mkcatalog -categories + make seed-catalog picking up categories.yml automatically, so a local seed shows real labels. Two fallbacks kept (a category missing from the vocabulary still gets a pill — dropping it would hide a browsable app — and a never-synced/disk box labels everything readably), written to match the control plane's so even the degraded modes agree done — verified against a real running brain: 17 seeded apps, admin bootstrapped, /catalog/home returns all 7 pills in authored order with authored labels and all 7 rows labelled, /catalog/category?name=ai returns "AI". Not clicked through in a browser
manifest-external-costs.md — Adds an optional external_costs: block to the manifest schema: money a third party charges to make an app useful (OpenClaw's model-provider API key, a mail provider an email app sends through). Until now that lived only as prose in description.long, so no surface could show it as a fact or put it beside the install button and the user found out when the bill arrived. {id, title, description, required, estimate, estimate_checked}, display metadata only — the brain never acts on it and it never gates install: an app that will not boot without a paid thing is a blocks-start curation verdict, and routing that through a display field would hide a real admission problem behind a nicer-looking one. What malmo charges is deliberately not in this schema — that is a commercial decision which changes without the app changing, so it lives with the curation that owns listed/environments (store status.yml price:); neither cost is a limitation, since a limitation is a broken feature and paying for something is not a defect. estimate is a unit rate, never a monthly total (a total depends on how one person uses the app), and omitting it is explicitly correct — without that, an author under pressure to fill the field invents a number, the one error estimate_checked cannot catch because it looks like a real one. required is a *bool with no default and estimate/estimate_checked are required together, both so this gate and the curation source's own Python validator agree field for field; the 100-char cap counts runes, not bytes, for the same reason. Absent block stays a no-op, so every existing manifest parses unchanged. Not on the catalog wire yet, so no box renders it — the verbatim manifest does reach a box and re-parses, but the store view is built from the wire struct, so surfacing it is the coordinated cloud + os change that also moves the index digest done
catalog-wire-external-costs.md — Closes the known gap manifest-external-costs.md left open: an app could declare what a third party charges to make it useful, but no box could see it, because the box's store view is built from the published display subset and not from the verbatim manifest bytes (which are the install payload). wireApp gains ExternalCosts, reusing manifest.ExternalCost rather than re-declaring it — that reuse is what reproduces the control plane's marshalling exactly. Position is load-bearing: it sits between Footprint and IconFile because the cloud's App declares it there, and the index digest is a SHA-256 over json.Marshal of the app array, so a mirror that placed it elsewhere would parse fine, pass every field-level assertion, and fail only on the digest against every real snapshot. Detail carries it, Entry does not — a cost is a paragraph of reading, so it belongs where someone decides to install, not on a grid card; both the remote and disk sources project it. The pinned wire fixture was refreshed so TestNoUnmodeledFields does not go blind, which also picked up the navidrome record store had added and cloud had not republished. Verified end to end against real data: a real external_costs block added to store's apps/openclaw/manifest.yml, run through the actual sync tool (35 apps published), and the resulting dist/catalog.json fed to this box's own parseSnapshot — it verified, so the digest reproduced with the new field present, and detailOfApp returned the cost verbatim; store and dist both reverted after. Coordinated with the cloud PR that publishes the field. No UI yet — how a cost card looks beside the install button is a design question, not a wire question done
catalog-per-app-field-rule.md — Follow-up to catalog-wire-external-costs.md; no behaviour change, it records a rule APP_STORE.md had backwards. The spec said the publish side can add a catalog field without waiting for deployed boxes because encoding/json drops unknown keys, and that "the integrity digest cannot catch this — index_sha256 covers the app index (apps) only". True for a field outside the app array (home); exactly backwards inside one. verify() recomputes the digest by re-marshalling what it parsed, so a box that does not model a per-app field drops the key, cannot reproduce the digest, and rejects the whole snapshot — then serves its last-good cache: not visibly broken, just frozen, with one log line that reads like corruption rather than a shape change. Measured, not theorised: a box at e825e9f fed a real published snapshot where one app declared external_costs returned catalog index digest mismatch, while the same snapshot verified on a box that models the field. The section is now split into the two cases by what happens (dropped, safely vs rejected, and it takes the catalog with it), with the release ordering spelled out — model on the box, cut a release, let the fleet take it, publish the data last; authoring data is safe any time, the danger starts when the sync tool carries it, which is automatic on the next publish. Bumping the schema version is the diagnosis, not the fix: it turns the digest mismatch into catalog schema version 1, want 2, but an older box refuses either way, and because the check is exact equality it must be bumped with the data — raising it early would reject every snapshot on every box immediately, causing the outage rather than avoiding it. TestUnknownFieldAsymmetry makes the doc claim executable (per-app key rejected, top-level key accepted), and the PR template now asks the question. The tolerant redesign (digest over the raw apps bytes, which would retire the whole class) is deliberately left out — it changes the box↔cloud integrity contract and would not help any box already deployed done
ghcr-control-plane-images.md — First implementation slice of the update work designed in #369 (UPDATES.md # 8). Stream B had nowhere to pull from: the brain and UI existed only as docker save tarballs baked inside the disk image, so a running box could not obtain a newer control plane at all. ci-cloud-image.yml now pushes both to ghcr on release — gated on the existing SHOULD_PUBLISH, placed after the seeded-boot proof (so we can only publish a control plane proven to boot), and tagging the same local images the build already baked rather than rebuilding them, so registry and disk image are byte-identical. Tags vX.Y.Z + latest, carries the Release-asset step's tag-points-elsewhere guard so a workflow_dispatch publish cannot clobber a shipped release's images, and writes pushed digests to the job summary. Uses the job's own GITHUB_TOKEN (packages: write) — no long-lived registry credential. Corrects BUILD.md # 6's claim that the Release asset was the only published image artifact. Gaps: the packages are private until someone with admin flips them (the workflow cannot), and the step is verified only against stubbed docker/curl across three cases — the first real release is the acceptance.
system-version-whole-box.md — Second slice of the update work, after ghcr-control-plane-images.md. GET /api/v1/system/version already existed (the slice was planned as if it didn't — the earlier survey grepped only raw mux.HandleFunc routes and missed every huma.Register one, 12 seen of 48 real), but it answered "what is this binary": the brain's version + commit. It now answers "what is this box", gaining host_agent_version (from the host round trip the brain already made for the version-mismatch health issue, never returned to a caller) and ui_image (the ref pinned for malmo-ui in the staged control-plane compose, via a new lifecycle.ControlPlaneUIImage). Additive per BRAIN_UI_PROTOCOL.md # Versioning — version/commit keep meaning and stay required. Reading the compose rather than asking Docker is the load-bearing call: it is the declaration the brain reconciles to and the file the updater rewrites (UPDATES.md # 8.3), so reported version and converged version are one fact. The endpoint degrades rather than 502s (unlike /system/storage): both new fields are omitempty and absent means "could not read it", never "not installed", so a dead host-agent never costs the caller the brain version an updater needs first. BRAIN_UI_PROTOCOL.md documents the endpoint for the first time. Gaps: ui_image is a ref not a semver (reads malmo-ui:dev until the updater writes real refs); interpolated \${VAR} refs error rather than resolve; no dashboard surface; no VM boot, so the staged-dir path is proven against a temp dir.
brain-healthz-probe.md — Third slice of the update work, after system-version-whole-box.md. UPDATES.md # 3 step 3d has host-agent waiting up to 60s for /healthz on a recreated brain and reverting both control-plane images if it never answers — and the endpoint did not exist; the brain served only /api/v1/* behind session auth. GET /healthz now answers 200 ok, outside /api/v1 (no versioning promise, absent from api/openapi.yaml and the TS client), public (host-agent drives the box and has no session; Caddy proxies only /api/* + /_malmo/*, so the path is not LAN-routable anyway), and exempt from plane 2's 30 req/min/IP throttle — the updater polls once a second for up to 60s, so without the exemption the throttle would 429 at the halfway mark, the updater would read that as "the new brain is unhealthy", and the rate limiter would revert a working update. Liveness, not readiness, is the whole design: the handler checks nothing — not SQLite, not Docker, not Caddy, not host-agent — because its only consumer reverts on failure, so a dependency check would roll a healthy new brain back to an old brain facing the same sick dependency (GET /api/v1/health is where that question already lives). BRAIN_UI_PROTOCOL.md gains an # API discipline paragraph, the plane-2 exemption, and a locked bullet, addressing the "no internal routes outside /api/v1/" rule head-on: that rule bans routes the dashboard uses that external callers cannot, and the dashboard never calls this one. The exemption test is mutation-checked (it 429s at request 30 without it) and the handler test builds the server with every dependency nil, so a future dependency check panics rather than silently changing what the updater's commit signal means. Gaps: no consumer until #380, not wired as a Docker healthcheck:, no VM boot
control-plane-image-ledger.md — Fourth slice of the update work, after brain-healthz-probe.md; the declaration half of the updater, with no Docker in it. New internal/hostagent/controlplane: RewriteUIImage points the staged compose's malmo-ui service at a new ref (the UPDATES.md # 8.3 handoff — written before anything is recreated, so the brain reconciles to the running version instead of reverting it), plus a ledger (images.json) holding the pair the box should run, the pair it ran before, and when each was applied. The spec said one file; the box needs two, and finding that is the point of the slice: # 8.3 read as though host-agent writes both refs into the compose, but the brain is not in that compose and cannot be — a process cannot bring itself up, so brainlaunch starts it with docker run from MALMO_BRAIN_IMAGE. Without the second declaration an applied update is one docker rm away from undoing itself, since brainlaunch only picks an image when there is no container to leave alone, and would pick the one baked at build time. cmd/host-agent-real now resolves the brain ref through the ledger and logs which source won. The rewrite edits one line rather than round-tripping the YAML — that file is hand-authored and heavily commented, and a marshal would delete all of it on the first update — and the scan is service-scoped by indentation, because image: under caddy comes first and a naive scan would repoint the reverse proxy at the dashboard bundle. Both files are written atomically (temp, fsync, rename, fsync the dir): both are read at boot to decide what the box runs. Every read failure falls back to the baked ref rather than refusing to launch, because the brain is how anyone finds out what is wrong. Tests run against the committed dev/control-plane/compose.yml, so a service rename or a switch to interpolation breaks CI instead of an update on a real box. Gaps: nothing writes the ledger until #380; PreviousExpired answers the 7-day question but deletes nothing; cmd/host-agent-real cannot be compiled on this machine (pre-existing cgo PAM issue), so its one-line wiring is CI-proven; no box has run it
control-plane-update-transaction.md — Fifth slice of the update work, after control-plane-image-ledger.md. New internal/hostagent/cpupdate: the stream-B apply and rollback UPDATES.md # 3 specs and # 8 shares between both profiles. Trigger-free by design — nothing in it knows whether the target came from a signed manifest, the cloud, or an admin typing two refs. Pull every moved ref (nothing has changed yet, so failing is free) → if the brain moved, stop it and then snapshot its SQLite (the brain runs journal_mode=WAL, so a copy taken under a live writer restores into a corrupt database — a safety net that only fails when used) → write the declaration, ledger and compose, before starting anything (# 8.3) → recreate only what moved → health-check both → on any failure revert both refs, restore the snapshot, and report which step broke. Both refs revert even when one moved: a coordinated ship is tested as a pair, so half of one is a combination nobody has run. The brain is recreated from brainlaunch.RunSpecFor, not a second docker run builder — a second builder drifts the first time a mount is added, the box boots fine, and the divergence appears only after an update. Any HTTP response counts as serving, 500 included: the question is "did it bind its port", and reverting over a 500 takes a working box backwards. A test caught a real ordering subtlety in this code — the first assertion counted any container call and failed, because the brain is removed before the declaration is written; that is correct (the snapshot needs it stopped, and a crash there leaves the ledger naming the old pair), so the assertion now watches container starts, which is the claim the spec actually makes. Gaps: nothing calls Apply until #381; proven against a fake Docker only (#382 is the booted-box proof); the probe reaches containers by IP since they publish no ports; no retry or three-strikes pin — that belongs with the trigger
control-plane-update-trigger.md — Closes #381. The trigger the transaction was waiting for: POST /v1/jobs/system-update on the host socket (the first job ever exposed there) plus the admin-only POST /api/v1/system/update that starts it and GET /api/v1/system/update/{job_id} that polls it. The target is two explicit image refs in the request — no release manifest, no cloud call, because the box↔cloud credential is Tier-1 undesigned (NEXT.md) and the updater is shippable without it. The job surface is the minimum, not Pattern B's framework: an in-memory record, a 30-minute MaxDuration, and one global lock — Dangerous: true realized for a single kind. No kind registry, no resource classes, no cancel, no SSE log; a second job kind (enroll-drive) is what would make us generalize, and BRAIN_HOST_PROTOCOL.md now says so plainly instead of claiming a framework that does not exist. A second update is refused with 409, not queued — the same op arriving twice is a double click. The run gets its own context, not the request's: the caller is the brain, which this update may be about to replace, so an inherited context would let the brain's own recreate cancel the transaction recreating it. Past MaxDuration the record says failed + job-timeout rather than the spec's stalled — the transaction rolls back, so the outcome is known. The job id is host-agent's, deliberately not wrapped in a brain-side job, because a brain-side record dies halfway through the operation it tracks. New elevation-class audit action system.update, recorded on the start and on the 403 / 409 / 502 (validation 422s are exempt per CLAUDE.md); success=true means "it started", not "it worked" — the brain cannot see how an update that replaces the brain ended. Also fixes a real bug in the merged transaction: Apply's snapshot-failure branch put the old brain container back on the caller's context, unlike every other recovery path, and this slice is what made that dangerous — it is the first caller, and it passes a cancellable, time-bounded context to a Docker that honours it. A MaxDuration expiring mid-snapshot would have left the box with no brain container at all. Both recovery paths now share one detached(ctx) helper, because the drift between them was the bug. Gaps: nothing picks the refs (no manifest, no poll, no notification); no dashboard surface; no retry or three-strikes pin; job records are in memory with no "last update" read after a restart; no elevation re-prompt; still no real-box proof (#382) done
cloud-update-boot-proof.md — Closes #382. The four updater slices above were all proven against a fake Docker; this adds an update boot to the hosted cloud lane so the path meets a real daemon, a real registry, a real brain restart and a real revert — in CI on a stock runner, not on a maintainer's machine, and inside the publish gate. The registry is real, not simulated: the guest starts one on 127.0.0.1:5000, derives a new brain + UI with docker commit from the images the box is running, pushes both, deletes both locally and asserts they are gone, so the updater's docker pull <ref>@sha256:… must fetch them back (docker load + retag would have skipped the one step production always takes). Happy path asserts the thing that had never once run — host-agent recreating the brain while the brain serves the request that asked for it: the container id must change, the running brain must carry a marker label only the new image has, both files of the # 8.3 declaration must name the new pair, /healthz must answer on the new container, and system/version must report the new UI ref. Revert asserts the half that only matters after something already went wrong: an update to a brain that starts, truncates the SQLite database, leaves a marker file and never serves ⇒ job failed with failure_mode: health, reverted: true, no revert_error, marker present (so the bad brain really ran and the proof is not vacuous), ledger back on the good ref and not naming the failed one, malmo.db a valid SQLite file again, and the box answering an authenticated request on the same session. Test-only scaffolding stays out of the published image (registry tarball in dev/cloud/test/mkosi.extra, and out of control-plane-images/ whose loader globs *.tar). It found a real bug on its first run, and the spec's stated order was what was wrong: cpupdate.recreate started the new brain and then ran ComposeUp, but the brain runs docker compose up -d on that same project at every startup — so two composes interleaved compose's rename-and-replace dance, collided on the backup name, and left the box with no malmo-ui container at all, after which the updater's own health check reverted a change that was otherwise fine. Fixed by recreating UI first, brain last on the apply and the revert path (same order, same race — the drift trap that already bit this package once), which removes the concurrency instead of narrowing it: the compose runs while no brain exists, and the brain then boots into a converged stack whose reconcile is a no-op. UPDATES.md # 3 step 3c said "brain first (if changed), then UI" and is corrected here — # 8.3 makes the two actors agree on what to run and says nothing about when. No fake-Docker test could see it (there is no second actor in a unit test); two new tests pin the order on both paths, mutation-checked by restoring the old order. Also recorded from that red run: the revert put the box back after a genuinely damaged intermediate state, and the whole failure was diagnosable from the serial log alone. Gaps: the new images are commit-derived, so no real migration, no version-stamp change and no protocol-major refusal; only the health failure mode is exercised; the 7-day GC never runs; plain-HTTP localhost registry only done
about-box-versions.md — Closes #393. The first dashboard surface for the update work: Settings → About reads GET /api/v1/system/version (#375), which every updater slice since has listed as a gap. Two levels on purpose — the box version sits on the front of the card ("malmo 0.6.0", the one fact a non-technical owner reads out when asking for help), and commit + host-agent version + UI image hide behind a closed disclosure for whoever is reading a support thread. Each part degrades on its own: the endpoint answers 200 with a field missing when it could not read that source, so a missing part renders "Unknown" and never blanks the card; only a failed request shows an error. Checked on a running box, where ui_image is genuinely absent (dev has no staged control-plane compose), which is the partial answer the card has to handle. No update affordance on purpose — nothing discovers a new version yet, so a button could only say "nothing to do". Gaps: no box name (the endpoint reports none); ui_image is a raw ref, not a version; no durable last-update outcome; no component test (vue-tsc + build only) done
release-manifest-read.md — Closes #395. The appliance side of "what should this box run": new internal/hostagent/relmanifest verifies, parses, decides and caches the signed release manifest (RELEASE_MANIFEST.md), with no network and no timer in it — the poll, the prompt and the three-strikes pin are later slices, and each is easier with this part pure. The minisign verifier takes both algorithm modes (Ed raw, ED prehashed — minisign's own default is the second) and checks both signatures in the file: the second one covers the signature plus the trusted comment, so skipping it would let anyone rewrite that comment on a genuinely signed file and hand the caller attacker-chosen text that looks authenticated. Keys are a list from day one (# Signing builds rotation on it; one constant turns a lost key into a synchronized fleet update), and zero keys refuses everything — the shipping state today, since no signing key exists yet, so the appliance updater is inert rather than credulous. Decide answers four states, not two: the spec's "valid and current, but host-agent too old ⇒ no prompt" is kept distinct from "not for you", because that box is healthy and waiting on apt. The kill switch is checked before the host-agent gate — a box on a retracted version should be offered the way back even when it is behind, since the gate exists to stop it moving forward. An unparseable version fails closed. The cache holds the manifest and its signature in one file, because two files means two renames and a power cut between them leaves the new manifest beside the old signature with the previous good manifest already overwritten — no usable cache at all, which is not the "previous valid manifest stays in effect" the spec promises (found in review). It stores the publisher's raw bytes (a re-marshal drops ignored unknown fields and can never verify again) and Load re-verifies, so the local file system is not a way around the signature. Five mutation checks, each by editing and restoring. Gaps: nothing calls it yet; no box has a key, so it is proven against test-generated keys only; no interop test against the real minisign binary; version → image digest still undecided and the ghcr packages are still private done
release-manifest-poll.md — Closes #397. The manifest reader gets a clock and a network, so an appliance box discovers a release on its own — the first thing in the update design that needs no human to type anything. Poll fetches stable.json + .minisig, verifies, parses, caches; Run polls once immediately then on a jittered hourly tick (not in the spec: boxes cluster after a provider window or a regional power cut, and an exact hour boundary turns that into a spike on a static file host). A failed poll changes nothing on disk — unreachable host, HTTP 500, truncated body, tampered manifest, foreign key, wrong schema all leave the cache exactly as it was (seven table cases, each caching a good manifest first). Verify before cache, not after, or anyone who can answer an HTTP request could replace what an offline box acts on. Keys are stamped at build time like internal/version, empty by default, and there is no env override on purpose: changing which releases a box accepts should take a new apt-signed binary, not an edit to a unit file. A build with no key does not poll at all — inert, and it reads in the journal as "this build trusts nobody" rather than "no releases published". Appliance-only wiring (a no-op twin for hosted, whose target comes from the cloud), started last so a hanging CDN sits in front of nothing. A mutation check caught a bad test: the oversized-body case served junk, which fails verification anyway and proved nothing about the 64 KiB cap; it now serves a real signed manifest padded past the limit and asserts the error names the size, because without the check the poll still fails — as "signature does not verify", sending a reader hunting a key problem that does not exist. Gaps: nothing consumes State() yet and Decide is still uncalled; no signing key and no releases host, so it is proven against a test CDN only; no three-strikes pin, no 24-hour signature-failure warning, no VM proof done
update-target-source.md — Closes #401. The box learns which control plane to run. One Source seam (internal/hostagent/updatetarget) with an implementation per profile — hosted reads the cloud's public update-target URL, appliance reads the signed release manifest — and one loop consuming it: compare, validate, hold for the window, apply. The box never resolves a tag: the answer carries full pinned references resolved once by the sender, and Validate refuses an unpinned or truncated digest, an answer naming only one of the two images (they move as one transaction, so a half-answer must never make a half-apply), an unexpected repository, or a carried digest that contradicts its own reference — logged loudly, nothing pulled, box unchanged. Unreachable and "no target" are different and both are no-ops (a 404 is "nothing published", not a failure), so a box never degrades because it could not ask. Hosted applies without a prompt but inside 03:00–04:00 local, which is why the poll is 15-minutely — an hourly tick can step over an hour-wide window — with one attempt per window per version, since a failed update has already reverted the box and would otherwise be retried every quarter hour. The apply goes through Agent.StartUpdate, factored out of the HTTP handler, so a target-driven update and an admin-triggered one share the single job lock. Appliance learns but never applies (# 3 keeps it admin-prompted). Verified against the live control plane (the real v0.6.0 answer parses and validates) and in the booted-box lane, which now refuses a tagged answer and then applies a pinned one with no admin in the loop. Gaps: the appliance source can only refuse — the manifest names versions, and closing that is a schema decision parked in NEXT.md; no report-back, no per-box targeting (both need the box↔cloud credential); no dashboard surface done
update-target-per-box.md: Closes #404. A provisioned box can now be pointed at its own update target. host-agent.service imports two optional systemd credentials on the same path malmo.seed uses: malmo.update_target_url and malmo.update_window. Precedence is credential > env var > built-in default, so a box provisioned with neither behaves exactly as before, while one carrying a credential is pinned to it. An unusable target URL is refused and the loop does not start rather than falling back to the fleet endpoint, so a box pinned to a candidate cannot quietly join stable; an unusable window still falls back with a warning, because a wrong hour cannot send a box to a wrong version. Which source won is logged at startup, at warn when it is not the fleet default. None of the resolution is build-tagged, because CI vets and tests untagged, so only the call site keeps //go:build hosted. Also writes down the downgrade property (UPDATES.md # 8.4): the loop has no notion of "forward", so a box newer than its target rolls back to it done
update-target-from-seed.md — Closes #407. #404's per-box update target shipped as two ImportCredential= lines and never reached a real box: a hosted box gets its per-box facts as metadata user-data, which does not feed systemd's credential store, so the mechanism worked in the QEMU lane and was inert everywhere else. The target URL moves to the provisioning seed — the only production channel for a per-box fact, and write-once, which fits a fact fixed for the life of the box ("which control plane does this box belong to"). Precedence becomes seed > env var > compiled default; from logs seed where it logged credential. host-agent reads the seed itself, not through the brain's profile.ReadSeed, which hard-errors on a missing box_id — a box seeded only for updates, and a box with no seed at all, must both work. Three seed states, decided on purpose: absent falls through and is not an error (the appliance path), a readable seed is used when the field is set, and a malformed one is refused with the loop not starting, because bytes we cannot parse might have pinned this box and reading stable instead is the one outcome pinning exists to prevent. The credential path is deleted, not kept alongside — two mechanisms for one setting, one of which silently does nothing in production, is worse than either. The window does not move to the seed and will not: it has to be changeable while a box runs and user-data cannot be rewritten, so it waits for the control plane's answer. The cloud lane's update boot now carries the URL in its seed rather than an Environment= drop-in, and asserts from=seed before the apply — without that, the apply passes on a box that read its target from anywhere at all, which is exactly how the credential version stayed green. Gaps: the boot-proof edit is unexercised locally (needs root + KVM, CI-only); nothing cloud-side sets the field yet, so this ships inert; an empty field is treated as absent, not as a refusal done
update-target-per-box-and-window.md — Closes #408. A hosted box now says which box is asking: GET <target-url>?box_id=<id>, with the id read off the same seed pass as the target URL (#407) rather than a second file read. That is what makes a per-box answer possible at all — UPDATES.md # 8.1 specified a target per box_id from the start, and until now the answer was the same for every box. A box with no identity sends no parameter at all, not an empty one: an appliance box and an unseeded hosted box ask byte-for-byte what they asked before. The answer also gains an optional window field, so when to update joins what to run as a fact the control plane owns and can change while a box runs — the home the seed could never be, because user-data is write-once. Precedence is answer > MALMO_UPDATE_WINDOW > built-in default, and an answer with no window field means "no opinion", never "use the default", or a silent field would outrank an operator's variable. An unusable window warns and falls back to the box's own setting (not to the default), keeping #404's asymmetry: a wrong hour only applies at the wrong time, a wrong target sends the box to the wrong version. The window is resolved per tick, deduped with memory separate from the target lines, and logged with from=answer — a fourth value for that field. The update boot now inverts its window setup — MALMO_UPDATE_WINDOW=04:00-04:01 against a whole-day window in the answer — so the apply can only pass if the answer won, and asserts the box_id and from=answer before it. The identity is deliberately weak and written down, not fixed: a bare box_id on an unauthenticated endpoint can be read and claimed by anyone who knows it, accepted only because the ask is a read, with the trade-off in UPDATES.md # 8.1 and the real credential a new NEXT.md item. Nothing that mutates state may be built on it. Gaps: the boot-proof edit is unexercised locally (CI-only lane); nothing control-plane-side reads the parameter or sends a window yet, so it ships inert; the box id is not validated done
container-logs-journald-driver.md — The dashboard's per-app Logs tab was empty on every real box, hosted and appliance, for every app, since the tail shipped: it opened the stream and sat on "Waiting for log output…" forever. Not a code bug — a missing image file. LOGGING.md calls the Docker log-driver switch "the single biggest configuration decision" and host-agent-real is built straight on it (internal/hostagent/journalsource runs journalctl CONTAINER_NAME=<container> -f, and CONTAINER_NAME is a field only the journald driver sets), but no image ever wrote /etc/docker/daemon.json, so both real profiles ran Docker's json-file default, the match returned nothing, and journalctl -f blocked on a stream that could never produce a line. Two blind spots hid it, and both are the same mistake: the inner loop's fake host-agent reads docker logs -f (cmd/host-agent/dockerlogsource.go) and the cloud boot proof greps the brain through docker logs — both work on any driver, so nothing ever exercised the query the feature depends on; cloud-assertions.sh even documents the daemon as being on json-file. Ships daemon.json on the hosted image (committed, so the boot-proof image inherits it via Include=..) and byte-identically into the appliance lane's generated tree, with CANARY_VERSION bumped so the medium lane rebuilds. Rate limiting follows the spec rather than the obvious fix: journald counts against _SYSTEMD_UNIT, so routing every container through dockerd puts them all in one bucket where a single chatty container silently starves the rest — disabled per-unit on docker.service (LOGGING.md # Tuning), not raised globally, so sshd brute-force spam still caps. New boot-proof assertion 5b asserts the driver and that journalctl CONTAINER_NAME=malmo-brain returns lines, deliberately not through docker logs, since reading it that way is exactly why this shipped. Gaps: the journal is still volatile — neither image sets Storage=persistent or creates /var/log/journal, so scrollback dies on reboot and SystemMaxUse, which # Tuning assumes is the backpressure once docker's rate limit is off, is unset; the appliance change is unbooted (medium lane is local-only, needs root + KVM); the tab still follows main_service only, so sidecar output stays unreachable done
path-scoped-app-exposure.md — Closes #415. Hosted exposure was whole-app and binary, which has no answer for an app that pairs a token-authed API with a session-authed UI: letting an external SDK reach the API meant making the app public, which dropped the box login in front of the UI too. Two catalog apps already hit that wall, and one of them (Laminar) signs in any email with no password on its self-hosted UI, so "go public to ingest" read as "anyone can sign in as anyone". A manifest may now declare access.public_paths, and a restricted app's route becomes one Caddy subroute — declared paths straight to the app, everything else gated exactly as before — so upsertRoute's insert-at-0 and the catch-all order are untouched, and the proxy handler is built once and used on both branches so the #335 per-cookie strip cannot land on one and not the other. The scrub grew past the issue's shape and that is the load-bearing part: the identity headers are now deleted at the head of every hosted app route, not just where the gate runs, because the gate does not run on a public path — the app would otherwise get a brain-vouched X-Malmo-User on one path and a client-forged one on another with no way to tell them apart — and the same hole already existed for a fully public app, where an app that learned to trust the header while restricted kept trusting it the moment the owner flipped the toggle. Validation refuses /, /*, /** (a manifest must not void the owner's toggle), a bare-suffix wildcard (/v1* also matches /v1admin), and %/?/#/\////.. (the proxy matches a cleaned decoded path while the app sees the original URI, so those mean two different things on the two sides) — but the spec now says plainly that catalog review is the trust boundary, not this validator. The dashboard names the open paths instead of claiming a bare "Only me", reading them from the instance's manifest copy so the label cannot drift from the route, and carrying them on both responses that report an app's exposure (the detail GET and the toggle's echo) rather than only the first — the pair is what the label is built from. The hosted lane's access boot carries the proof through real Caddy, including a bypass table (prefix footgun, traversal, encoded traversal, double slash, case variant) that must stay gated. The first CI run paid for itself twice: //v1/ never reaches the gate — Caddy collapses the duplicate slash and 301's to the normalized path before matching, so the probe was safe and the expectation was wrong (the table now asserts the claim that matters: an undeclared path never reaches the app upstream anonymously) — and that failure came back green, because the lane matched its verdict with a *PASS* glob and the reason read "PATH GATE BYPASS". The harness now anchors on the exact verdict string; any failure text containing bypass/password would have silently greened any boot in this lane, so that one was latent well beyond this PR. Gaps: no catalog manifest declares the field yet, so it ships inert; the UPDATES.md clause making a widened public_paths prompt the owner is specified, not built; anonymous public-path traffic is invisible to Activity and has no rate limit in front of it done
catalog-no-box-side-copy.md — The box no longer keeps the catalog on disk. loadCache / the write-through in syncOnce are gone: the brain holds one snapshot in memory, re-fetches it, and shows an empty store when it cannot reach the endpoint, rather than browsing a last-good copy it could not install from anyway (a pinned old snapshot can also offer a manifest the store no longer publishes — DECISIONS.md 2026-08-17). RemoteOptions.CacheDir became AssetCacheDir and now means what it says. The three dev/test lanes that booted from a pre-seeded cache (make dev-app, both QEMU bootstraps) pass the snapshot explicitly as the new MALMO_CATALOG_FILE — read once, never written, unset on a real box. Icons and screenshots keep their disk cache but gained a 24-hour TTL: filenames are stable per app, so without one the first icon a box fetched was the icon it served forever; an expired asset whose refetch fails is served stale rather than broken. The pinned wire fixture is now synthetic — three hand-written fake apps covering the full key surface, replacing a copy of the published catalog — with a -update flag that re-stamps its digest. TestNoUnmodeledFields is honest about what that costs it: it can no longer detect that the published shape moved, which becomes a publish-side check. make test-nopam, fmt-check, vet, openapi-check green done
store-detail-costs-and-screenshots.md — The store detail page now shows what a third party charges and what the app looks like. external_costs reached the box API in catalog-wire-external-costs.md and nothing rendered it, so a person learned about listmonk's mail bill after installing; a pricing panel above Information now shows a Price row plus each cost as a disclosure, required ones open (the one you must read to decide) and optional ones collapsed, with the rate estimate and the manifest prose. The "you pay the provider, not malmo" note is a CSS-only tooltip (group-hover / group-focus-within), so it works for pointer, keyboard and tap with no script. Price stays a constant in the view, not a wire field — every app is free today and what malmo charges is authored in the curation source — so there is one line to change when it does reach the wire. The screenshot strip, which was inert, now opens each shot fit to the viewport with arrow keys, prev/next and a live counter, using a native <dialog> rather than the fixed-overlay idiom AppMenuDialog hand-writes: the browser supplies the top layer, backdrop, Esc, focus trap and focus restore, leaving an index and a src swap. Both halves are ported from the marketing store's app page in ../cloud, so the two surfaces show one catalog the same way — minus its progressive-enhancement half, which a Vue view cannot use. Gaps: typechecked and built but not yet clicked in a browser; web-ui has no test runner; the install dialog still does not repeat a required cost done
mail-provider-presets.md — Closes #426. Adding an email account meant typing seven fields the admin had to look up in their provider's docs, four of which are constants for any provider worth presetting. Settings → Outgoing email is now a two-step add: pick the provider, then supply only the credential, the from address, a username where the provider does not fix one, and a region for the two providers whose region changes the host — with host / port / encryption prefilled behind an Advanced settings disclosure, so a non-standard endpoint is never trapped, and custom rendering as that disclosure open with nothing prefilled (the old form). Nine presets in a new leaf package internal/mailpreset, served admin-only at GET /api/v1/mail-presets, every one STARTTLS on a port a hosted box can reach (587, except SMTP2GO's 2525) — hosted blocks 25 and 465, so no preset may default to implicit TLS. Two of the issue's constants were wrong and one changed the design: Mailgun's EU host is smtp.eu.mailgun.org, a prefix rather than a region code, so the planned single {region} host template could not serve both it and SES — each region option now names the host it resolves to, one mechanism with no substitution; and Brevo's username is the SMTP login xxx@smtp-brevo.com, not the account email, which is the kind of detail that turns into a failed test-send. mail_providers.provider_type persists which preset was picked (DEFAULT 'custom' via the idempotent ALTER path, no CHECK — one cannot ride an ALTER, so it is validated in Go like scope and exposure); pre-preset rows load as custom, which is right, they were typed by hand. It is kept for the credential broker, not for the form: without it every provider registered before the broker ships is an untyped row someone must classify by pattern-matching hostnames. The server stores what the client sends and never re-derives host / port / encryption from the preset, because re-deriving would silently undo the advanced override — so provider_type records what the admin picked, not a guarantee the values still match. The test-send now names the blocked port on hosted (a live bug for anyone who already registered a 465 provider there) and only on a connect failure, since an auth rejection means the port was reachable. internal/lifecycle/mail.go is untouched on purpose — mailEnvLines is the seam the broker replaces, and keeping it out is what makes the two features independent. Gaps: no live test-send was performed against any provider — that is the issue's real acceptance gate and needs a provisioned hosted box plus a real account at each of eight providers; the constants are verified against current vendor docs and nothing more. The SES region list is a common subset (8 of ~19); edit does not restore the region select (only the resolved host is stored); web-ui has no test runner; make check could not run end to end on this machine (msteinert/pam fails to build identically on unmodified dev) done
settings-nav-app-url-public-marker.md — Three small dashboard changes in one pass. The Settings left nav is regrouped: You is Account, Installed apps (it lists the apps this user has, not a box-wide setting), System is Users, Outgoing email, Notifications, Activity, About — Notifications moved down to sit directly above Activity, since both answer "what happened on this box". The per-app settings page now writes out the app's URL as a link: the Open button is the one-click path but it hides the address and only renders while the app runs, so there was no way to read, copy, or send an app's address from that page; it renders in every state, truncates rather than wrapping, and gets no copy button on purpose — navigator.clipboard is unavailable on the HTTP-only .local origin, so right-click-copy on a real anchor is the path that always works. Home tiles gained a globe in the bottom corner of the logo saying how open the app is, because the launcher could not say "anyone with this link can open this": a whole globe for exposure: public, the same globe at 30% opacity for an app that is restricted but declares access.public_paths (#415) and so is partly open, and no marker for an app that is closed end to end. Both are gated on isHosted(), since the appliance has no public app subdomains and reads "public" for every row. The globe is one PNG used as a mask filled with currentColor (the source is black line art, which would vanish on a dark tile), and one drawing carries both states so the partly open app reads as a fainter version of an open one rather than as a second symbol to learn; an eclipsed-globe version was built first and dropped, because at 16 px the crescent read as a smudge. The copy on both surfaces says that some paths are open, never which ones, and carries no em dashes. One brain change: GET /api/v1/apps now runs withPublicPaths too, because a list DTO carrying the exposure without the paths draws a fully closed app that is in fact partly open — one instance-manifest read per app, best-effort, and no schema change since the field was already on InstanceDTO. The Access card also lists the manifest's open paths as mono badges (Tailwind Plus elements/badges, palette on malmo's tokens), shown only while the app is Only me. The app's email-account picker stopped being a native <select>: it is a listbox showing each account's provider logo (Tailwind Plus forms/select-menus/05-custom-with-avatar, Headless UI primitives mapped to the reka-ui the project already ships), keyed on the provider_type the DTO already carried, so again no brain change; reka-ui reserves the empty string, so "None" rides a __none__ sentinel that turns back into "" on the wire. Button.vue gained an as prop (tag name or component) and it fixed three defects at once: Open was a hand-built pill on an <a> a size bigger than the buttons beside it, both Add account links wrapped a <Button> in a <RouterLink> (a button nested in an anchor: invalid HTML, two focus stops), and the add form's Cancel was a button that navigated (no middle-click, no hover target). Every other link under /settings was checked and is correct. Gaps: not clicked in a browser (web-ui has no test runner); HomeView's "Browse the Store" is the same hand-built-pill defect, left alone as out of scope; the marker overlays the logo square, safe for today's centered glyph icons but not for a future edge-to-edge one; the Installed apps list still marks nothing; the globe is an Icons8 asset whose free tier wants a visible link back, which the dashboard does not give it done
catalog-split-browse-and-install.md — Closes #434. The box pulled one 614KB bulk snapshot in which 77% was install payload for apps it would never install. Browse and install are now two fetches: GET /catalog?env=<environment> returns display records + landing + categories + an opaque version token, and an app's manifest / compose are fetched per app at install time by following the manifest_url / compose_url the record carries (application/yaml, verbatim). Load takes a context now. The index digest is gone — it was recomputed by re-marshalling the parsed app index, which made field order load-bearing and turned any new published field into a flag day (measured on external_costs); it did cache work, not security work, so version replaces it and unknown keys are dropped the way encoding/json drops them. schema_version stays as the one refusal. Load-bearing half: Load was never install-only, so the two callers that used it for already-installed apps (the detail page's mail picker, the rebind pre-check) now read life.InstanceManifest — the copy writeInstanceDir already persists — which keeps routine box operation off the catalog service and fixes an installed app's manifest vanishing when the app is unpublished. Asset filenames became opaque URLs (absolute ones on another origin work), so the asset cache name is derived from the URL and safeJoin is gone; environment filtering moved to the control plane (?env=), so visibleIn is gone. Dev seeds keep inlining manifest+compose as a documented seam (a staged file has no control plane to serve the document routes); mkcatalog lost -environments done
caddy-data-volume.md — Closes #433. The control-plane caddy service mounted only caddy.json, so Caddy's /data — the ACME account key and every issued certificate — sat in the container's writable layer and died with the container. Known since C3b and recorded as a footnote in hosted-caddy-image-bake.md; the footnote's mitigation ("the brain re-asserts EnsureWildcardTLS") is the cost, because a re-assert is a new Let's Encrypt order, not a renewal. /data is now the named volume malmo-caddy-data; /config becomes a tmpfs, matching malmo-ui — Caddy does write /config/caddy/autosave.json on every admin-API change, but nothing reads it back (the container loads caddy.json explicitly, never --resume), so it must be writable and is worth nothing across a recreate. Config is re-assertable by the brain on startup; a certificate is not. The rate-limit finding the issue asked for, and it is fleet-wide: Let's Encrypt allows 50 new certificates per registered domain per 7 days (plus 5 duplicates of an identical identifier set), "registered domain" resolves through the Public Suffix List, and malmo.network is not on the PSL (checked) — so every hosted box shares one budget. A cold box costs two certs (wildcard via acme-dns, apex via Caddy's default issuer), ARI renewal exemptions do not apply to a re-issuance with no prior cert, so a recreate loop on one box spends the pot a new customer's first boot needs. Measured against real Docker with the committed file: probe in /data survives up -d --force-recreate, stock no-volume caddy loses the same probe, /config tmpfs loses it by design. Also read from the code: after a Caddy-only recreate the box places no order at all until the brain restarts — Caddy comes back on the bootstrap caddy.json, which has no tls app and no :443. Lands alone, not paired with #187 as the old note assumed (that is brain-side network-reconnect work, unrelated to a volume); unblocks the read_only: true half of #431. Cloud lane gains seeded-boot check (c): /data is the named volume, and the mount is live — a file written inside malmo-caddy is read back from a separate container on the same volume. Both image canaries bumped. No deployed box re-issues from this: the staged compose is written at image build and the only runtime writer is RewriteUIImage's single malmo-ui image: line, so it reaches a box only when that box is created. Gaps: no real ACME anywhere (air-gapped lane, limits read from LE's published docs); the lane does not recreate malmo-caddy (it would drop the routes the earlier checks need); QEMU lanes not run here; the ENVIRONMENT.md "one combined cert" line, stale since DECISIONS.md 2026-07-08, is corrected here rather than in the #437 it was first filed as — this change's new bullet sits beside it and says the opposite, so the contradiction became this PR's to fix done
third-party-image-pins.md — Closes #432. The two third-party control-plane images (caddy:2-alpine, tecnativa/docker-socket-proxy:v0.4.2) and the hosted Caddy build's two upstream bases were all named by mutable tag, so two builds of the same malmo commit could hold different bytes with nothing recording which. Not a live-box risk — a box docker loads the baked tarballs and never pulls — but a build-reproducibility and supply-chain gap, and the opposite of what the catalog already does for every app image. All four are now digest-pinned in one checked-in file, dev/control-plane/images.lock, which the Makefile includes — every other builder, the cloud staging script included, goes through a make target. control-plane-images pulls by digest then re-tags to the plain tag before docker save, because the box looks the image up by tag in an offline compose file. The acmedns Dockerfile takes both bases as build args with no default (a default is a second copy of the pin, free to drift) and is built through a new make caddy-acmedns-image. A guard test fails if a pin loses its digest, or if a pinned tag stops matching the four places that name the image by tag. BUILD.md # 5c records where the digests live and how to bump one, with upstream Caddy security releases as the case that matters done
pin-remaining-build-inputs.md — Closes both gaps third-party-image-pins.md left in its own "What's next". The hosted Caddy's plugin was fetched by xcaddy with no version, so it could still change under two frozen base images; it is now pinned at caddy-dns/acmedns@v0.7.0, a version read out of the shipped binary (caddy build-info) rather than taken from upstream's latest, since pinning to newest would be a silent bump wearing the clothes of a pin. Caddy's own version was never loose — it comes from the pinned builder image. The three bases under malmo-brain and malmo-ui (golang, debian-slim, node-alpine) are digest-pinned too and passed in as default-less build args, the same rule the acmedns recipe follows; malmo-ui's runtime base reuses CADDY_IMAGE rather than adding a second pin, so the UI and the proxy always hold one Caddy. Two more guards: the module pin must carry a version, and every FROM in the three Dockerfiles must come from a build arg that is declared with no default and actually passed by a make target — checking only for ${ would accept ARG BASE=caddy:2-alpine, which interpolates identically. BUILD.md # 5c widens from images to build inputs and says plainly that this is pinning, not reproducibility — the brain still apt-gets docker-ce-cli from a live index done
control-plane-container-hardening.md — Closes #431. Every app container gets cap_drop: ALL + no-new-privileges written by the brain with no opt-out; the control plane's own containers had none of it — including the one that terminates TLS, holds the hosted wildcard's private key and carries an unauthenticated admin API. caddy and malmo-ui now run cap_drop: [ALL] + cap_add: [NET_BIND_SERVICE] (both are a Caddy binding :80, and that is the only capability either uses) + no-new-privileges + read_only: true — the read-only root became affordable when caddy-data-volume.md gave Caddy's two write paths somewhere to go. malmo-docker-proxy gets cap_drop: ALL + no-new-privileges in brainlaunch.proxyRunSpec (host-agent docker runs it, so it is not in the compose); it needs no capability back, since haproxy binds :2375. The dev Caddy in dev/docker-compose.yml matches, so the inner loop runs it the way a box does. Measured against real Docker with the committed compose: both containers inspect clean, the catch-all serves, a live admin-API route add reaches the dashboard bundle through the hardened proxy, and — the part the issue flagged as the risk — the certificate path still works under a read-only root: Caddy obtained a cert from an internal issuer and wrote the key, cert and local CA to the /data volume. Two exceptions, both recorded in CONTROL_PLANE.md # Locked: control-plane container hardening and THREAT_MODEL.md B2 rather than left silent: the brain stays unhardened because it chowns app data dirs to an elected uid, so CAP_CHOWN is load-bearing and the real set has to be named and proven on a booted box; the socket proxy keeps a writable root because read_only exits 1 against the real image (the entrypoint writes /tmp/haproxy.cfg, haproxy wants /run and /var/lib/haproxy) and three tmpfs mounts pinned to another project's internal paths would break silently on an image bump. The hosted boot proof gains step 5c: on a booted box docker inspect must show the sandbox on all three containers, in every boot mode, so a box that boots without it turns CI / Cloud image red (both image canaries bumped). A guard test parses the committed compose and fails if a service loses any of the four lines — for apps the brain enforces the sandbox in code, but the control plane declares it in hand-edited YAML, where a dropped line still starts the stack, just without the sandbox. Gaps: no QEMU lane run; hosted acmedns image not exercised; user: deliberately untouched; an existing box converts on its next host-agent start rather than instantly, since capabilities are fixed at container create time done
socket-proxy-escape-measured.md — Investigation half of #430. Measured the socket-proxy exposure on real Docker with the exact production allowlist and the post-#441 sandbox: from a curl-only container on the proxy's network, POST /containers/create with Privileged:true+Binds:["/:/host"] then /start ran a container as host uid 0 writing to the host filesystem — a container escape to host root, reachable from any app on malmo-ingress. v0.4.2 has no ALLOW_START; the proxy filters URL+method only and never reads bodies, so the "host-bind mounts are denied" claim in brainlaunch.go and CONTROL_PLANE.md described a control that does not exist — both corrected. The allowlist is already minimal (every family maps to a real brain path; the escape needs only POST+CONTAINERS+IMAGES), so narrowing cannot close it — the fix is network reachability, which is #187 (apps off malmo-ingress). THREAT_MODEL.md B2 updated: the "escape to host root" row is no longer absolute, the Brain↔Docker residual records the live gap, and the blast-radius summary carries the caveat until #187. Guard test TestProxyAllowlistIsMinimal pins the set and bans EXEC. #187 re-triaged (measurement fed back, stale blocked label removed, its only dep #165 being closed). Not fixed here by design — the escape closes with #187, which is also where the docker-proxy:2375 non-reachability regression test belongs (it cannot pass while apps share the network) done
hosted-seed-doc-drift.md — Closes #412. Documentation only. ENVIRONMENT.md and FIRST_RUN.md still described the hosted first admin as gated by a one-time admin_bootstrap_secret in the seed; that mechanism was removed by portal-box-sso.md and grep for it in the Go code returns nothing, so the single home for hosted design described a login that cannot happen — on the page malmoos/cloud reads before building against the seed. The seed shape is corrected to {box_id, assertion_verification_key, enrollment, update_target_url} with which fields ReadSeed requires; "Admin bootstrap — as built" is renamed "Owner sign-in & seed ingestion — as built" and rewritten from the code (ingestion order and the box-id commit marker, the /_malmo/sso handshake and its iss/box/sub/email/single-use-jti policy, first-assertion admin create, owner-only afterwards, 503/401/403/404, /setup 403 on hosted); the "Operator hand-off" bullet is deleted. FIRST_RUN.md # Step 2 now says the step does not run on hosted and there is no recovery code there. LOGGING.md gains the three actions this path emits (setup.failure, sso.success, sso.failure). A new DECISIONS.md 2026-06-28 entry records the flip; the two bootstrap-secret entries stay as written, since the log records what we believed then. Also checked and found correct: enrollment, update_target_url precedence, frozen identity, the #251 egress block, TESTING.md's hosted lane description done
update-target-read.md — Closes #443. updatetarget.Loop decided every 15 minutes and told nobody: its state was private and its only output was the journal, so on an appliance the log line "a different control plane is available" was the end of the road and the admin prompt UPDATES.md # 3 promises had nothing to read. The loop now records each tick behind a mutex and host-agent serves it at GET /v1/system/update-target, re-served admin-only as GET /api/v1/system/update-target. One state field, seven values, none merged — current, available, none, unreachable, refused, disabled, unknown — because a source that is down and a source stuck on a bad answer are not the same problem, and the cloud lane already proves them separately. Three cases the obvious implementation gets wrong and this does not: a box whose seeded update_target_url is unusable starts no loop at all, so it reports disabled with the reason rather than the "never checked" a box that booted a second ago also reports — only one of those needs a human; the running pair is read when the request arrives, not carried from the tick, since a tick that ends early never reads it and host-agent outlives an update, so a stored pair would be stale exactly after an apply; and the two from settings stay apart on the wire (from = where the target URL came from — seed/env/default; window_from = where the window came from — answer/env/default), which the issue as first written merged into one field that no single setting has. The error text is called detail, not error, because it carries raw Go strings — a diagnostic for an admin, with state as the field the dashboard writes its sentence from. The fake agent defaults to none (honest for a dev box) with MALMO_FAKE_UPDATE_TARGET selecting any other state, the MALMO_FAKE_NO_GPU precedent, so the later prompt can be built against all seven. Gaps: no UI; the update boot's four new assertions are written but not run (needs root + KVM or a CI / Cloud image run); an appliance that ever gets a signing key sits on refused permanently, since the manifest names versions and not digests (#400); and the read deliberately says nothing about whether the last update worked — that is the open NEXT.md item on where an update outcome lives. Review caught the leak this change had reasoned its way around: the URL was kept off the wire because an operator-set one can carry credentials, and then the fetch errors that quote it verbatim reached the same response as detail — RedactURL now strips userinfo everywhere the URL is written for a person, the two startup log lines included, so the journal exposure that predates this change closes with it. The first redaction was half a fix, which the second review pass caught and two measurements pin down: http.Client.Do returns a *url.Error that strips the password but keeps the whole query, and url.Parse returns one that keeps everything, password included — both were wrapped straight into detail, so ?token=… leaked on every path and a malformed seeded URL leaked its password in cleartext. The redaction now drops query and fragment too, and CauseOf unwraps *url.Error so the raw URL cannot ride in beside the redacted one. The cloud lane went red on the new assertion's own timing, not the endpoint: the read lands a second after the box recreates its brain on a deliberately failed update and restarts host-agent, and a 502 from Caddy reads identically to a 502 from the handler — it now waits for /api/v1/me first and says "the box is not serving" when that is what happened, so one status line stops standing for two different failures. The second red run was a real box bug, filed as #447 and not fixed here: RuntimeDirectory=malmo makes systemd delete /run/malmo when host-agent stops, and the brain reaches the socket through a directory bind mount resolved at container-create time, so any host-agent restart cuts the brain off from it until the brain container is recreated — measured on real Docker, and invisible until now because nothing restarted host-agent in a boot proof and the endpoints that call it degrade quietly by design. The brain-side read is therefore asserted after the apply, where the brain is fresh, so it tests this endpoint and not #447 done
host-agent-restart-keeps-brain-socket.md — Closes #447. Follows update-target-read.md, whose new assertion found this and worked around it. host-agent.service declared RuntimeDirectory=malmo alone; systemd deletes that directory on stop and makes a new inode on start, while the brain reaches the agent socket through a bind mount of the directory that Docker resolves when the container starts. So a plain systemctl restart host-agent left the brain looking at the deleted inode with no agent.sock in it, and nothing closed the window — the brain runs restart=unless-stopped and host-agent does not touch a running brain — so nobody could log in until a reboot (the brain holds no password hash and calls host-agent on every login) while the box looked healthy: every container Up, every endpoint needing no host call still answering. Fixed with one directive, RuntimeDirectoryPreserve=yes, in dist/systemd/host-agent.service — the only copy, since all three lanes cp from it, so the two mkosi.extra paths #447 listed are gitignored build artifacts. Both halves are now measured: #447 measured the Docker half twice and left the systemd half as its "one thing not verified"; a throwaway unit on real systemd shows RuntimeDirectory alone moving the inode 38737→38741 with contents wiped and the directory gone on stop, and +Preserve holding 38775→38775 with contents kept — man systemd.exec agrees, including that /run being tmpfs means a reboot clears it regardless, so preserving leaks nothing durable. A second live bug closes with the same line, and it was not in #447: /run/malmo also holds storage.json, written by malmo-storage-verify — a Type=oneshot that runs once at boot and never again — so host-agent stopping deleted a file it does not own and nothing regenerated it, and since HealthSource treats a missing report as empty findings by design, the box then reported "storage is fine" for the rest of its uptime: a silent false negative on health from the same root cause. The cloud lane's update boot already restarts host-agent, so no new scenario was needed: the comment block that documented the bug becomes assertion 6a-bis — a host-backed endpoint answers 200 before the restart (so a box that never served it cannot pass by failing twice), answers 200 after, and the brain container id is unchanged, which is the half that matters, because a recreated brain also answers 200 and that is exactly how a box "recovers" from this bug today. Step 6c's comment, which credited the apply's recreate for the read working at all, is corrected. Socket activation was weighed and still deferred, now as a measured call: it is the canonical answer and would let connections queue through a restart, but it buys the ~2s window where this removes the permanent outage, at the cost of sd_listen_fds plus a non-systemd path for make dev. Verified on a booted box: CI / Cloud image green with SOCKET SURVIVES OK in the log, so the assertion ran rather than being skipped into a pass; the negative control is #446's own red, which hit the 502 at this exact point and moved its read because of it. Gap: the health finding #447 also asked for is filed separately rather than grown into this PR, since it needs a new issue type, a HEALTH.md catalog row, debounce and UI copy done
catalog-accept-schema-version-2.md — The store was empty on every box and the cause was one constant. wireSchemaVersion still said 1 while GET /catalog serves schema_version: 2, so verify() refused every snapshot, syncOnce never swapped one in, and the projection stayed empty with no user-visible error. The box has read schema 2 in full since 514710e — manifest_url / compose_url on wireApp, install documents fetched per app, nothing reading the inline fields — so only the accepted-format stamp was behind. Two one-line edits: the constant, and the synthetic fixture's top-level stamp (its body was already schema 2; the three fake apps are untouched and nothing regenerated the file). Verified past the fixture against the live served payload — 110 KB fetched and fed to parseSnapshot, projecting 41 apps, 8 categories, 7 home groups and install-document URLs on every record — with the throwaway test deleted. Gaps: the fixture stamp is corrected by hand rather than driven by the publishing side, and a refused snapshot is indistinguishable from an empty catalog to a user. done
hosted-login-picker-closed.md — GET /api/v1/auth/users returns 404 on hosted; unchanged on the appliance. The route lists every account as {id, username} and sits in publicPaths, which is right on the appliance — it draws the user-list login screen before a session exists, behind a .local or mesh perimeter. Hosted has no perimeter: the dashboard answers on the public internet at <box-id>.malmo.network, so the same payload is a tenant roster any scanner can read, giving exact Linux usernames, and the box-id labels needed to find it are already public in certificate transparency logs. It also had no caller there — App.vue bounces an unauthenticated hosted visitor to the portal, so Login.vue never renders and the owner returns through the SSO handshake, which mints the session itself. 404 rather than 403, matching ssoLanding's appliance behaviour: the route does not exist on that profile, and a 403 would confirm there is a roster worth hiding. Third profile seam in internal/api next to /setup and /_malmo/sso, branching for the same public-by-default reason. Gaps: not exercised on a booted box (API test only, no cloud-lane assertion); the AUTH.md privacy toggle that swaps the picker for a blank form is still unbuilt, so the appliance roster stays public as specced; per-name probing is unaffected, only bulk enumeration closes done
ssh-per-account-access.md — Closes #463. SSH existed only on paper: specced for the appliance in AUTH.md/BUILD.md, locked off for hosted, and zero code anywhere. Now decided, written down, and built from the brain's API to the sshd config. The mandatory auth factor is the profile's — a public key on hosted, the malmo password on the appliance — with the other available as a required second method (AuthenticationMethods publickey,password), never as an alternative, because an account's security is set by its weakest accepted method. The one-password rule was a decision about a LAN, not about passwords: it holds where nftables keeps :22 to RFC1918+mesh, and on a public box it would turn a household password into an internet-facing credential and a second door to the credential the brain's login throttle guards, which sshd knows nothing about. :22 is open only while someone has SSH on, replacing daemon-on-with-an-empty-allowlist — and that works because the box is its own perimeter, measured: the cloud repo's Hetzner create sets no Firewalls field and that package has no firewall code, so no in-guest nftables ruleset was needed (one was deferred on purpose in 2026-06-19). New POST /v1/ssh/set-access carries one account's full state so a retry converges; internal/hostagent/sshaccess renders the drop-in whole (global AllowUsers + one Match User block each), writes keys before the config, runs sshd -t before the reload, and reloads rather than restarts so live sessions survive. An empty set writes DenyUsers *, since omitting AllowUsers means every account to sshd; Match blocks go last, since a global written after one silently joins it. Brain side: two additive tables, /api/v1/me/ssh (My-account, every user), elevation-class with success+failure audits, hosted refusing an enable with no key and refusing to drop the last key while enabled, and keys re-serialised from the parsed key so a paste cannot smuggle command=/from= options. Gaps: never run against a real sshd or systemd — the daemon lifecycle is the port control and a unit test cannot show a port closing, so the issue's own "Done when" is unmet pending a booted box; no UI; the hosted image still ships no openssh-server and the lean lockfile still bans it; nothing polls /v1/ssh/state, so drift is not surfaced done
ssh-in-the-images.md — Closes #467. #464 built SSH from the brain's API down to the rendered sshd config and could not run it anywhere: no image carried openssh-server, and a unit test cannot show a port closing. Both images carry it now, installed and stopped — Debian's postinst enables ssh.service, so the hosted build undoes that with a wants removal plus a preset a later preset-all can't override; ssh.socket is not enabled on trixie (checked against the real package), so the service unit is the whole of the run state. Two things the packaging turned up: Debian's postinst bakes host keys at build time, so every box from one image would share them, and its sshd-keygen.service is ConditionFirstBoot=yes — useless for a daemon that first starts weeks later — so the build deletes the keys and ships malmo-sshd-keygen.service to make per-box ones at boot — at boot and not with the daemon because sshd -t runs before the unit starts and needs keys, which the first boot-proof run failed on. The appliance gets the two files BUILD.md # SSH specified and nothing wrote — the default-deny :22 nftables rule, loaded every boot as a standing policy, dropping container traffic on Docker's bridges before allowing the three private ranges and their v6 counterparts — in an inet table ip saddr compiles to an nfproto==IPv4 test, so an IPv4-only rule would have silently dropped every IPv6 client while reading as if it allowed the LAN, and the static hardening drop-in — checked in under dev/test-qemu/appliance/ because no .deb exists to own them yet. The harness-lockout question #467 asked to settle early is settled: sshd takes the first value it obtains and reads drop-ins in filename order, so the medium lane's own file is renamed to sort first, and that lane never exercises the account toggle — enabling one renders an AllowUsers that excludes root, disabling the last stops sshd, and either cuts the harness's own connection. The proof moves to a new ssh cloud boot, in the publish gate: :22 closed at boot, a key-less enable refused, the port opening, a real ssh -i key login, a password-only attempt refused with sshd's own "can continue" list read off the wire, the second factor making the key alone insufficient, the port closing again, and a deleted account leaving no name and no key file behind. Gaps: no mesh interface in the rule (v1 installs no mesh client); the appliance firewall half is local-lane only, not CI; the lifecycle is proved on hosted only; a real hosted owner still cannot pass the re-auth gate at all (#469 — the lane sets a password as host root to get past it); no UI. Three real defects surfaced only by booting: sshd could not validate a config with no host keys (fixed by generating them at boot, not with the daemon); an anchored regex could never match sshd's CRLF-terminated debug output; and SSH was one-shot per boot — ssh.service declares RuntimeDirectory=sshd, so stopping it deleted /run/sshd and sshd -t then refused every later enable until reboot, which only a test that enables twice can see. Also folds in two bugs the review found in #464's seam (maintainer's call, out of this issue's scope): deleteUser revoked SSH on the host before the brain row went and never compensated when a later step failed, so a failed delete silently left the account enabled in the brain and revoked on the host — and restored only the user row, handing back an account whose keys the cascade had destroyed; and sshaccess.SetAccess restored the drop-in but not the key file when sshd -t rejected a render. AddSSHKey now keeps a supplied added_at so a rollback cannot reorder a user's key list done
hosted-portal-confirm.md — Closes #469. A hosted owner had no way past the re-auth gate: the portal signs them in and the box gives their PAM account a random password that is thrown away, so every elevation-class action (add a user, change a role, the mail and SSH writes) was unreachable on a hosted box. Their confirm step is now a second portal round-trip instead of a password prompt. The dashboard mints a one-time challenge (POST /api/v1/auth/elevate/challenge, hosted-only), carries it to the portal inside a return path, and the SSO landing redeems it and marks the session it mints elevated for the normal five minutes. No challenge, no elevation — that rule is the whole cross-site answer: the portal's open-box route is a GET with a SameSite=Lax cookie, so a cross-site page CAN drive the round-trip, but it cannot mint a challenge (that needs a JSON POST to the box's own API), so it gets a sign-in the victim already had and no window. The challenge is single-use, bound to its user, and spent before the session is elevated; the return path must be a relative path on this box, since the landing hands out a live session and an open redirect there would hand it to someone else's page. GET /me grows an owner flag so the dashboard branches on "this account has no password", not on "this is hosted" — a box user the owner created keeps the prompt, and the appliance is byte-unchanged. Proved on a real box: the access cloud boot refuses a create-user 403, runs the confirm round-trip with a second real assertion, then gets 200 and a real PAM account, with a third assertion showing an off-box return path still lands on /. Gaps: the portal half (forwarding return) lives in the private cloud repo and is not built, so the round-trip does not close in production yet; the pending action is not resumed across the redirect; no UI test harness exists for the dashboard branch done
cors-origin-reflection.md — Closes #475. Found by Greptile on the v0.12.0 release PR, in code already merged to dev. withCORS wrapped the whole handler chain on both profiles, reflected any Origin, set Access-Control-Allow-Credentials: true, and answered every OPTIONS with 204. On hosted that is a live hole, not a theoretical one: apps are <slug>.<box-id>.malmo.network and the dashboard is <box-id>.malmo.network, and malmo.network is not on the Public Suffix List — the same fact that makes the fleet share one Let's Encrypt budget — so the two are same-site and the owner's SameSite=Lax session cookie rides a fetch between them. The cookie went and the reflected header let the app read the reply, so any app on the box could act as the owner against the dashboard API, POST /auth/elevate/challenge included. Two places in the tree already asserted the opposite — AUTH.md # Re-authentication ("a cross-origin page cannot make [an authenticated JSON POST]") and confirm.go ("the JSON content type forces a preflight the brain does not answer") — so the hosted confirm step's entire cross-site argument rested on a refusal that was in fact a 204. Review of this change caught that both of those sentences were also wrong about the mechanism: elevateChallenge takes no body and requires no JSON content type, so that POST is a simple request a same-site page can send regardless — the protection was never that the request cannot be sent, but that the reply cannot be read, and a challenge nobody reads is inert (single-use, user-bound, short-lived). Both the spec and the code comment now say the thing that actually holds the boundary. Removed, not narrowed to an allowlist: nothing calls this API cross-origin in any lane — the dashboard fetches relative paths, Caddy serves UI and brain on one host in production, and Vite proxies /api with changeOrigin in dev, which had already made the middleware's own stated reason for existing untrue. An allowlist would keep a knob that is only ever set wrong, for a caller that does not exist. Two regression tests, both run against the old middleware first and failing there with the attacker origin echoed back; the 200 case uses the appliance login picker so absence is asserted on a success rather than a 401. AUTH.md now states it as a guarantee with its reason, so a future CORS layer contradicts a spec line. Gaps: the blanket OPTIONS 204 goes too (intended; same-origin never preflights, so make dev is unaffected, but a browser page hitting the brain directly would notice); no booted-box assertion, since proving it needs a hosted box with an app installed; Caddy's own headers on app routes are untouched; and a same-origin XSS in the dashboard is a different boundary this does not narrow done
appliance-ssh-password-factor.md — Closes #477. Second of three from the Greptile review on the v0.12.0 release PR, stacked on cors-origin-reflection.md. AUTH.md # Device access gives each profile a mandatory factor and an optional second one, and only the hosted row was enforced: an appliance account that added a key and left require_password unset got AuthenticationMethods publickey, replacing the mandatory password instead of adding a lock beside it. The field is omitempty, so that is the default path a client takes — {"enabled": true} produced it — not an odd one. The placement is the substance of the fix: sshaccess renders what it is handed and its methods() is right to write publickey alone for a key with RequirePassword false; host-agent does not know the profile and must not guess which factor is mandatory (ssh-per-account-access.md says so outright), so host-agent was not wrong — the brain was handing it a false the appliance is not allowed to ask for. setMySSH now resolves the caller's value against the profile before anything is written, once, at the only entry point a caller-supplied value comes in through: syncSSHIfEnabled, the deleteUser restore and both rollbacks read the stored row, so normalising before the store write makes every downstream push right without touching them, and the row stops describing a posture sshd is not running. The audit keeps the two apart: require_password is what was asked for (every record here is about the request, refusals included — the resolved value on a refused request would describe a posture the box never took), and the success record adds require_password_applied beside it. Two of the three tests fail against the unfixed handler with RequirePassword:false sitting next to the key; the hosted one passes either way by design, proving the fix did not spill across the profile seam. Gaps: nothing proved against a real sshd — the assertion stops at what the brain sends, and the ssh cloud boot covers hosted only, so no lane runs the appliance renderer on a booted box; existing rows are not migrated (there are none — SSH has never shipped and has no UI); require_password is still accepted and ignored on the appliance rather than 422'd, since a uniform client setting it across profiles should not break done
ssh-daemon-failure-rollback.md — Closes #479. Last of three from the Greptile review on the v0.12.0 release PR, stacked on appliance-ssh-password-factor.md. SetAccess writes key file, then drop-in, then the systemd call; the first two already undid themselves (snapshotKeys, and writeDropIn restoring a render the combined sshd -t rejects) and the third had nothing, so a failed enable/disable/reload returned an error with both files left as written. The brain reads that error as a host failure and rolls back only its SQLite row, so the two sides then disagree about who has a shell — and it does not stay bookkeeping, because readDropIn treats the rendered file as truth on the next call, so a stale entry is merged back in rather than overwritten. Two real outcomes: a failed first enable left the account in the drop-in with its key on disk and nothing in the brain, and the next account to enable SSH starts the daemon and brings the forgotten one up with it — access granted by a call about a different user; a failed disable dropped the entry and the key while the brain put the row back to enabled, showing access the host was no longer configured for. snapshotDropIn now mirrors snapshotKeys, and undo restores the drop-in, then the key file, then reconciles the daemon to the set as it was before the call. That last step is the argued part: files alone would be half an undo, since enable --now starts the unit before a later reload in the same sequence can fail, leaving sshd serving a set that no longer exists on disk — and reconciling is the same full-state convergence SetAccess already does, so running it after a failed systemctl is safe rather than reckless. A failed undo says the host could not be put back, matching writeDropIn. Review of this change corrected two things: the drop-in snapshot moved ahead of both writes, since taking it between them would leave a new key file live with no restore to undo it — silently changing who can authenticate on a key replacement — and undo now attempts both restores and joins the errors instead of returning at the first, with the daemon reconcile split by direction: stopping is unconditional, since systemctl disable --now reads neither file and stopping is what closes :22 — the only port control hosted has — while starting or reloading is gated on both files being back, since those read what is on disk. Two further passes got that shape: one caught that gating on the drop-in alone was not enough (a restored drop-in points at the very path a failed key restore left holding the new keys), the next that gating the stop at all would leave :22 open after a failed first enable. Four tests on a runner where sshd validates and systemctl fails, which is the real shape of this failure; two fail against the unfixed code, the disable case showing DenyUsers * where the account should still be. Gaps: nothing ran against real systemd — the runner is faked, and no lane fails a systemd call on purpose; the undo is best-effort, so a failed reconcile leaves the error message as the only remedy; and a crash between render and daemon call still drifts, since nothing runs the undo — all three want the reconcile loop on GET /v1/ssh/state that three entries have now named and none has built done
ssh-settings-screen.md — Closes #482. The UI half of per-account SSH: a /settings/ssh screen with one switch that turns shell access on, then an Authentication methods pair, the malmo password and the public key, drawn as two cards of the same shape because they are the same kind of thing: locks this account must pass, both demanded together when both are on. The profile rule is read, not re-implemented. SSHAccessDTO.key_required already says which method this box makes mandatory, so that one shows an on switch that cannot be moved and the other is live: password fixed on the appliance, key fixed on hosted. The key switch has no field behind it and should not have one, since a key set of zero is the method being off; turning it on opens the add form, turning it off asks first and then deletes the keys. The hosted box owner gets the password switch disabled: their box password was generated during the portal handshake and discarded, so turning it on would have sshd ask for a string nobody has ever seen. That is the only rule this screen owns, and the file says why the brain is not the place for it. Keys arrive by paste or .pub file, all writes go through withElevation, and every guard rejection shows the server's own message. Gaps: typechecked but not clicked in a browser, no test runner, dropping keys is several calls with no bulk endpoint, no drift surface, and the screen is named SSH rather than Device access until SMB has an API done
rename-to-moose.md — Closes #489. The project is renamed from malmo to moose: boxes move to onmoose.io, the product site and portal to mooseos.com (two registrable domains on purpose), and the module to github.com/onmoose/os. One mechanical pass over 371 files (about 4,950 mentions), most-specific-first so no name was rewritten twice, then the file renames, then a hand pass over the seams. It covers the on-disk names (/var/lib/moose, /etc/moose, /run/moose, /srv/moose), the host accounts (moose-app, moose-shared, moose-svc-<uid>, /etc/pam.d/moose), the systemd unit graph, the brain runtime names (moose.db, moose-brain, moose-ui, moose-ingress, moose-control-plane, the moose.* labels including the moose.protocol.major gate), the HTTP surface (moose_session, moose_forward_auth, X-Moose-User, /_moose/sso), about 60 MOOSE_* env vars including the app-facing set, and the dashboard copy. Three calls sit inside it (DECISIONS.md 2026-09-16): a clean break with no compatibility layer, because the host-agent that mounts the old paths and checks the old label is baked into the image, so an existing box is re-provisioned rather than upgraded; the app-facing contract renamed in lockstep with onmoose/store, with no dual-prefix period; and the box domain split from the product site, with every baked value on its own subdomain of the box domain (catalog., auth., api.) so a tenant-controlled name never shares a registrable domain with the portal. The frozen history keeps the old name, and no link in it needed fixing. Verified beyond the unit suite: the images build and carry the renamed moose.protocol.major label, make test-caddy passes end to end, and a real install with the catalog seeded from the paired store branch came up with moose.* labels, MOOSE_* in its .env and MOOSE_FOLDER_DOCUMENTS reaching the container, answering through Caddy and through mDNS. Gaps: nothing ran on a booted box, so the cloud image lane on the branch is the real proof; the local-only medium lane was not run; and the two-repo seams (store, and the private control plane) must merge together done
catalog-service-doc-accuracy.md — Rides on rename-to-moose.md, the change that points MOOSE_CATALOG_URL at https://catalog.onmoose.io. That URL is the last step of a move the box side never wrote down: the catalog is not part of the control plane any more. It is published and served from onmoose/store, on its own host and its own deploy, and its artwork is served from an object-storage origin rather than from the catalog host at all. No box behaviour changes. The code was already right, for a reason worth keeping: resolveURL treats every published URL as opaque and follows it as given, with a comment saying assets may move to object storage. They did. What was wrong was about twenty sentences that named the wrong operator, across APP_STORE.md, docs/architecture.md, BRAIN_UI_PROTOCOL.md, NEXT.md, CLAUDE.md, README.md, three files in docs/dev/, and every code comment carrying the claim in internal/catalog and the catalog wiring in cmd/brain/main.go; the vocabulary is now "the catalog service". The other meaning of "control plane" in this repo, the box's own daemon (CONTROL_PLANE.md), is deliberately untouched, as are the seed and acme-dns seams that really are the cloud's, which is why the pass was read rather than grepped. CLAUDE.md's seam list said three things come from the control plane, and only two do, so the catalog row now says plainly that a catalog edit never touches the control plane. APP_STORE.md # What we run also listed an asset route on the catalog API that is not one any more. docs/dev/contributing.md gained the # Changing the published catalog shape section it was already linked to, which states the quiet property of the seam (a field the box does not model is dropped with no error and no log line) and the four steps that deliver one, the hand-written fixture rule (#420) among them. Writing it found a stale command in the same file: go test ./internal/catalog -run TestVerifyFixtureSnapshot -update names a test and a flag that do not exist, so it reported "no tests to run" and passed, a leftover from before #434 removed the digest it claimed to re-stamp. One test added, because the claim the docs now make had no coverage: documents on another origin were tested, assets were not, so TestRemoteAssetFollowsAbsoluteURL stands up a bucket server and asserts the catalog origin serves zero assets. Gaps: nothing asserts the operator split itself (the box cannot tell who runs the host it fetches from, and should not); APP_STORE.md's superseded banner now carries three layers of "not what shipped" and wants a rewrite that is not this change; and the store side still describes the fixture procedure differently, which nothing here can check done
display-names.md — Closes #468. FIRST_RUN.md # Identity & display names has promised since the first draft that a person types a first name and the box derives a stable Linux account name from it. None of it was built, and the gap had gone three ways: /setup and POST /api/v1/users wrote the caller's string straight to users.username, the hosted SSO handshake had a third rule that split the email and folded non-alphanumerics to _ (producing j_smith, which the spec's own [a-z0-9] rule forbids), and there was no display name in the database at all, so there was nothing to rename. validateUsername rejected exactly two things, -- anywhere and an xn-- prefix, so José Smith passed the brain and failed later at useradd. Now there is one derivation (internal/api/accountname.go) and all three paths call it; none accepts an account name from a caller any more, and ssoUsername is gone. It lowercases, decomposes, drops combining marks, folds the Latin letters decomposition does not split (ø, ß, ł, æ, đ, þ, which is why Søren is soren and not srn), keeps [a-z0-9], caps at 32, gives a digit-initial result a leading u because useradd refuses an all-numeric name, then walks past the reserved list and the host. The host half is the security half: set-password is an upsert, so a name that already exists gets a password set on that account rather than creating one, and a person called Plex on a box running Plex would have been handed the Plex daemon's account. The new GET /v1/users/{username}/exists closes it, and is deliberately a boolean probe for one name, never a listing and never proxied to the browser, since either is an oracle for which system accounts exist; a lookup failure that is not "unknown user" is an error rather than "free". It is a route of its own rather than a read of /v1/users/{username}/home because the fake agent resolves every name to the dev operator's home, so as a probe it would call every name taken and the walk would never terminate. Uniqueness is checked in the brain, not the database: SQLite's NOCASE folds only ASCII and would let José and JOSÉ both through, so the index is the backstop for the race and the real comparison folds case and whitespace but not accents — José and Jose are two people, both allowed, colliding on josesmith/josesmith1, which is what the numeric walk is for. Two spec sentences were corrected rather than implemented: the derivation removes characters outside [a-z0-9] instead of collapsing runs of them, which is what makes the --/xn-- reservations true by construction; and systemd* is matched as the bare name, not as a prefix, because a prefix match also rejects systemd1, systemd2 and every fallback the walk has, leaving a person called Systemd with no name at all — a test found that one, as an infinite walk. One change the issue did not ask for but forced: adoptSSOOwner used to find a half-created owner by re-deriving its name, which only worked while derivation was deterministic; it now adopts the box's sole admin and errors if there is not exactly one. The column is additive and backfilled from username, asserted against a database written before it existed, and the store refuses a row with no display name so a forgetful caller fails on the first insert rather than conflicting on the second. Dashboard shows the name everywhere and the slug in exactly two places, Settings → SSH and the admin user list. Verified live against the inner loop: José Smith → josesmith, second one → josesmith1, root → root1, rename to Jo leaves josesmith signing in on the old password. Review of this change caught three real bugs, each now covered by a test that fails against the code as it was: the migration would have stopped a box from starting, because usernames are unique case-sensitively and the old validateUsername never rejected uppercase, so a box holding both Bob and bob backfills two display names the NOCASE index treats as one, the index fails to build and migrate errors; the SSO wedge was still there one line further along, since newAccount runs the uniqueness check and the half-created admin already holds the name the assertion asks for, so the retry answered 409 and never reached the adopt branch, fixed by checking for an existing user before deriving anything; and the length cap ran before the digit prefix, so a 32-digit name came back at 33 characters wearing its new u. Gaps: non-Latin names fall back to user/user1 rather than the 李 → li the spec used to promise, and the spec now says so; the SSO owner's name is the email local part until the control plane signs a name claim; the recovery screen still asks for an account name nobody has been shown, which is a hole this change opens and does not fill; x/text is a new direct dep and pulled indirect x/mod to v0.17.0; nothing ran on a booted box done
installed-apps-card.md — Closes #487. Settings → Installed apps rebuilt on the Tailwind Plus "In card with links" stacked list: one card, each row showing the app's logo (or AppGlyph fallback), name, one-line short description, a colored status dot with a plain word (never the raw state string, and "Needs attention" for a running app with an open container-restart-loop/app-unresponsive health issue), and a " · "-joined second line (owner in multi-user mode, version, access mode on hosted only). InstanceDTO gained short_description, filled from the same catalog-entry lookup that already sets icon_url/icon_glyph, empty for a Door-2 custom app. Gaps: no browser smoke test this session, no automation tool available; go tests and vue-tsc pass done
ssh-draft-save.md — Closes #494. Settings → SSH becomes a draft with one Cancel and one Save. PUT /api/v1/me/ssh now takes the whole desired state (enabled, require_password, and the complete key set: kept keys by id, new keys by text). The brain diffs it, writes the difference in one transaction, and pushes the host once. Guards apply to the end state, so "remove my only key, add its replacement" is one valid Save on hosted, and the per-delete last-key guard is gone. POST /me/ssh/keys and DELETE /me/ssh/keys/{id} are removed. All three audit actions come from the one handler, one record per key changed, on success and failure. A refused key names its index, so the screen shows the error under that key. A removed key stays greyed with Undo until Save. The copy says "Enable SSH for my account" and "Password", never "moose password". The draft survives a reload and the hosted owner's portal confirm in sessionStorage, keyed by user id, with a visible "Unsaved changes restored." line. A private key is refused before it can enter the draft. Gaps: no browser pass yet; the CI cloud lane check and the owner round-trip on a real provisioned box are still open done
install-setup-page.md — Step 1 of the install setup plan (INSTALL_SETUP.md). The install modal is gone. Install now goes to a setup page at /store/:id/install (?scope=household for the household item), laid out as a description list with one row per section: Permissions, Folders, Email, AI providers, Settings, Storage. It keeps everything the modal did (folder sources, storage warning, required-field gate, 422 inline, 409 warn-and-retry, health gating). The Email row shows accounts as cards and lets an admin add one inline through mailProviderForm.ts and withElevation. The AI providers row shows provider tiles, asks for the key and a model or server address where the app has a field, and fills the existing config fields; the provider list and the env-name lookup in aiProviders.ts are temporary until manifest roles land (plan step 4). A 202 moves to a progress page at /store/:id/install/:jobId that shows four phases and ends with Open; a reload resumes the job, and a 404 after a brain restart says so. UI only, no Go or API change. Gaps: not tried in a browser; a bare MODEL (openmuse) is not recognised and stays a plain field; the hosted owner loses the setup form on the portal elevation trip done
email-accounts-per-user.md — Step 2 of the install setup plan (INSTALL_SETUP.md # 5), after install-setup-page.md. Email accounts now belong to the user who added it (mail_providers.owner_user_id, labels unique per owner; the table is rebuilt once and existing rows go to the founding admin). Any user can add one, in Settings or inline on the install setup page, with no password re-prompt for add or edit; delete keeps it. Every mail endpoint is scoped to the caller, another user's account answers like a missing one (404, or 422 on install and rebind), and a user delete takes their accounts with it (restored if the host step fails). Also: Try again on the progress page keeps the household scope, and the temporary AI provider list is corrected against provider docs. Gaps: not tried in a browser; the AI list is only partly verified done
ai-provider-data.md — Step 3 of the install setup plan (INSTALL_SETUP.md # 4), the os side, after email-accounts-per-user.md. The catalog snapshot gains a top-level ai_providers list (schema version stays 2), read leniently once per load: unknown model types and flags, models with no known type, bad defaults and providers with no id or name are dropped and logged, and nothing in the field can make the box refuse the snapshot. GET /api/v1/ai-providers serves it in authored order to any signed-in user (200 with an empty list when there is none), with logos proxied and cached like app icons at /api/v1/ai-providers/{id}/logo and /logo-dark. The setup page now draws its AI tiles from that endpoint: a native slot matches by native_protocol, the model picker offers chat models with defaults.chat first, help and the key link sit under the key, a key without key_prefix gets a soft warning, and "Other (OpenAI-compatible)" stays a UI tile. An empty list means no AI section and plain fields. mkcatalog -ai-providers and make seed-catalog AIPROVIDERS= carry test data onto a seed. Gaps: tried in a browser only without logos; the store side lands separately, so a real box has no tiles until it does; the fixture's two providers were added by hand until the publisher writes them; dark logos are wired to a dark theme the dashboard does not have yet done
manifest-roles.md — Step 4, first piece, of the install setup plan (INSTALL_SETUP.md # 1 to # 3), after ai-provider-data.md. Config fields gain role (<kind>.<protocol>.<attribute>, kind ai only) and separator, and manifests gain requires: [{one_of: [...]}]. manifest.Parse stays lenient: a role the box cannot fill makes a plain field, a requires member that matches nothing is dropped, and none of it can fail a parse (internal/manifest/roles.go). The strict rules and three warnings are in manifest.Lint (lint.go), run only by moose manifest lint/check, which now print warning: lines and take --ai-providers <path>. The brain refuses an install that leaves a group unmet (422, audited) and a config edit that makes a met group unmet; the install plan and config DTO gain role, separator and requires. manifest_version stays 1. Gaps: nothing fills a slot yet (piece 3); no store manifest is tagged yet; three fillability rules go past the brief done
ai-accounts.md — Step 4, second piece, of the install setup plan (INSTALL_SETUP.md # 5), after manifest-roles.md. AI provider accounts follow email accounts: a new ai_accounts table (owner cascades with the user, labels unique per owner) and GET/POST /api/v1/ai-accounts, PUT/DELETE /api/v1/ai-accounts/{id}, open to any signed-in user and scoped to the caller. Add and edit need no elevation, delete does; another user's id is 404 and audits a failure (ai.account.*). The provider id must be in the box's provider data or be openai_compatible (base URL required, key optional); a listed provider needs a key. With no provider data a listed id is a plain 422. The key is never returned: reads carry key_set. A failed user delete puts the AI accounts back. Gaps: no UI yet (pieces 3 and 4); no check-key call; nothing binds an app to an account yet done
ai-slot-filling.md — Step 4, third piece, of the install setup plan (INSTALL_SETUP.md # 1, # 5 and # 6), after ai-accounts.md. POST /api/v1/apps takes config.ai_bindings ({slot, account_id, models}), and the brain resolves each binding from the caller's own AI account into the slot's app_env values (key, base URL, model ids joined with the separator, provider defaults), then runs the usual field and requires checks on the result (internal/api/aibindings.go). Plain-English 422s cover an unknown or repeated slot, another user's account (same as missing, audited), a provider that does not fit or has left the data, undeclared model settings, missing models and bad ids, and a typed value on a bound slot. The bindings are stored in a new instance_ai_bindings table (cascade with the app and with the account), written in the install transaction, and restored by a failed user delete. The setup page reads roles: the env-name lookup is gone, tiles follow the # 1 order and model types, the user picks or adds an account inline, then models, and Install waits for the requires groups. Gaps: not tried in a browser or on a running brain; post-install pickers and rebind are piece 4; deleting an account leaves the app's values until its next write done
ai-bindings-after-install.md — Step 4, fourth piece, of the install setup plan (INSTALL_SETUP.md # 1, # 5 and # 6), after ai-slot-filling.md. GET/PUT /api/v1/apps/{id}/config carry AI bindings: the GET lists them (another user's account without its label) with needs_setup and plain missing sentences, and the PUT takes ai_bindings in the install shape (listed slot replaced, empty account_id clears, unlisted slot untouched), resolved with the install code and checked together with the fields, then stored with the values in one transaction. Account changes reach apps: an AI key or base URL edit, an AI delete, and an email edit that changes MOOSE_MAIL_* or an email delete each start one job that rewrites the bound apps and restarts the running ones (lifecycle/restamp.go), one app's failure not stopping the others; an AI provider change while apps use the account is a 409; both account lists gain used_by. needs_setup is on every instance. UI: one shared AISlotPicker for the setup page and the app's settings screen ("set by hand" slots, "Someone else's account"), a new Settings Integrations group with LLM providers and Email (old routes redirect), edit and delete forms that name the apps, and an amber "Needs setup" on tiles and in the list. Gaps: not tried in a browser or on a running brain; job outcomes are not audited done
install-setup-doc-gaps.md — A doc audit of the install setup plan (#503 to #508), after ai-bindings-after-install.md. One code bug fixed: a user delete dropped the user's AI and email accounts by cascade but left their values in the apps, so a household app kept the deleted user's key and never showed "needs setup". The delete now clears the AI values in the same transaction as the user row (store.DeleteUserAndAccountValues, sharing its loop with the account delete), puts them back on a host failure, and runs a user-delete job that rewrites each app in one pass and restarts it once; it answers 200 with the job id, or 204 when no app was reached, and the Users screen follows the job. After review the bindings are read inside the delete transaction, and a failed delete never restores a binding over one written since. Docs: INSTALL_SETUP.md is marked as the record of the plan with the specs as the source of truth; the base URL removal rule reaches SERVICE_PROVISIONING.md; "install dialog" becomes "install setup page" in four specs; SETTINGS.md gains Installed apps and DASHBOARD.md SSH; the authoring guide covers role, requires and --ai-providers; NEXT.md gains the deferred limits; Up next gains the store tagging. Gaps: capabilities.yml left alone on purpose (no gap-class closed); job outcome not audited; not tried on a running brain or in a browser done
cloud-lane-ssh-state.md — The v0.14.0 boot proof failed on its ssh boot, with the other five green. The box was right: ssh-draft-save.md (#501) made PUT /api/v1/me/ssh take the whole state and removed the key routes, but dev/cloud/cloud-assertions.sh still called them. The boot now sends whole-state saves, including an explicit empty key list for the refusal check and the end-state guard in place of the removed per-delete one. hosted-boot-proof.md and CLAUDE.md now name all six boots. Gaps: no unit test for the script; the lane run is the proof done
userns-remap-pretest.md — Step 0 of #516, after the June spike ../dev/spikes/per-app-userns-remap.md. 58 listed catalog apps plus a poznote fixture ran through the real brain as root on a Debian 13 GCE box with Docker 29.8.1, in three daemon modes: A (containerd store), B (classic overlay2), C (overlay2 with daemon-wide userns-remap on host UIDs 1000000 to 1065535, with the #516 tiers). No app failed only under the remap. 47 pass in A and C. poznote and mealie pass only in C, with the five capabilities back, and own their data at base+82 and base+911. Managed Postgres, MariaDB, MySQL and Valkey own their data at base+999, service_user apps at base+UID, and all eight folder apps ran with host userns. Probe 8 passed: remapped root with the capabilities cannot touch a real-root 0700 bind. plunk and formbricks wrote their baked paths as their own USER under the remap alone, so the re-own layer (Option A) may not be needed. The remap costs nothing measurable (B vs C). Pre-existing bugs: mealie crash-loops under a root brain like memos did; quay.io/minio/minio now needs a login, which breaks cap, langfuse, plane and trigger-dev; forgejo's pinned amd64 manifest is gone from Codeberg; jotty is refused by admission; nocodb and open-seo fail their health checks in every mode. nocodb's cause was found later: managed Valkey refuses its FLUSHDB, now a ledger entry (redis-flush-at-start). mealie, the MinIO apps, forgejo, jotty and nocodb were fixed in their catalog packages the same day. A later calibre-web test shows a limit: the remap as designed only helps folderless apps. One daemon-wide range cannot map a container to a real user, so folder apps stay on today's sandbox. That answers NEXT.md open question 2 for daemon-wide remap, and NEXT.md gets the status. The spike's sysbox claim is corrected. Verdict: go for the #516 proofs in the CI lane. Gaps: fake host-agent, so no distinct service_user identities; boot only, no recreate, reboot or GPU; A's store numbers are not usable done
userns-remap-ci-proofs.md — The #516 proofs on the booted hosted image, after userns-remap-pretest.md. A throwaway branch turned on a daemon-wide userns-remap in the production image (daemon.json, a moose-remap range in /etc/subuid and /etc/subgid, SUB_UID_COUNT 0 in login.defs) and ran the CI / Cloud image lane with host-agent-real and the real control plane. Every proof the lane can run passed: the socket proxy and the brain run with --userns=host and Caddy and moose-ui remapped (proof 1); poznote in the caps tier writes and reads a note through Caddy, keeps it across a container recreate and a real reboot, and owns its data at base+82 (proof 2); whoami, memos and memos-pg work (proof 3); a household moose-shared grant writes as moose-app:moose-shared in host userns (proof 4); managed Postgres owns its data at uid base+999 (proof 6); two service_user apps run as base+2100 and base+2101 (proof 7); a table test over writeOverride shows host userns never comes with restored capabilities (proof 9). GPU (proof 5) is not tested. The socket proxy failed as predicted (haproxy cannot create /run/haproxy.pid on remapped image files with no capabilities) and a tmpfs /run fixes it with no capability back. The classic overlay2 store that the remap forces breaks nothing: first-boot loads and the full update and revert boot pass. Not caused by the remap: a containerized brain cannot prepare a household shared folder (it cannot see /srv/moose/shared), and one app lost its Caddy route once after a failed admin call. Verdict: go for the implementation issue. Gaps: QEMU lane only; appliance image not built; small app set done
brain-folder-sources.md — Closes #519, the bug userns-remap-ci-proofs.md found. On a real box the brain runs in a container that saw neither /srv/moose/shared nor /home, so a household folder install failed ("stat /srv/moose/shared: no such file or directory") and a personal folder was made inside the brain's own container, leaving Docker to create it root:root on the host. The shared tree is now mounted into the brain, but /home is not. host-agent makes sure /srv/moose/shared is root:moose-shared 02770 on every start, before the launch, on both profiles (brainlaunch.EnsureSharedTree), and mounts it read-write at the same path. Personal folders move to a new narrow op, POST /v1/users/{username}/prepare-folder: the path must start with a use-case folder, the home comes from /etc/passwd, and the walk uses openat(O_NOFOLLOW) so a symlink in a home is never followed. The brain's shared prep now refuses a symlink or a file too. Reason for the split: the shared tree is household space the brain already prepared, while a home is one user's 0750 space and home access stays a named host-agent op (DECISIONS.md 2026-09-29). The CI / Cloud image access boot installs a new synthetic filedrop fixture household and personal and finds each file on the host with the expected owner. Gaps: a symlink a member swaps into the shared tree after install is followed on the app's next start (both reviewers; needs a STORAGE.md decision such as a sticky bit); an existing brain container gets the mount only when it is next created (first boot or control-plane update); a later data-drive mount over /srv/moose would not reach the brain; appliance medium lane not run done
install-steps-design.md — The design for the next install flow, after install-setup-doc-gaps.md. No code. A UX review of the setup page against the 112 store manifests found that every question shows at once, and that the Save inside the AI row leaves Install disabled when missed. New INSTALL_STEPS.md: one question per page; a last page with every answer and Change; repeat installs go straight to it; saved keys first, then the service grid in popularity order with no Recommended badge; a key form with a default name; no model choice at install; three need levels with a proposed recommends list; permissions and size in a quiet box on the first and last pages. Email: Gmail and iCloud become the first presets, and the old "iCloud is 465-only" exclusion is corrected. Two DECISIONS.md entries; NEXT.md gains key checks and early download. Gaps: not built; three open questions; the new presets need a real send from a hosted box done
userns-remap-spec.md — The spec change for #516, after userns-remap-ci-proofs.md. Docs only. Writes down the daemon-wide userns-remap that #516 proved: a moose-remap range, SUB_UID_COUNT 0, the classic overlay2 store, and three tiers per container (default, caps, host), with the brain picking the host tier from folders, gpu and devices, never a manifest. Adds the manifest intent field root_setup (folderless only; the image's entrypoint must chown or drop privileges as root) for the caps tier. THREAT_MODEL.md records the accepted shared-range trade-off, and DECISIONS.md 2026-09-29 why not sysbox CE. The NEXT.md item closes; the build and the proofs still to run are #523. Open: images that must run as their own baked user (plunk, formbricks) done
brainlaunch-userns-host.md — Slice 1 of #523, closes #526, after userns-remap-spec.md. host-agent now launches the Docker socket proxy and the brain with --userns=host, and the proxy also with --tmpfs /run (brainlaunch.RunSpec.UsernsMode and Tmpfs). Both are always passed and change nothing on a daemon without the remap. No capability is given back. The control-plane update builds the brain from the same spec, so it keeps the flag. Gaps: an existing proxy or brain is not recreated for the flag (not needed: turning the remap on moves Docker to a new data root); not proved on a remapped daemon here, the spike did that and #531 will keep proving it done
remap-base-well-known.md — Slice 2 of #523, closes #527, after brainlaunch-userns-host.md. The real host-agent reports remap_base on GET /v1/identity/well-known: the start of the moose-remap range, read from /etc/subuid and /etc/subgid (usermgr.LinuxUserManager.RemapBase). Both files must agree, or the call answers an error, not a guess; a missing line (or file) means no remap and the field is left out. A malformed line, two lines, a start of 0, different counts, or a range under 65536 ids are errors too. protocol.WellKnownIdentityResponse.RemapBase is a *int with omitempty, so an older side sees nothing new. The fake never sends it. The brain does not read it yet (#529). Gaps: a broken range fails the whole call, so folder installs too (the spec's rule; no image writes the line yet); not run in the cloud lane, since the image has no range done
root-setup-manifest-field.md — Slice 3 of #523, closes #528, after remap-base-well-known.md. The manifest gains root_setup (bool, omitempty, manifest_version stays 1), and admission.CheckManifest refuses it with a folders grant, gpu: true, devices or service_user: true, each with a plain message. moose manifest check now runs CheckManifest too, so catalog CI refuses at publish what the brain refuses at install (it ran only the compose rules before, so it also missed service_user with folders). Nothing acts on the field yet: a lifecycle test shows the override is byte for byte the same. Gaps: until #529 a root_setup app installs in the default tier and fails at start, so the catalog must not publish one yet done
caddy-route-swap.md — Closes #520, the route loss userns-remap-ci-proofs.md saw once in run 36490829133. upsertRoute deleted an app's route and then PUT the new one, so one failed PUT ("connection reset by peer") left the app on the catch-all 404. A route write is now one in-place PATCH /id/<id>, which lands whole or changes nothing, so a failed write keeps the old route serving and returns the error. Only a 404 ("unknown object ID") falls back to PUT .../routes/0. Adding first and removing after was ruled out: Caddy refuses a duplicate @id. A call with no answer is tried up to 3 times; an answered error or a timeout is not. EnsureDashboard uses the same path. The startup pass now writes the splash for stopped and failed apps, which lost their route on every brain restart; running apps were already re-asserted. After review the splashes go last, so they cannot use up the startup deadline before a running app's route. Tests use a fake admin API with a real route list that fails any chosen call; the client was also run against a real caddy:2.11.4. Gaps: the startup pass is still the only repair if all 3 tries fail done
brain-userns-tiers.md — Slice 4 of #523, closes #529, after root-setup-manifest-field.md. The brain puts every app in one of the three tiers (internal/lifecycle/userns.go). Before every install it reads remap_base from host-agent and checks docker info for name=userns; it refuses every install while they disagree, and a root_setup install with no remap. On a remapped daemon: userns_mode: host for folders, gpu or devices (host tier), five capabilities and no user: for root_setup (caps tier), never both; bind dirs go to base+uid or base, new managed-service data to base. The tier is stored (instances.userns_tier, default host: every existing instance, and every install with no remap, runs in the host namespace with real host owners). With no remap the override is byte for byte the same (golden files written by the old code). Proof 9 is a real table test and fails on a deliberate break. A throwaway remap boot (runs 36596370743, 36598956852) showed data at base+0 and base+2100, poznote with the five caps and no user: at base+82, and a folder app UsernsMode=host. plunk fails in the default tier and gets past its baked write in the caps tier; formbricks v6 stopped at SpiceDB env checks. Gaps: no app update path, so the tier refusal has no caller yet; turning the remap on under existing apps is not repaired; a product call on root_setup for baked-user images done
image-user-tier.md — Part of #523, closes #537, after brain-userns-tiers.md. The manifest gains image_user (bool, omitempty, manifest_version stays 1) for a folderless app that must run as the user its image sets. On a remapped daemon the brain puts it in a fourth tier, image: remapped, cap_drop: [ALL], no capability back and no user: pin. After the pull it reads each service's Config.User; a number is used as it is, and a name is looked up in the image's own /etc/passwd and /etc/group, copied out of a container that is created and never started. Each bind dir goes to base+uid:base+gid of its service. Refused: with no remap, with folders, gpu, devices, service_user, root_setup or a user: in the compose, and for a name the image does not list, an id past the range, or a dir two users share. With no remap every override is byte for byte the same. The never-host-with-caps table test now also checks no host namespace without a user: pin, and fails on two deliberate breaks. A throwaway remap boot (runs 36609452121, 36613039852, 36615206322) ran a synthetic two-image fixture at base+1001 and base+1002 with both writes working, refused an unknown name plainly, and ran plunk as its own user with no capability until its Prisma download. Gaps: no update path to check that the image user stays the same; a dev database from #536 keeps the old CHECK; an image picks which uid in the range it runs as done
userns-remap-on.md — Slice 5 of #523, closes #530, after image-user-tier.md. Both images turn on Docker's daemon-wide userns-remap: "userns-remap": "moose-remap" in daemon.json, a moose-remap system account, moose-remap:1000000:65536 in /etc/subuid and /etc/subgid, and SUB_UID_COUNT 0 / SUB_GID_COUNT 0 in login.defs, in dev/cloud/ and dev/test-qemu/. No package added (lean check 162). Every cloud boot and the appliance lane now check it on the booted box: docker info lists name=userns and overlay2 at /var/lib/docker/1000000.1000000, the proxy and the brain run as host uid 0, Caddy and moose-ui as 1000000. The access boot's image_user step now checks the fixture runs remapped at base+1001 and base+1002. All six boots pass (run 36636692363). BUILD.md gives the #486 answer: new boxes get the remap, older boxes keep it off, and the OS update rule lives on #486. The appliance image booted once with the remap on and passed the same check; the lane then stopped on an old moose-brain:latest tag bug in its one-shot check, fixed here. Gaps: the full appliance lane has not passed, and must run on another machine; no remap-off box left in the lane done
install-steps.md — Steps 1 to 3 of the plan in install-steps-design.md. The install is one need per step (AI needs, email, required settings, folders), each step listing the saved accounts with the newest picked, and the last step installs; an app with no steps installs from the App page, which now lists its permissions. Adding an account is a sub-flow with only Back and Continue. The brain's install plan lists the copies the caller can see, so the first step warns; config 422s carry a location, so an error opens its step. New icloud preset, Google Workspace renamed "Gmail or Google Workspace". The UI says "AI service". The design changed during the user test (no summary page, offers for optional needs, folder consent step). Real Gmail and iCloud sends, the user test and recommends are left.
startup-wait-docker.md — Closes #540, found by the first remap-reboot boot of #531. After a reboot Docker starts the socket proxy and the brain together, and the brain was up a second before the proxy answered: its one startup reconcile failed at docker ps, so no app got its Caddy route back and every app answered 404. Manager.WaitDocker polls the same docker ps until Docker answers, and cmd/brain calls it once before any startup Docker work, for up to 15s (well under the 60s host-agent gives a recreated brain). The reconcile still runs once. Tests: a reconcile into a failing Docker adds no route, and after the wait it does; the wait gives up with the last error. Gaps: host-agent is not waited for; a Docker down for over 15s at start keeps the old behaviour done
userns-remap-boots.md — Slice 6 of #523, closes #531, after userns-remap-on.md. Two new cloud-lane boots, remap and remap-reboot, on one overlay of their own: the first installs one synthetic app per tier (remapdrop folderless and svcdrop service_user in the default tier, rootsetup root_setup in the caps tier, pgnote on the managed Postgres, filedrop household in the host tier) and checks each one's userns mode, user:, capabilities, process host uid and data owner, then recreates the caps-tier container and checks its token is kept. The second reboots the same disk and runs every check again. Both check the new SSO owner has no /etc/subuid or /etc/subgid range. A boots input on the manual run picks which boots run, and the script refuses an unknown name. The reboot boot found #540 (apps lost their routes after a reboot), fixed in #541. Gaps: a PR run still boots only update; the recreate is done by the lane; no real provisioned box or GPU done
userns-remap-close.md — Closes #523 and #516, after userns-remap-boots.md. No code. Lists the entries and PRs that shipped the daemon-wide userns-remap, and the proof on dev as merged: CI / Cloud image run 36694063081 with publish=false passed all eight boots, the two remap boots included. Product call: no test on a real provisioned hosted box; the QEMU lane and the Step 0 GCE run are enough. Next release: raise minimumAgentVersion in cmd/brain/main.go (still 0.4.0) to the new version in the release PR, since the brain now needs prepare-folder (#522) and remap_base (#534); the floor only reports, so the per-box update target guards older hosted boxes. The spike branch and the slice worktrees are removed. NEXT.md now says the build is done. Gaps: full appliance lane not run (no CI job); the OS update must keep each box's remap (#486); checkTierKept has no caller until the app update path exists done
userns-remap-capability.md — Closes #545, after userns-remap-close.md. No code. capabilities.yml gains userns-remap (version 2), naming root_setup and image_user, folderless only, and only on boxes built with the remap on (after #530); the service-user summary no longer calls it unshipped. That fires the store's re-screen for records waiting on it. The gap ledger gets dated updates, headings unchanged: poznote (caps tier), formbricks (caps tier, still blocked on SpiceDB) and plunk (image tier) are implemented; the nextcloud and penpot entries stay open. The authoring guide gains a "Which tier" section and says make dev-app cannot boot these apps, since the inner loop has no remap; the boot goes to a remapped box per the store's remote curation box doc. APP_ISOLATION.md no longer says these images wait on the remap. Gaps: the dev loop, and the store's remote curation box as it is, still cannot boot them (#548) done
hide-remap-apps.md — Closes #544, after userns-remap-close.md. On a box with no userns-remap the store leaves out apps that set root_setup or image_user, since the brain refuses to install them there. Manager.RemapState makes the install path's two reads (host-agent's remap_base, Docker's name=userns) and caches the answer (10 min known, 30 s unknown). Catalog.WithoutRemapApps() drops them from the list, search, landing (spotlight, groups, featured, pills) and category routes; the detail route still loads. The install plan gains unavailable: "needs-remap", read from the manifest, and the detail page and install pages show one plain sentence in place of Install. An unknown state hides nothing. DECISIONS.md 2026-09-30: the remap is a fact about one box, so this filter runs on the box. Gaps: the published browse record does not carry the two fields yet, so the lists hide nothing until the catalog publisher sends them (the install plan works now); the pinned fixture waits for the publisher's output done
fake-agent-remap-base.md — Closes #548, after userns-remap-capability.md. The fake host-agent now reports remap_base from the moose-remap lines of /etc/subuid and /etc/subgid, read by the real agent's code (usermgr.ReadRemapBase, which LinuxUserManager.RemapBase now calls). No line: no field, as before, so make dev still refuses these apps (and #544 marks them not installable). A hand-made range: reported, so the dev brain can boot root_setup and image_user apps there. A bad line: an error, like the real agent. running-locally.md # Booting apps that need the remap says how, and the authoring guide points there. Gaps: not run on a remapped machine; the unprivileged dev brain cannot give bind dirs or managed-service data to the range, so those apps need the brain as root there done
fixture-remap-fields.md — Closes the fixture gap of hide-remap-apps.md. internal/catalog/testdata/snapshot.json is refreshed from the catalog publisher's own output, which now sends root_setup and image_user on each browse record; only the version token and the two keys on alpha-notes changed. TestNoUnmodeledFields now covers both, and TestParseFixtureSnapshot checks they reach the parsed record. Gaps: the live catalog sends them only after the store release done
claude-md-skip-small.md — Closes #553. The "Skip small issues" rule in CLAUDE.md # Working style now covers proposals and questions, not only issues. Only a big improvement, a breaking change for users, a real bug or a security risk is raised; everything else is dropped, with no "one line at most" allowance. A small open question is never repeated across replies, and an item the user says to ignore is dropped for good. It still lets through a design decision the task needs and the findings of a requested review. Docs only done
ab-update-engine-spike.md — Closes #485. A spike, with throwaway code on the never-merged branch test/485-ab-update-spike. RAUC and systemd-sysupdate each built a small trixie box (mkosi v26) with two 3G root slots and a data partition that first boot makes and grows. In QEMU each box updated to v2, then to a broken v3 that fails its health gate, and came back to v2 on its own with no console. All four proofs pass for RAUC under UEFI and legacy BIOS (one GRUB config for both), and for sysupdate under UEFI. sysupdate fails under BIOS: no native rollback, because systemd-boot is UEFI-only. Per-box state (the userns-remap files, password hash, SSH host key, machine-id) survives the swap through an /etc overlay on the data partition and a short pinned list, so the #486 rule holds. New system users must come from sysusers.d. Found: Debian puts RAUC's daemon in rauc-service, and trixie's systemd-import writes a .zst through without unpacking it and reports success. UEFI-only hosting would mean CPX22 at €19.49 instead of CX23 at €5.49. Recommendation: RAUC with GRUB, whole-root slots, verity slots and a LUKS data partition on the appliance. Gaps: QEMU only; a slot that hangs instead of rebooting never reverts on either engine; Secure Boot, verity and signing not tried. Docs only done
ab-os-update-design.md — First slice of #486, after ab-update-engine-spike.md. No code. Stream A is an A/B OS image on both profiles, built with RAUC and GRUB; apt and unattended-upgrades leave the update path, and host-agent ships inside the image. Two maintainer calls: appliance OS updates apply and reboot in the window with no prompt, and a moose release is the OS release while the control plane gets its own version (CONTROL_PLANE_VERSION, control-plane-vX.Y.Z), partly reversing 2026-07-16. UPDATES.md # 1 and # 2 are rewritten as the update transaction; new BUILD.md # 1b holds the layout (two 4 GiB slots, a state partition with the /etc overlay, four pinned files now including login.defs), the signed bundle and the OS package lock (a Debian snapshot timestamp, Docker pinned beside it, bot bump PRs). NEXT.md gains six open points to prove before shipping. #486 is split into #559 to #564. Gaps: nothing built; the version split is documented, not implemented (#559) done
control-plane-version-line.md — Closes #559, a slice of #486. Two version lines are built. VERSION is the moose (OS) release and stamps host-agent; a new CONTROL_PLANE_VERSION (starting at 0.15.0) stamps the brain and labels both control-plane images. moose-brain --version now prints moose control plane X.Y.Z (g<sha>), host-agent keeps moose X.Y.Z (g<sha>). release.yml decides each line on its own through dev/release/decide.sh (tested against throwaway git repos in make check): a VERSION bump tags vX.Y.Z and attaches the disk image, a CONTROL_PLANE_VERSION bump tags control-plane-vX.Y.Z (Release with --latest=false) and pushes ghcr images tagged vX.Y.Z with the control-plane number, because the private control plane resolves digests by that tag. A control-plane-only release still runs the full boot proof and skips only the attach. A control-plane version whose image tag is already published is refused rather than overwritten, so control-plane-v0.15.0 must be tagged once by hand at v0.15.0. Gaps: the disk image still bakes the same commit's control plane (#566); About shows the control-plane number as moose until #509 done
os-package-lock.md — Closes #560, a slice of #486. Every package in the hosted image is locked. dev/os-lock/ holds a snapshot.debian.org timestamp (mkosi v26 --snapshot, debian-security included, trixie-updates added back from the same snapshot), exact pins for the four Docker packages (apt Pin-Priority: 1001), and the resolved list of 163 packages, made from mkosi's manifest by the new dev/os-lock/oslock tool. Both cloud builds (the shipped image and the boot-proof image) fail when they resolve to a different list, so CI proves two builds of one commit install the same versions. os-lock-bump.yml moves the lock forward daily and names every changed package; with the OS_LOCK_BOT_TOKEN secret it opens one PR into dev, without it it boots the branch itself and leaves an "open a PR" link in the summary and a tracking issue. A PR touching dev/os-lock/ runs the full boot list. Decided: a lock-only OS patch release is cut from main as hotfix/X.Y.Z, within 7 days when a trixie-security package changed. Gaps: the token path is checked only by the first bump after merge; the appliance lane and the mkosi tools tree are not locked (#564) done
docs-after-version-split.md — Docs-only sweep after os-package-lock.md. Fixes three statements #559 and #560 made wrong: CLAUDE.md no longer lists apt as unwired (it names the A/B OS update) and describes the per-line publish inputs; BRAIN_HOST_PROTOCOL.md # Versioning is a minimumAgentVersion floor, not "brain N talks to host-agent N", and apt leaves its scope list; # Up next gains the A/B OS update with #561 next. Docs only done
hosted-ab-layout.md — Closes #561, a slice of #486, after docs-after-version-split.md. The hosted image is built in the A/B layout: a 128 MiB ESP, a BIOS boot partition and slot A, a 1 GiB read-only squashfs-xz holding the kernel and initramfs, in the image; slot B (1 GiB) and a state partition (ext4, the rest of the disk, grown every boot) made by systemd-repart in the initramfs at first boot. The OS reserves 2.28 GB, 5.7% of a 40 GB disk. The maintainer rejected the designed 4 GiB ext4 slots (DECISIONS.md 2026-10-01). GRUB boots both firmwares from one grub.cfg and grubenv; the ESP holds no kernel (Bootable=auto) and is built with 512-byte sectors so 128 MiB can be FAT32. An initramfs-tools hook sets up the /etc overlay, four pinned files and the bind mounts; databases keep their own bind mounts and /home comes from srv/moose/home. rauc with the slot config, no keyring. dev/cloud/slotbudget fails the build when the squashfs fills over 60% of its slot (now 435.3 MB, 40.5%). host-agent's hosted build reports the state partition as "System". Every boot runs under UEFI and legacy BIOS and checks the layout; run 36932849491 passed all 14. Gaps: no bundle, install or mark-good (#562, #563); QEMU only; the control-plane tarballs are copied to the state partition (383 MB); the private smoke test needs a disk of 20 GB or more done
ci-cloud-image-speedup.md — A slice of #486, after hosted-ab-layout.md. CI / Cloud image builds once and boots in parallel: a full run drops from 27.4 to 12.2 min (run 37001109373). One build job makes both images and uploads the boot-proof image as a qcow2; a boot matrix runs one job per boot group and firmware (10 for the full list, unseeded seeded kept together because they share a disk); a publish job, the only one with write access, runs only after every boot passed and pushes the control-plane tarballs the image baked. Every publish guard, the PR update-only rule, the lock-bump full-list rule and the boots input are kept. The boot-proof build reuses the production tools tree (MOOSE_MKOSI_TOOLS_TREE, about 40 s), the QEMU packages are downloaded once and installed with dpkg (one slow mirror once cost a job 6 min), and runs that publish nothing build the pinned hosted Caddy image with a BuildKit gha layer cache (MOOSE_BUILD_CACHE; the brain and UI build plain, because their cache cost more than it saved). Runner minutes go from 27 to about 34, free on a public repo. Gaps: every new branch starts with an empty Caddy cache (about 1.2 min on its first run); no apt cache (measured at about 15 s, dropped by decision); the boot-proof image is still a second mkosi build; the publish job has not run yet done
rauc-bundle.md — Closes #562, a slice of #486, after hosted-ab-layout.md and ci-cloud-image-speedup.md. Every OS release builds a signed RAUC bundle (moose-vX.Y.Z-amd64.raucb, verity format): slot A of the image that ships, cut out by dev/cloud/slotbudget -extract, bundled by dev/cloud/build-bundle.sh with the box's own RAUC version from the snapshot, and checked against the system.conf and keyring read back out of the slot (the staged keyring, the throwaway root, a release keyring that must refuse the throwaway signer, a wrong key, and a rehearsal of the publish job's signing). The image bakes /etc/rauc/keyring.pem with check-purpose=codesign: the release root on an OS publish run, a throwaway root otherwise. Key custody decided (DECISIONS.md 2026-10-02): an offline root CA (dev/release/rauc-ca.sh, docs/dev/rauc-signing.md) and a signer in the os-release environment, so only a sign job of its own reads the signer, re-signs with rauc resign a bundle whose sha256 matches the build job's, and hands it to the publish job, which attaches the image, the bundle and their checksums as one set of four (it detects and refuses a mixed set). release.yml tags nothing for a VERSION bump until release-ca.pem is committed. Numbers: bundle 438.5 MB, 40.8% of the slot and half of an 874 MB OS release; about 1 min more per run. Gaps: no release path has run yet (no release-ca.pem committed; release.yml tags no OS release until the maintainer's setup); no rauc install proof (#563) done
host-agent-os-update.md — Closes #563, a slice of #486, after hosted-ab-layout.md and rauc-bundle.md. host-agent applies OS updates on hosted. The update-target answer gains an optional os list; host-agent picks the next release (never skipping a minor, at most one minor back, never below the control plane's floor), downloads the bundle, checks its sha256 against the answer before RAUC sees it, installs it into the other slot ahead of the window (activate-installed=false), switches inside it after stream B (checking the grubenv reads OK=1 TRY=0), and on the next boot marks the slot good once the brain answers or reboots back; an image timer reboots a slot whose host-agent never started. Only the first boot after a switch is on trial. Stream A is reported on the version and update-target reads, and admins get one notification per outcome. New os-update and os-revert boots prove both paths under UEFI and BIOS, in every full run. Gaps: the private control plane does not send the os part yet (wire described for the maintainer), QEMU only, appliance waits for #564 done
uefi-grub-try.md — Closes #575, a slice of #486, after host-agent-os-update.md. GRUB's try flag now survives under UEFI in the boot lane. The cause was the harness, not GRUB or the image: it ran OVMF with no writable VARS store (Ubuntu 24.04 renamed the files), so OVMF saved its variables to an NvVars file on the ESP at every boot and lost GRUB's save_env write (traced in runs 37061482623, 37063254219 and 37064755329: GRUB wrote and read back A_TRY=1, the initramfs read A_TRY=0). The harness now needs a CODE and VARS pair. Every boot checks GRUB saved <booted slot>_TRY=1 and the ESP has no NvVars, under both firmwares, and os-revert gains a last stage: slot B made active again crashes its kernel in the initramfs (a test-only hook), and GRUB must skip it. The trial timer stays on the marker, not the grubenv. No image change, no disk cost. Gaps: QEMU only, a real UEFI provider box not checked for NvVars; the appliance medium lane has the same OVMF fallback (#564) done
bake-released-control-plane.md — Part of #566 (kept open until the released path is green), a slice of #486, after control-plane-version-line.md. An OS-only release bakes the last released control plane: the brain and UI of v<CONTROL_PLANE_VERSION>, resolved to digests once and pulled from ghcr by digest (make control-plane-released, dev/release/ghcr-resolve.sh). A control-plane release, or one that bumps both files, bakes the pair it builds and releases; a PR, a lock bump or a dispatch bakes a build of its commit (-f control_plane=released checks the release path ahead). The image records the pair in /usr/lib/moose/control-plane.env, and every boot checks the running brain and UI against it. The released run (37345392679) baked 0.15.0 and passed the six original gate boots under both firmwares; os-update and os-revert fail with it because the 0.15.0 brain predates #563, so the next OS release must also release the control plane.
ab-hang-and-major.md — A slice of #486, after hosted-ab-layout.md and host-agent-os-update.md. Settles points 3 and 5 of NEXT.md # A/B OS image. A slot that hangs: real Hetzner cx23 VMs (Intel and AMD hosts) are QEMU q35 with the ICH9 TCO watchdog; the image's generic kernel drives it (iTCO_wdt), and with PID 1 frozen the VM reset after 118 s. No image change; every boot checks systemd feeds it. Gaps: the reset comes after about twice the 60 s timeout, and a hang in GRUB or the initramfs is not covered. A Debian major: when the slot's major differs from the one the state partition records, in either direction, the initramfs tidies the /etc upper layer: the keep list (etc-keep.list) stays, the account files are merged, the pinned files are taken again from the slot when the remap stays, the rest goes to an attic; a failure never stops a boot. host-agent turns sshd back on once after a tidy-up when the drop-in names an account. The image's accounts are a generated sysusers.d file, with ids checked against dev/os-lock/cloud-accounts.lock. The os-update boot fakes a major, and every boot fails on an upper-layer file no rule covers. It must ship in a Debian 13 release before the first 14 release done
os-switch-undo-marker.md — A fix to host-agent-os-update.md (#563), part of #486, found by Greptile on the 0.16.0 release PR (#583). A failed switch undo keeps the trial marker: doSwitch removed the new slot's trial marker even when it could not put the booted slot first again, so the new slot could boot next with no trial. The marker now goes only after the booted slot is first again; Boot already removes a marker for a switch that never took effect. Unit test only done
bare-box-ids.md — The box-id is the name the owner chose. It was a typed base plus a system-assigned suffix (cindy-fox); it is now the owner's name, used as given (andrei, so andrei.onmoose.io). MOOSE_NETWORK.md replaces the suffix section with "Locked: the box-id is the name the owner chose": 4 to 30 characters of lowercase letters, digits and single inside dashes, a reserved list that covers every name with its own record under the fleet domain, a taken name refused with no reroll, and a destroyed box's name held for its former owner for one year. Older dashed ids stay valid. FIRST_RUN.md Step 5 and NEXT.md follow, and DECISIONS.md 2026-10-06 records the flip. The box's behavior is unchanged: it treats box_id as opaque and does not check its shape. Comments in internal/profile updated; bare-id tests added for the hosted hosts, cert subjects, a seed round trip, the cert-subject split and the forward-auth cookie domain. Gaps: the reserved list, uniqueness and the hold are enforced by the issuing service, not the box done
pull-rate-limit-retry.md — Retry an image pull the registry rate-limits (#586). A box pulls without logging in, and a short 429 spike from ghcr rolled back a working install. pullWithRetry in internal/lifecycle/pinning.go retries a pull whose error is toomanyrequests or 429 Too Many Requests, with 2, 4, 8 and 16 second waits, and logs each retry. Every other pull error still returns at once, so the offline fallback is unchanged. APP_LIFECYCLE.md # Locked: image digest pinning says so. Gaps: the retry-after hint is not read; other transient pull errors are not retried; not tested against a real rate-limiting registry done
image-backup-sources.md — Pull an image from a backup source when upstream fails (#588), after pull-rate-limit-retry.md. The manifest's images entries gain an ordered sources list. pullImage in internal/lifecycle/pinning.go pulls upstream first; on a 429 it waits once, retries, then tries each source and finishes the ladder on upstream; any other registry or network error tries the sources at once; local errors, a cancelled install and offline mode never move; when all fail, upstream's error is reported. The override names the pulled <ref>@<digest> and sets pull_policy: never; the brain pulls any missing image before every later compose up. instance_images gains a ref column so uninstall removes a source-pulled image. Fake-driver tests per error kind, a dockerlive test with a local registry, and a hosted-box run. Gaps: a source is tried once; local errors are a text match; no login (auth is skipped) done
store-landing-sections.md — The box store draws its landing from the catalog's sections (#591). The brain models home.sections and packs, resolves them against the apps it holds (a missing id drops from its slot, a pack with any missing app drops whole, empty things drop whole, unknown section types drop), and drops remap-needing apps and the packs that hold them. /catalog/home adds the sections, /catalog/pack?id= serves one pack (404 when not on this box), /catalog/search returns apps then packs, and /catalog/illustration?key= proxies slide and pack art. The UI draws search, discover, intents, packs and categories in catalog order, keeps the older landing when there are no sections, and adds the pack page, where each app has its own Install (useStartInstall, shared with the detail page). Gaps: the pack route takes ?id= because /catalog/packs/{id} clashes with /catalog/{id}/install-plan in the mux; no install was run from the pack page done
ssh-screen-on-then-key.md — SSH screen: turn SSH on, then add a key, then Save, after ssh-draft-save.md. The top switch always moves, the authentication methods show only while it is on, turning it on with no key on hosted opens the add-key form, turning it off drops unsaved method changes, and Save stays in view but disabled while hosted has no key, with the reason beside it. Each line of copy is one fixed text. A key is checked at "Add key" through a new POST /me/ssh/check-key that runs Save's parse and stores nothing. Gaps: the Save reason reuses the old message done
hosted-owner-sudo.md — The hosted owner uses sudo over SSH with no password (#602), after ssh-screen-on-then-key.md. The portal gives the owner a random password nobody knows, so sudo was a dead end and SSH rescue did not work on hosted. The brain now sends passwordless_sudo on set-access for the hosted owner only, while SSH is on. host-agent writes /etc/sudoers.d/moose-owner (root 0440, checked with visudo -cf), puts it in the same undo set as the keys and the drop-in, and at every start removes it unless the account it names has SSH on. The appliance never writes it; accounts the owner creates keep the prompt. DECISIONS.md 2026-10-11 revises 2026-05-15 for this one account; APP_ISOLATION.md gains the invariant that no app code runs as the owner's UID outside a container. Unit tests on both sides and the cloud lane's ssh boot. A role change pushes the owner's SSH again, so a demoted owner loses the rule. Gaps: #604 (a folder app can swap another app's folder source for a symlink to ~/.ssh between starts; this change raises that from an owner shell to root, so #604 should land first or with it); a box with the owner's SSH already on gets the rule at the next Save; no Activity record yet (#601) done
system-settings-updates.md — Settings → System: versions, the update schedule, the last result, and Update now (#488), after update-target-read.md and host-agent-os-update.md. Every user sees the moose (OS) and control-plane versions; an admin on a hosted box sees the window in the box zone, the state as a sentence, the last control-plane update and Update now, a no-body route that applies the target the loop checked. host-agent keeps the outcome in last-update.json, and the loop reads the zone from /etc/localtime on every tick. The 426 path is not built. done
os-update-now.md — Settings → System covers the OS update (#509), after system-settings-updates.md. One sentence per OS state, the restart said before the click, both last results, and Update now for an OS release already installed in the other slot: the box switches and restarts outside the window, after the control plane (DECISIONS.md 2026-10-12). The wait follows the restart through os.last. The os-update boot now switches through Update now. done
confirm-dialog.md — A shared confirm dialog (components/ui/ConfirmDialog.vue), after os-update-now.md. The Update now confirmation was a <dialog> inside SystemSection.vue; it is now a shared part with a title, a body slot, Cancel and a confirm button, opened with open(). The System screen looks and works as before. done
app-health-docker-signal.md — app-unresponsive reads Docker's health status and the probe does not follow redirects (#609). Docker unhealthy on the main service fails the tick for every app, with or without a health_probe; starting and no healthcheck add nothing. The probe's client returns the first response, and on hosted the forward-auth gate's 302 to the box login is no sample, so an owner-only app is no longer reported healthy from it. The login URL is shared through profile.HostedLoginURL. DECISIONS.md 2026-10-13. Gaps: a tick that does not fail clears, as before; an owner-only app with no image healthcheck stays unflagged until #610 done
apps-off-ingress.md — Apps leave the control-plane network; Caddy's admin API is a unix socket (#608). No app joins moose-ingress any more, so no app reaches the socket proxy (host root, #430), the brain or Caddy's admin API. The brain attaches Caddy to each app's own network on install and start, takes it off before uninstall, and puts a recreated Caddy back on every running app's network after the startup reconcile and on each health tick. On a new box Caddy's admin API listens on /var/lib/moose/control-plane/caddy/admin.sock, in a 0700 dir host-agent creates for the remapped Caddy root, and the brain reads that address from the staged caddy.json, so an older box stays on TCP and keeps working (DECISIONS.md 2026-10-13). The access boot proves it from inside an app. Gaps: older hosted boxes keep the TCP admin API (#612); a recreated Caddy has no routes until the brain restarts (#613); the next OS release must also bump CONTROL_PLANE_VERSION done
caddy-admin-socket-migration.md — Boxes made before #608 move to the Caddy admin socket (#612), after apps-off-ingress.md. At startup, before its compose up, a brain that finds TCP in the staged caddy.json makes caddy/ for the remap base, keeps copies of both files, adds the one ./caddy:/run/moose-caddy line to the compose, points admin.listen at the socket, recreates Caddy and waits for the socket. If it does not answer, both files go back and the box stays on TCP until the next brain start. The move happens at once, without waiting for the revert window; the brain writing one compose line is a narrow exception to UPDATES.md # 8.3 (DECISIONS.md 2026-10-13). The access boot proves the failed move and the move. Gaps: the issue's "fresh previous pair" proof is dropped with the ledger check; a revert to a pre-#608 brain on a moved box cannot change routes until the next update done
caddy-probe-socket.md — The health probe reaches owner-only apps through a Caddy probe socket (#610), after app-health-docker-signal.md and caddy-admin-socket-migration.md. The brain writes a second Caddy server, moose-probe, through the admin API (not in the staged caddy.json, which never reaches an existing box), listening only on caddy/probe.sock next to the admin socket, with an ungated copy of every app route. The probe dials it when it exists; the marked catch-all (X-Moose-Probe: no-route) and a dead socket are no sample. A route write that finds the server missing after a Caddy recreate writes it again (#613). The access boot proves a 502 raises and a 200 clears on an owner-only app, that no app mounts the socket dir, and the restore after a recreate (DECISIONS.md 2026-10-13). Gaps: no sample still clears, as in #609; after a recreate only rewritten apps get their copy back until the brain restarts done
auto-restart-unresponsive.md — The brain restarts an app that stays unresponsive (#598), after caddy-probe-socket.md. When app-unresponsive has stayed raised for 5 minutes past the start-period grace, the brain runs a Stop then a Start under one lock (lifecycle.BeginRestart, the row stays running). The wait doubles per restart in the last hour (5, 10, 20 min), at most 3 restarts in any hour, then the issue says it stopped trying. Never for an app that is not running, has an operation in flight, or has its new per-app Automatic restart setting off (PUT /api/v1/apps/{id}/auto-restart). Each restart is an app.restart.auto audit record shown in Activity, and the issue names the last restart. Proved on the cloud access boot: probewho at 502 is restarted once and recovers, a copy with the setting off is left alone. Gaps: rolling-hour limit read literally, history in memory, UTC time in the details done
caddy-config-restore.md — The brain writes Caddy's config again after a recreate (#613), after apps-off-ingress.md, caddy-probe-socket.md and auto-restart-unresponsive.md. The staged caddy.json already has the catch-all, so the brain marks its own config (@id moose-configured on trusted_proxies, written with If-Match so a Caddy recreated mid-write is not marked). A health tick that finds the mark gone writes the server parts and the probe server with no route reset (caddy.Client.RestoreServer), the TLS policy, the dashboard route and every app's route or splash with its probe copy (Manager.RestoreRoutes, under each app's lock), then the mark; an app that is busy or mid-install is written on a later tick. It runs before the probe, and the probe skips an app Caddy is off the network of, so a recreate never causes an automatic restart. Proved on the cloud access boot: after a Caddy recreate with no brain restart, the app, a splash, the dashboard, :443 and the probe copies come back, and no app is restarted or flagged. Gaps: up to one tick of 404s, appliance lane not run done
auto-restart-give-up.md — The automatic restart gives up after one hour (#620), after auto-restart-unresponsive.md. After 3 automatic restarts that did not help (waits of 5, 10, 20 minutes), the brain never restarts the app by itself again and writes no more app.restart.auto records; the issue says it tried 3 times and that the user can stop and start the app to try again. The count is in the app's row (instances.auto_restarts, counted by lifecycle.BeginRestart under the lock), so a brain restart keeps it. A real healthy sample (not a tick with no sample), a person's Start or a change of the Automatic restart switch resets it and gives a fresh hour. The #598 cap of 3 restarts in any hour stays, in memory, for an app that recovers between restarts (DECISIONS.md 2026-10-13). Proved by unit tests on the real detector with a real store; lane not changed. Gaps: "one hour" is three restarts, not a clock; the hourly cap for a recovering app is still in memory done
ram-pressure-full-avg300.md — ram-pressure reads full avg300 and available memory (#597), after ram-pressure-detector.md. The placeholder signal, PSI some avg60 above 20%, never fired on a hosted box that was stalled on memory for 15 hours at full avg300 3.66%. The detector now raises when PSI full avg300 is at 2% or more, or MemAvailable is under 5% of MemTotal, and clears when both are back under. Each signal fails open on its own. The summary now says to stop an app the owner does not use; naming the heavy apps is #623. HEALTH.md records the field numbers as the first soak data point. Proved by unit tests against fixture files. Gaps: no hysteresis band; one soak data point; heavy apps not named done
app-fit-check.md — The store says whether an app fits the box, and the install refuses one that is too big (#599), after ram-pressure-full-avg300.md. The manifest now models resources.recommended, host-agent reports the core count, and the new internal/fit package says fits, tight (not next to the running apps; headroom is MemTotal minus the larger of their recommendations and the memory in use) or too-big (more memory than MemTotal rounded up to a whole GiB, or more cores than the box has). The brain reads the box once per request (lifecycle.FitBox) and puts the same fit on every store entry (Catalog.WithFit), the detail and the install plan, which also says unavailable: "too-big". The install transaction refuses a too-big app before any work (lifecycle.TooBigError). The UI shows a card badge, Memory and CPU rows, a notice naming the running apps, "Install anyway" for tight, and on hosted an Upgrade link for the owner, and refetches on app state changes. The store publisher sends resources on each detail record (store PR) (DECISIONS.md 2026-10-13). Proved by unit and API tests and against a running brain with the fake host-agent. Gaps: "Install anyway" is UI only; tight not run with real apps; no badge until the store PR is published done
fit-notice-placement.md — The fit is said on the app's page only, in a yellow alert, with Install disabled, after app-fit-check.md (#599). From the maintainer's testing: no badge on store cards; on the App page a yellow alert above the price panel ("This app can't run on this box as it is." / "It requires X of memory, and this box only has Y."), the same words as the brain's refusal; Install kept but disabled for a too-big app, also on pack pages, with no sentence there; and the same alert for "This might not fit." disk space (warning only). Catalog.WithFit is gone: Fit moved to Detail and the detail handler computes it, so list routes do not read the box. Gaps: card badge dropped by choice; install pages keep the old disk wording; tight not seen with real apps done