- Status: done
- Date: 2026-05-22
- Specs touched:
CONTROL_PLANE.md,APP_LIFECYCLE.md,APP_MANIFEST.md,BRAIN_UI_PROTOCOL.md,BRAIN_HOST_PROTOCOL.md,WEB_UI.md,APP_STORE.md
The first vertical slice of malmo. Goal: prove the architecture spine end-to-end with the fastest possible dev loop — everything runs natively on a dev box, no VM. The thread exercised:
UI → brain
POST /api/v1/apps(job) → generatecompose.override.yml+.env→docker compose up -d→ fake host-agentpublish(mDNS) → register Caddy route → SSEapp.installed→ UI updates → uninstall tears it all down.
A single Go binary (cmd/brain) with clean internal packages, matching the
"one binary, internal modularity" decision in CONTROL_PLANE.md:
internal/api— HTTP API via huma (OpenAPI emitted as a byproduct, perBRAIN_UI_PROTOCOL.md) plus the raw SSE event stream. Endpoints:GET /api/v1/catalog,GET/POST /api/v1/apps,GET /api/v1/apps/{id},DELETE /api/v1/apps/{id},GET /api/v1/jobs/{id},GET /api/v1/events. Install/uninstall are jobs (Pattern B); the rest are sync (Pattern A).internal/lifecycle— the install/uninstall transaction fromAPP_LIFECYCLE.md: slug allocation (with reserved-name list), instance dir tree, generatedcompose.override.yml+.env, per-app network creation,docker compose up -dvia the CLI driver, mDNS publish, Caddy route registration, and full rollback on failure.internal/store— SQLite as the desired-state source of truth (instances table).internal/catalog— loads hand-curated manifests fromcatalog/.internal/manifest— parses + validatesmanifest.yml(required fields).internal/caddy— drives the Caddy admin API (no Caddyfile on disk).internal/hostclient— brain's client for the host-agent UNIX socket.internal/events— the global SSE event bus (typedKindenum).internal/protocol— wire types shared by brain and host-agent.
The generated override is faithful to APP_LIFECYCLE.md # override file
contents: every service gets cap_drop: [ALL], security_opt: [no-new-privileges:true], forced restart: unless-stopped, and attachment to
the per-app network; main_service additionally joins malmo-ingress with a
per-instance alias (malmo-<id>-<service>) so Caddy reaches exactly that
instance.
cmd/host-agent speaks the real BRAIN_HOST_PROTOCOL.md wire format
(HTTP/JSON over a real UNIX socket) but its host operations are canned:
/v1/discovery/publish|unpublish, /v1/discovery/state, /v1/system/status.
This is the seam that lets the brain be built before the real host-agent
(Avahi/DBus/cryptsetup) exists.
web-ui/ per the WEB_UI.md stack: Vue 3 <script setup>, TanStack Query for
all server state, a thin fetch wrapper shaped for future openapi-fetch
codegen, and useEvents() subscribing once to the SSE stream to invalidate
Query caches (the push/pull-share-one-cache pattern). Lists the catalog and
installed apps; Install/Uninstall buttons drive jobs and poll to completion.
catalog/whoami (a ~5 MB traefik/whoami image) as the smoke-test app.
dev/ holds a standalone Caddy container + config; the Makefile wires the
all-native inner loop (make help). See
../dev/running-locally.md.
CONTROL_PLANE.md— brain as one Go binary + SQLite; Caddy driven via its admin API; layered packages.APP_LIFECYCLE.md— compose-project unit, on-disk layout, CLI driver, install transaction with rollback, override contents, slug allocation, mDNS ownership by host-agent, register-Caddy-route timing.BRAIN_UI_PROTOCOL.md—/api/v1prefix, sync vs. job patterns, SSE global event stream with typed kinds, OpenAPI from huma.BRAIN_HOST_PROTOCOL.md— HTTP/JSON over UNIX socket, discovery + system-status endpoints, idempotent publish/unpublish.APP_MANIFEST.md— required-field manifest, compose held verbatim.
- host-agent ops are faked — no real Avahi/LUKS/apt/NetworkManager. The protocol shape is real; the effects are not.
- No auth.
AUTH.mdis unbuilt; the brain uses a permissive dev CORS shim so Vite (:5173) can call it. No session cookie, no login. - Dev Caddy on host port
:8088, not:80— port 80 was occupied on the build box. Production Caddy publishes 80/443. - No startup reconcile of Caddy routes. On brain restart,
EnsureServerresets the route list; installed apps' routes are not re-asserted yet (APP_LIFECYCLE.mdreconcile pass is the fix). - No health-wait / splash flip. Install marks
runningaftercompose upwithout waiting formain_servicehealthy or doing the splash→real upstream flip (steps 10–11 of the install transaction). - No digest pinning, no admission policy, no Door-2 custom-compose path yet.
- No Tailwind/shadcn-vue. Plain CSS placeholder; the
WEB_UI.mdcomponent stack is deferred. .localURLs are illustrative — the dashboard showshttp://<slug>.malmo.local, which won't resolve until the real host-agent + Avahi land. Real routing today is via Caddy on:8088with aHostheader.- Go installed user-local at
~/.local/go(no system package).
Ordered, roughly by leverage:
Startup reconcile pass— done in reconcile-and-health-wait.md.Health-wait + splash route flip— done in reconcile-and-health-wait.md.- Door-2 custom-compose path + admission policy —
docker compose configrejection rules, synthetic manifest generation. WEB_UI.mdcomponent stack — Tailwind 4 + shadcn-vue, real layout,useJob()composable, health/degraded-mode surfacing.- VM outer loop (QEMU + swtpm, per
TESTING.md) — start building the real host-agent: boot ordering, storage assembly, LUKS/TPM unlock, NetworkManager, Avahi.