What exists in this repo today and how the pieces are wired. This doc is the
current state — design intent lives in specs/, per-change history
lives in progress/. When the code changes, this doc changes in
the same PR.
Five real processes/artifacts make up a running moose right now. Three are Go, one is JavaScript, one is a container we don't write.
| Component | Lives in | What it is | Status |
|---|---|---|---|
moose-brain |
cmd/brain/, internal/ |
The control-plane daemon. Owns SQLite state, the REST+SSE API, the app lifecycle, and the Caddy config. One Go binary. | Real |
host-agent (fake) |
cmd/host-agent/ |
Privileged side used in the inner dev loop. Speaks the real BRAIN_HOST_PROTOCOL.md wire format over a UNIX socket; the host operations themselves (Avahi, LUKS, PAM, apt) are stubbed in memory. |
Fake (real wire, canned ops) |
host-agent-real |
cmd/host-agent-real/, internal/hostagent/ |
The real privileged binary. Seam-injected reporters: PAM password verify (pamverifier), /proc system sampling (procsource), disk usage, RAM pressure, journal streaming, service health, reboot-required flag, user manager, system time-zone setter (timezone, timedatectl set-timezone — the first-run wizard's Step 3, wired in both build profiles), per-account SSH access (sshaccess, #463 — renders sshd_config.d/moose-allowed.conf whole from the enabled set, validates with sshd -t, and starts or stops sshd so :22 is open only while someone has SSH on; wired in both build profiles). Discovery is real: per-LAN-interface Avahi announcements (avahipublisher) driven by the NetworkManager LAN set (netstate), with an avahi-daemon.conf allowlist sync and IP-change replay. Seeds the brain's Docker transport then launches the brain container on startup (brainlaunch: EnsureTransport creates moose-ingress + runs the docker-socket-proxy; Launch docker-loads the bundled image if absent, lockstep moose.protocol.major OCI-label check, docker run --restart unless-stopped on the ingress net with DOCKER_HOST at the proxy). Before the launch it makes sure the household shared tree /srv/moose/shared exists as root:moose-shared 02770 (brainlaunch.EnsureSharedTree) and mounts it into the brain at the same path, so the brain can prepare shared folder sources (#519). Personal folder sources are prepared by host-agent itself, over POST /v1/users/{username}/prepare-folder (usermgr, a walk that never follows a symlink), so the brain never mounts /home. The A/B OS update on the hosted build (osupdate, #563: RAUC install into the other slot, switch in the window, trial boot, revert). Host ops not yet wired: LUKS/TPM, NM configuration (WiFi setup, /v1/network/*). A build-tagged slim hosted profile (go build -tags hosted, #204/C1c) compiles the discovery/NetworkManager stack out for the cloud image — avahipublisher/netstate unwired, no-op publisher, nil Net — keeping the same PAM/user-mgmt/health-system/brain-launch seams (cmd/host-agent-real/wiring_appliance.go vs wiring_hosted.go). |
Partial — see "What is not built yet" |
| Caddy | dev/caddy.json, dev/docker-compose.yml |
Reverse proxy. Terminates *.local (appliance) or *.<box-id>.onmoose.io over real Let's Encrypt HTTPS (hosted, via a custom acme-dns build) and routes to app containers + the brain. Configured live by the brain via Caddy's admin API. |
Real (container) |
web-ui |
web-ui/ |
Vue 3 + Vite + TanStack Query dashboard. Talks only to the brain. Tailwind 4 with the Oatmeal @theme tokens; reka-ui + cn() are present as shadcn-vue scaffolding, but the owned components in components/ui/ (Button, Heading) are hand-written from the Oatmeal patterns, not pulled through the shadcn CLI (#261). The catalog install is a set of steps, one need per step with the saved accounts listed and one picked, and the last step installs; an app with no steps installs from the App page (INSTALL_STEPS.md, DASHBOARD.md # Install authorization). Internal code architecture: dev/web-ui.md. |
Real |
| SQLite | $STATE_DIR/moose.db |
The brain's only persistent store. Schema + queries in internal/store/. |
Real |
Plus the Docker daemon on the host, which the brain drives with the
docker compose CLI (internal/lifecycle/docker.go). App containers run on the
moose-ingress Docker network so Caddy can reach them by service name.
┌─────────────────────┐
browser ─────────► │ web-ui │
│ (Vue, TanStack Q.) │
└──────────┬──────────┘
│ HTTP + SSE
▼
┌─────────────────────┐ docker compose CLI
│ moose-brain │ ──────────────────────► Docker daemon
│ │ │
│ api / lifecycle │ ─── Caddy admin API ──► Caddy ─┘
│ store / catalog │ │
│ auth / audit │ ▼
│ caddy / events │ app containers
└──────────┬──────────┘ (moose-ingress net)
│ HTTP/JSON over UNIX socket
▼
┌─────────────────────┐
│ host-agent (fake) │
│ in-memory state │
└─────────────────────┘
Each arrow, in one line:
- browser → web-ui: Vite dev server in dev; the brain serves the built bundle in prod (planned). The UI is plain SPA, no SSR.
- web-ui → brain: REST under
/v1/*(OpenAPI generated by huma) for reads/mutations; SSE under/v1/eventsfor install/lifecycle progress. Auth is an opaque cookie minted byinternal/auth; the middleware ininternal/apigates every mutation. Seespecs/BRAIN_UI_PROTOCOL.md. - brain → Docker: the brain shells out to
docker composeper instance. The compose file is held verbatim from the manifest — the brain never rewrites it. Driver interface lives ininternal/lifecycle/(the consumer), implementation in the same package. - brain → Caddy: the brain POSTs JSON to Caddy's admin API to add/remove
site blocks per app. A splash route covers
<slug>.localuntil the container's health check passes, then flips to the real upstream. Each flip replaces the route in place by its@idin one admin call, so a failed call leaves the old route serving (#520). The startup pass writes every app's route again: the app for a running one, the splash for a stopped or failed one. It first waits up to 15s for Docker to answer through the proxy (Manager.WaitDocker), since after a reboot the brain can start before the proxy does (#540). - brain → host-agent: HTTP/JSON over
MOOSE_AGENT_SOCK. Two patterns, sync request/response and SSE-streamed jobs (internal/protocol/host.godefines the types;internal/hostclient/is the brain-side client). The routes the brain calls today, frominternal/hostclient/:/v1/auth/{verify-password,set-password,set-role,delete-user},/v1/users/{username}/home,/v1/identity/{well-known,app-service,app-service/release},/v1/discovery/{publish,unpublish},/v1/system/{status,resources,gpu,set-timezone},/v1/health/system,/v1/journal/follow, and the job routes/v1/jobs/system-update+/v1/jobs/{id}.specs/BRAIN_HOST_PROTOCOL.mdowns the wire and also lists the routes that are specced but not built yet (files, network, terminal, drive jobs).
Packages under internal/ and what each owns. Layer rules come from
../CLAUDE.md # Go code discipline; only the directional rules
are stated below. The table is the whole of internal/'s top level; the
host-side implementation packages under internal/hostagent/… are covered by
the host-agent-real row in # Components and by # What is not built yet.
| Package | Owns | Imported by |
|---|---|---|
api |
HTTP handlers (huma), auth middleware, request/response shapes. The only package that knows about HTTP. | cmd/brain |
lifecycle |
The install transaction: door-1 (catalog) and door-2 (paste-a-compose), digest pinning, reconcile pass, health-wait, Caddy timing, uninstall. Owns the one central route builder (buildRouteConfig) that resolves each app's caddy.RouteConfig from profile + per-instance exposure (hosted owner-only default, SetExposure toggle, #306) + the manifest's access.public_paths carve-out and the always-on identity-header scrub (#415). Also re-stamps the apps an email or AI account change reaches (restamp.go: new slot values, a rewritten override or .env, a recreate of the running apps, one app's failure not stopping the others). Defines DockerDriver consumer-side. |
api, cmd/brain |
store |
SQLite schema + queries. Sole persistence boundary. ErrNotFound is the only typed error. |
api, lifecycle, auth, audit, cmd/brain |
catalog |
Door-1 source behind a fixed six-method facade. Production (every profile) uses the remote thin client against the catalog service (NewRemote, MOOSE_CATALOG_URL): fetches GET /catalog?env=<profile> for browse data, checks its schema version, holds it in memory only (no catalog on disk), proxies+caches assets with a 24h expiry (an asset URL is absolute and today points at an object-storage origin, not at the catalog host). An app's install payload is a separate per-app fetch at install time (Load(ctx, id) follows the record's manifest_url / compose_url); every published URL is opaque. Environment filtering is the catalog service's (the ?env= on the fetch), and there is no index digest: version is an opaque ETag token the box never recomputes (#434). The payload also carries the store's authored landing page (a spotlight app + category groups, authored in home.yml) and the authored category vocabulary (id + display label, in authored order) verbatim; Home() projects both (docs/specs/APP_STORE.md # Landing page, # Category labels). Category display text is always the authored label, never derived from the id. The payload also carries the AI provider data (ai_providers, INSTALL_SETUP.md # 4), read leniently once per load (aiproviders.go: an unknown model type or flag, a default naming a missing model, or a provider with no id is dropped and logged, never a reason to refuse the snapshot) and projected by AIProviders() with logo URLs on the box's own routes; AIProviderLogoPath proxies and caches a logo like an app icon. WithoutRemapApps() is a view that leaves the apps needing the userns-remap (root_setup or image_user, read from the browse record or, on disk, the manifest) out of the lists, the landing and search, and passes every by-id lookup through; the API uses it only when lifecycle.Manager.RemapState says the box runs no remap (APP_STORE.md # Apps this box cannot run). The disk reader (New) is retained only as a test constructor; no catalog is baked into the image. |
lifecycle, api, cmd/brain |
manifest |
manifest.yml schema (parse + validate), and the synthesizer that wraps a pasted compose into a door-2 manifest. Also the role vocabulary on config fields and the requires groups (roles.go), read leniently so Parse never refuses a manifest over them, and their strict rules (lint.go), which only the moose manifest CLI runs (APP_MANIFEST.md # D4 # Roles and requires). The lint also refuses a requires group that mixes a kind or slot with a plain field, because the install flow asks those on different pages. The model-type list there is the one the catalog's AI provider reader uses too. |
catalog, lifecycle, api |
admission |
The single compose admission policy applied to both doors (image pinning rules, forbidden constructs, etc.). | lifecycle |
caddy |
Client for Caddy's admin API. Site-block JSON generation lives here (per-app route via AddRoute(RouteConfig) — optional hosted forward_auth gate, a strip of the single RouteConfig.StripCookieName cookie from the Cookie header, never the whole header, #306/#335, an unconditional RouteConfig.ScrubHeaders delete, and a subroute that carves RouteConfig.PublicPaths out of the gate, #415), plus the hosted wildcard-TLS automation policy (EnsureWildcardTLS: ACME DNS-01 via the acmedns provider for *.<box-id>.onmoose.io). Profile-agnostic: the strip/gate policy is resolved by the caller. |
lifecycle, cmd/brain |
profile |
The environment-profile marker (appliance|hosted) + the first-boot seed reader, and the hosted URL-shape helpers (HostedAppHost/HostedAppURL/HostedDashboardHost/CertSubjects — the single place <slug>.<box-id>.onmoose.io is named). Leaf package. |
api, lifecycle, cmd/brain |
mailpreset |
The built-in outgoing-mail provider presets (host, port, encryption and the username rule per provider), in display order: Gmail and iCloud first, marked Personal with numbered steps for their app passwords, then the sending services, then custom. Plus List/Get/Valid/LabelFor. Hardcoded rather than catalog-served: the catalog is an app-distribution channel, these constants change roughly never, and the credential broker's per-provider logic has to be Go (DECISIONS.md 2026-08-27). Leaf package, no moose imports. |
api, store |
hostclient |
Brain-side client for host-agent. Mirrors the routes in protocol. |
lifecycle, api, auth, cmd/brain |
protocol |
Wire types shared with cmd/host-agent. Source of truth for the host protocol. |
hostclient, cmd/host-agent |
auth |
First-admin bootstrap, password verification (delegates to host-agent), opaque cookie sessions, plus the hosted per-app forward-auth credential (a second, lower-privilege Domain-scoped cookie on the session row that the box Caddy's forward_auth verifies against the brain, #305). Owns ForwardAuthCookieName, the one source of truth for the cookie name the route builder strips (#335). No password hashes on the brain side. |
api, lifecycle, cmd/brain |
assertion |
Verifies the portal's short-lived Ed25519 ownership assertion for the hosted portal-to-box SSO handshake (Verify: signature + expiry; box-id/issuer/replay are the handler's policy). Minimal signed token, not a JWT. Mirrors the cloud signer's wire format. Leaf package. |
api |
audit |
Append-only audit_events table writes. Every elevation-class mutation calls audit.Record on success and failure. |
api |
events |
In-memory pub-sub bus for SSE. Lifecycle stages publish; the SSE handler subscribes. | lifecycle, api, cmd/brain |
health |
The brain's typed-issue registry (HEALTH.md). The taxonomy is registered in code — a stable string ID binds severity / category / tier / blocks_* at registration, never redeclared per raise. Writes through to SQLite on every raise/clear so issues survive a brain restart. |
api, notify, store, cmd/brain |
notify |
Routing + derivation for the dashboard notification center (NOTIFICATIONS.md). Notifications are derived from events that already exist — today, health raise/clear transitions through a code-registered allowlist — never a parallel taxonomy. Coalesces by dedup_key, and emits the member-transparency variant for box-blocking storage issues. |
api, store, cmd/brain |
applog |
Per-app log fan-out (BRAIN_UI_PROTOCOL.md Pattern C, LOGGING.md # Per-app logs). Sits between host-agent's single upstream follow per instance and the dashboard's many SSE readers, and owns the reconnect contract host-agent deliberately does not: a ~256 KiB ring buffer, replay from Last-Event-ID, one {"lost":true} marker when a position was evicted, and a linger so a quick reconnect reuses the warm buffer. One ref-counted Hub per instance — zero idle cost when nobody is watching. |
api, cmd/brain |
systemlive |
The live system-resources stream (BRAIN_UI_PROTOCOL.md Pattern C stream 3, LOCAL_ANALYTICS.md). Ref-counted upstream poller: the first SSE subscriber starts a 1 Hz poll of host-agent's raw cumulative counters, each poll is diffed into rates and fanned out, the last unsubscribe stops it. Same zero-idle-cost shape as applog. |
api, cmd/brain |
storageverify |
The canary + enrollment-marker check behind the moose-storage-verify reporter (BOOT.md # The storage-ready target, STORAGE.md # Storage canary). Split out of cmd/ only so the check is unit-testable against a tempdir root; the binary is a thin shell that writes findings to /run/moose/health/storage.json. Not a brain package — it is imported by cmd/moose-storage-verify alone. |
cmd/moose-storage-verify |
version |
A binary's build identity: Version (its release line's version file) and Commit (git sha), stamped at build time via -ldflags -X (Makefile, cmd/brain/Dockerfile, BUILD.md # Versioning). host-agent is stamped from VERSION (the moose OS release), the brain from CONTROL_PLANE_VERSION. Dumb: vars, String() (OS form) and ControlPlaneString() (brain form), no logic. |
api, hostagent, cmd/brain, cmd/host-agent, cmd/host-agent-real |
Cross-cutting invariants:
- Brain commits first, host is reconstructible. Mutations that span SQLite
- host-agent commit to the brain first, then call the host. On host failure,
the brain row is rolled back. Established by
/setup,createUser,updateUserRole,deleteUser. See../CLAUDE.md.
- host-agent commit to the brain first, then call the host. On host failure,
the brain row is rolled back. Established by
- Single logger.
slog.Default()everywhere; no*slog.Loggerthreading, nologpackage, nofmt.Printlnfor diagnostics. Standard field names are listed in CLAUDE.md. - Audit on success and failure. Elevation-class handlers emit
audit.Record(..., success=false)on every observable failure path (host 502, store 500, conflict 409, guard rejection), mirroringlogin.failure.
.dev/
agent.sock UNIX socket the brain dials the fake host-agent on
state/
moose.db brain's SQLite (schema in internal/store)
instances/ per-app state (compose file, .env, digests)
services/ managed-service data (postgres-<v>/, valkey-<v>/, …)
catalog-cache/ proxied catalog icons, screenshots, AI provider logos (24h expiry)
host-agent built binary
brain built binary
dev/
caddy.json Caddy bootstrap config (replaced live via admin API)
docker-compose.yml brings up the dev Caddy + moose-ingress network
MOOSE_STATE_DIR and MOOSE_AGENT_SOCK are set by the Makefile so the brain
and host-agent agree on paths.
make dev runs the four foreground processes — Caddy (container), host-agent,
brain, Vite — in one terminal. make help lists the per-process targets for
the four-terminal layout. See dev/running-locally.md
for the full inner loop. The VM-based outer loop for host-integrated parts
(boot, LUKS, systemd) is not wired into the native dev loop — it lives in the
QEMU lanes (specs/TESTING.md).
So this doc isn't read as a claim about the finished product:
- Full real host-agent.
cmd/host-agent-realis partially real: PAM password verify,/procsystem sampling, disk usage, RAM pressure, journal streaming, service health, reboot-required, discovery (per-LAN-interface Avahi announcements from the NetworkManager LAN set, allowlist sync, IP-change replay), and the first-boot brain launch (#164: load-if-absent, lockstep label check,docker run --restart unless-stopped) are wired. LUKS/TPM, apt, and the NM configuration surface (WiFi setup,/v1/network/*) are not yet wired — those ops are still no-ops or stubs. - Control-plane stack bring-up — built (M1b, #165), VM-boot acceptance pending. host-agent seeds the brain's Docker transport (the
moose-ingressnetwork + thedocker-socket-proxy, raw socket:ro,EXECdenied) before launching the brain, and points it atDOCKER_HOST=tcp://docker-proxy:2375; the brain then reconciles Caddy +moose-uifrom the staged control-plane compose (lifecycle.EnsureControlPlane) and installs the dashboard route (/api/v1/* → brain, else →moose-ui). All of it is production-gated onMOOSE_CONTROL_PLANE_DIR/MOOSE_DASHBOARD_UI_UPSTREAM, so the natively-run dev brain is unchanged (standalone dev Caddy, Vite UI, raw socket). Managed DB in production is no longer blocked. #185 moved provisioning offdocker execonto a one-shot--rmclient container. SoEXECstays denied, and the brain stays off the app-reachablemoose-svc-*network (DECISIONS.md2026-06-15, which lifts the 2026-06-14 gate). The bring-up is proven on a booted box by the hosted cloud lane, which runs in CI. The appliance medium-lane run (sudo make test-medium-qemu) is still outstanding. - User-namespace remap: built, and on in both images (#526, #527, #528, #529, #537, #530, lane proof #531). Both images set
"userns-remap": "moose-remap"in/etc/docker/daemon.json, make themoose-remapsystem account, writemoose-remap:1000000:65536to/etc/subuidand/etc/subgid, and setSUB_UID_COUNT 0andSUB_GID_COUNT 0in/etc/login.defs(dev/cloud/mkosi.postinst.chroot,dev/test-qemu/mkosi.postinst.chroot). A box built before #530 keeps the remap off; nothing on a box replacesdaemon.jsontoday (BUILD.md# User-namespace remap). Every boot of the cloud lane and the appliance medium lane checks thatdocker infolistsname=usernsand theoverlay2store. The cloud lane's tworemapboots (#531) install one synthetic app per tier and check its userns mode, capabilities and host data owner, then reboot the same disk and check them all again (TESTING.md# Hosted cloud variant). host-agent launches the socket proxy and the brain with--userns=host, and the proxy also with--tmpfs /run(brainlaunch.RunSpec.UsernsModeandTmpfs). Both are always passed, and on a daemon with no remap they change nothing. The real host-agent also reportsremap_baseonGET /v1/identity/well-known, read from themoose-remaplines of/etc/subuidand/etc/subgid(usermgr.LinuxUserManager.RemapBase); on a box with no such lines the field is absent. The fake host-agent reads the same lines with the same code (usermgr.ReadRemapBase, #548), so on a normal dev machine it sends no field, and on one with a hand-made range it sends that range. The manifest fieldroot_setupis parsed, andadmission.CheckManifest(at install and inmoose manifest check) refuses it withfolders,gpu: true,devicesorservice_user: true. The fieldimage_useris parsed too, refused the same way and also withroot_setup, andadmission.CheckManifestComposerefuses it with auser:in the compose. The brain's tiers (internal/lifecycle/userns.go): before every install it readsremap_baseand checksdocker infoforname=userns(DockerDriver.UsernsRemap). It refuses every install while the two disagree, and aroot_setuporimage_userinstall when there is no remap. On a remapped daemon the override getsuserns_mode: hostfor an app withfolders,gpuordevices(host tier), andcap_addof five capabilities with nouser:for aroot_setupapp (caps tier), never both; for animage_userapp it writes nouser:and nocap_add(image tier); every other app stays remapped with today's sandbox (default tier). Bind dirs are owned bybase+uid(default) orbase(caps). In the image tier each service's dirs go tobaseplus the ids of its image's user: after the pull the brain readsConfig.User(DockerDriver.ImageUser) and, for a name or a bare uid, the image's own/etc/passwdand/etc/group, copied one by one out of a container that is created and never started, then removed with its volumes (DockerDriver.ImageUserFiles,imageuser.go). A new managed service's data goes tobase. The tier is stored on the instance row (instances.userns_tier, defaulthost, the value every instance gets on a daemon with no remap). With no remap the override is byte for byte what it was (TestOverrideUnchangedWithoutRemap, golden files written by the code from before the tiers), and one golden per tier pins the remapped overrides (TestOverrideRemappedTiers). The app update path is not built, so the "tier is fixed" refusal is a check it must call (checkTierKept). SeeAPP_ISOLATION.md# User-namespace tiers. The store on a box with no remap (#544):Manager.RemapStatemakes the same two reads, cached (ten minutes when known, thirty seconds when not,remapstate.go). When it is a known "no remap", the store lists leave outroot_setupandimage_userapps, and the install plan of such an app carriesunavailable: "needs-remap", which the detail page and the install pages show as one plain sentence in place of Install. An unknown state hides nothing. The browse record carries the two fields only once the catalog publisher sends them; until then only the install plan knows (APP_STORE.md# Apps this box cannot run). - Storage subsystem — the boot half exists, the pooling half does not. The
userspace boot chain is real and shipped in
dist/systemd/(moose-storage-ready.target,moose-storage-verify.service,moose-recovery.target) with the reporter incmd/moose-storage-verifyand the health wiring ininternal/health(progress/boot-pipeline-units.md), and LUKS root + first-boot TPM enrollment + unseal is proven end to end in the QEMU medium lane against a real kernel and a software TPM (progress/luks-tpm-enrollment.md). What does not exist: the data-drive half — no mergerfs assembly, no/srv/moosepool (the path appears only as the storage-verify canary), no UI or host-agent surface for adding a drive or unlocking one. On a dev box apps still write to wherever Docker puts volumes. - Boot, install ISO, updates. The
mkosiimage build (BUILD.md# 2; proven in the test lane, not yet the production ISO) and stream A (an A/B OS image with RAUC and GRUB,UPDATES.md# 1 andBUILD.mdthe 128 MiB ESP, a BIOS boot partition and slot A, a read-only 1 GiB squashfs (xz) holding the kernel and initramfs, in the image; slot B (1 GiB) and a state partition (the rest of the disk, grown on every boot) made bysystemd-repartin the initramfs at first boot. The OS reserves 5.7% of a 40 GB disk, and the build fails when the squashfs fills more than 60% of its slot (dev/cloud/slotbudget). GRUB on both firmwares from onegrub.cfgandgrubenv; the/etcoverlay, the four pinned files and the bind mounts set up by an initramfs-tools hook (dev/cloud/mkosi.extra/usr/lib/moose/state-setup);raucandrauc-servicewith the slot config and a keyring (/etc/rauc/keyring.pem). Every OS release builds a signed RAUC bundle (#562): slot A of the image that ships, in the verity format, built bydev/cloud/build-bundle.shand checked against the keyring read back out of the slot, re-signed in asignjob of its own (the only job that enters theos-releaseenvironment,dev/release/sign-bundle.sh; an offline root CA,docs/dev/rauc-signing.md) and attached beside the image. host-agent's hosted build reports the state partition as its "System" volume (diskusage.NewHosted). A hosted box updates its OS (#563):internal/hostagent/osupdatedownloads the bundle the update target names, checks its sha256 against the target, has RAUC install it into the other slot ahead of the window, switches and reboots inside it (os-installandos-switchjobs under the one job lock, after stream B), and on the next boot marks the slot good once the brain answers, or reboots back. An image timer (moose-os-trial.timer) reboots a slot whose host-agent never started. Only the first boot after a switch is on trial. The brain reportsos_version/os_slotand stream A's decision, and raises one admin notification per outcome. Proven by theos-updateandos-revertboots under both firmwares, andos-revertalso proves that GRUB skips a slot whose kernel panics before userspace (#575). Not built: the OS part of the private control plane's answer (described indocs/progress/host-agent-os-update.md), so no production box moves its OS yet, and the appliance layout (#564). The OS package lock is built (#560) for the hosted image:dev/os-lock/holds a snapshot.debian.org timestamp, exact Docker pins and the resolved package list, both cloud builds fail when they resolve to a different list, andos-lock-bump.ymlmoves it forward daily. The appliance lane is not locked yet (BUILD.md# 1b # The OS package lock). Stream B — the control-plane update — is half built. A box declares its brain/UI pair in two files (internal/hostagent/controlplane: the staged compose plus animages.jsonledger), the apply/health-check/revert transaction exists (internal/hostagent/cpupdate), and an admin can start one:POST /api/v1/system/updatewith two explicit image refs → thesystem-updatejob on the host socket (internal/hostagent/jobs.go), polled viaGET /api/v1/system/update/{job_id}. That path is proven on a booted box: the hosted cloud lane'supdateboot drives a real update and a real failed-update-then-revert against a real Docker daemon and a registry inside the guest (#382/#388), and it runs on PRs that touch the updater (#389). What picks the target is one seam (internal/hostagent/updatetarget, #401), consumed once by host-agent: it asks a source, validates the answer, compares it with the pair the box declares, and starts the same job an admin would. On hosted the source is the control plane's public update-target URL, asked with the box's ownbox_id(#408) so the answer can be per box, and the box applies without a prompt, inside the window the answer names or the 03:00–04:00 default; on appliance the source is the signed release manifest and the box only learns its target, because the control plane there is admin-prompted. Every answer must name both images pinned to a digest in an expected repository, or it is refused with nothing pulled. The hosted half is proven on a booted box (theupdateboot also drives the loop and its refusal path). The appliance trigger is half built:internal/hostagent/relmanifestverifies, parses and caches the signed release manifest and polls it hourly (#395/#397). What is still missing is the rest of picking a target: no signing key and noreleases.onmoose.io(so every build refuses every manifest and does not poll — the deliberate inert state), no digests in the manifest (it names versions, so the appliance source can only refuse —NEXT.md), no per-box targeting or report-back on hosted (blocked on the box↔cloud credential,NEXT.mdTier 1), no update notification, and no dashboard surface beyond Settings → About reporting the running versions (#393). What the seam decided is now readable (#443):GET /v1/system/update-targeton the host socket, re-served admin-only asGET /api/v1/system/update-target, answering what the box runs, what it could run, where the target and the window came from, when it last checked and why the last check produced nothing. Onestatefield carries the whole answer, and it keeps "nothing on offer", "could not ask", "answer refused" and "this box has no update loop at all" apart. No UI reads it yet — it is what the dashboard prompt will consume. - File manager.
FILES.mdis written, and/filesis a real top-level route. Nothing behind it is built. The brain registers no/api/v1/files/*handlers, and host-agent implements none of the/v1/files/*ops the protocol reserves.FilesView.vueis a "coming soon" stub. - Telemetry — we record consent, and send nothing. The first-run consent
choice is stored (
POST /api/v1/system/telemetry→box_meta). Nothing goes anywhere after that: there is no telemetry client, and no endpoint to send to (TELEMETRY.md). Health, notifications, time and discovery used to sit in this bullet too. They are built now:internal/healthraises typed issues that show onGET /api/v1/healthand in the dashboard'sHealthBanner; the notification centre and its bell work (internal/notify,NotificationBell.vue, mute per category); theclock-not-synceddetector reads realchronyc tracking(internal/hostagent/clockhealth); the time zone can be set through host-agent; and Avahi discovery is listed as real in - Off-box notification transports. The bell is dashboard-only. No email and
no push, so a box that needs attention while nobody is looking at the
dashboard cannot say so (
NOTIFICATIONS.md,specs/NEXT.mdTier 2). - Login UI — it renders, but it is not a route.
Login.vueis real and users reach it.App.vuechooses betweenSetup,Loginand the dashboard based on auth state, so a logged-out appliance box shows the login screen with no route change. There is no/loginroute: the logged-out screens are app state, not router entries./recoveris the one exception. Cookie sessions and the auth pipeline behind the screen are real. - App store. Every box syncs browse data from the catalog service (
GET /catalog?env=) and fetches an app's manifest + compose per install, then persists them next to the installation; TLS is the trust story, with no integrity digest and no Ed25519 signature (DECISIONS.md2026-07-02, 2026-09-04). No catalog is baked into the image and the box keeps no browse copy on disk (DECISIONS.md2026-08-17). What remains is store-side:onmoose/storeis the authoring surface, and it publishes and serves the catalog. The door-1 app-authoring how-to (docs/dev/authoring-apps-with-an-agent.md) is reconciled with that — it authors intostore:apps/<id>/and keeps the schema, tooling, and gap ledger here. - Install steps, step 4. The
recommendsneed level (a page with Skip) is not built, so a need is required or optional (INSTALL_STEPS.md# 5). The key picked in advance is the newest usable one, not the one another app used most recently.
For where each of these is planned, see the matching specs/ doc.
- This file.
specs/SPEC.mdandspecs/CONTROL_PLANE.md— the design vocabulary the code uses.dev/running-locally.md— get the stack up.progress/walking-skeleton.mdthrough the latest entry — the order things were built, with the why.cmd/brain/main.go— 100 lines, names every package and how they wire.