- Status: done
- Date: 2026-06-10
- Specs touched: docs/specs/BRAIN_HOST_PROTOCOL.md (app-service identity allocation endpoints — written here); APP_ISOLATION.md # Runtime identity & data ownership, APP_MANIFEST.md # B, DECISIONS.md 2026-06-10 (realized, not changed — the spec landed with the decision)
Closes #116. Builds on folderless-app-data-dir-ownership.md, which made the brain pin user: on every instance and chown data/ to match — and left folderless apps running as the brain's euid (root in production). That default breaks images whose processes drop to a non-root service user (nginx+php-fpm, LinuxServer-style): they can't write the root-owned data/, and CAP_CHOWN is stripped so they can't fix it themselves. This slice implements the spec'd answer: service_user: true opts a folderless app into a dedicated, host-allocated, per-instance-stable non-root UID/GID, with the hard rule that a manifest can never name a numeric UID (a host-namespace number could alias a real principal under malmo's no-userns-remap model).
- Protocol (
internal/protocol/host.go): the reserved app-service band asAppServiceUIDMin/Max = [2100, 2999]— above the fixed well-knowns 2000/2001 (2002–2099 left as headroom for future fixed identities), below the 3000 malmo-user floor — plusAllocate/ReleaseAppServiceIdentityRequest/Responsewire types forPOST /v1/identity/app-serviceand…/app-service/release. Documented inBRAIN_HOST_PROTOCOL.mdalongside the well-known-identity section. - Host-agent (
internal/hostagent/agent.go): both handlers, mirror-shaped with the rest of the identity surface — delegate toUserMgrwhen wired (real binary), else an in-memorysvcIdentsmap (fake). Allocation is idempotent per instance (re-allocating returns the same pair); release is idempotent and band-guarded before any delegation — the endpoint must never be usable to delete an arbitrary account. Missinginstance_idand out-of-band UIDs are 400s; a full band is a 500 (app-service-band-exhausted). - Real allocator (
internal/hostagent/usermgr/linux.go): the/etc/passwdentry is the durable reservation —AllocateAppServicecreates a system account + groupmalmo-svc-<uid>(--system --no-create-home --shell /usr/sbin/nologin), so band state survives host-agent restarts with no side state. The account is named by UID, not instance ID (Linux's 32-char username cap vs. unbounded instance IDs); the GECOS field carriesmalmo app-service for <instanceID>, which is the idempotency probe. The free-number scan parses/etc/passwd+/etc/groupdirectly (no nsswitch/nscd dependency, same posture asisInGroup); auseraddfailure rolls thegroupaddback.ReleaseAppServicedeletes user then group, tolerating "already gone" at each step (including the orphan-group case a past interrupted release leaves), and refuses any UID outside the band. - Brain client (
internal/hostclient/hostclient.go):AllocateAppServiceIdentity/ReleaseAppServiceIdentity. - Manifest (
internal/manifest/manifest.go):service_userboolean, default false. Boolean intent only; Door-2 synthetic manifests never set it. - Store (
internal/store/store.go):service_uid/service_gidcolumns oninstances(0 = none; allocated values are always ≥ band min) +addColumnmigration +SetServiceIdentity. - Lifecycle (
internal/lifecycle/lifecycle.go): install step 6 grows anelse if man.ServiceUserbranch — allocate from the host, persist on the row, then feed the pair into the existingisolationstruct so the established pin-user:-and-chown path does the rest. Persisted before the chown/override so every later failure path can read it back. Release happens at uninstall (after teardown, mirroringdropServiceGrantsplacement) and at install rollback (row read-back beforeDeleteerases it); both are best-effort like grant drops — a failed release leaks one band slot for manual cleanup, never blocks. Identity stability across container recreations needs no new machinery:writeOverrideonly runs at install andReconcileonly does compose up/stop against the on-disk override, so the row + override file carry it. - Admission (
internal/admission/admission.go): two new rules, both door-symmetric. A numericuser:on any compose service (bare int,"1000","1000:1000","0", or a numeric component like"www-data:33") is rejected — malmo owns every runtime UID; named users (www-data) and variables (${APP_UID}) pass, since the brain's override pinsuser:regardless. NewCheckManifestrejectsservice_user: truecombined with afoldersgrant (a folder app already has a managed non-root identity); it's pure, so lifecycle calls it directly in the shared install transaction rather than widening theAdmitterseam (which exists only to skip the docker-CLI syntax pass in tests). - Tests: admission table rows for every
user:form +CheckManifestcombos; manifest parse + default; store roundtrip (Get + List paths,ErrNotFound); host-agent handler tests (fake branch in-band/stable-per-instance/distinct-across-instances/release-returns-to-band, 400s, UserMgr delegation + 500-without-detail-leak); usermgr pure-helper tests (firstFreeAppServiceIDincl. out-of-band UIDs ignored, GID-alone reserves, exhaustion;findAppServiceByGecosincl. an imposter non-malmo-svc-account not counting) and the release band guard; hostclient wire roundtrips; lifecycle fake-driver scenarios (override pins2100:2100+ row persisted, uninstall releases, late compose-up failure releases via row read-back, allocate failure rolls back cleanly,service_user+folders rejected before any state, and the regression guard that a default folderless install makes no identity calls); and adockerliveend-to-end (TestLiveServiceUserBootAndWrite) proving the container actually runs as 2100 and writesdata/owned 2100:2100 — live-verified.
- APP_ISOLATION.md # Runtime identity & data ownership: the identity table's
service_userrow, the band ("below 3000, distinct from the fixed 2000/2001"), per-instance stability, release at uninstall, and the folderless-only rule. Realized. - APP_MANIFEST.md # B (
service_user): boolean intent, never a number; numericuser:andservice_user+folders as admission rejections. Realized. - BRAIN_HOST_PROTOCOL.md: the two new endpoints documented in this change — request/response shapes, the band constants and their headroom rationale,
malmo-svc-<uid>naming, GECOS idempotency, fake behavior. - DECISIONS.md 2026-06-10: the locked decision this implements; nothing flipped.
- Band [2100, 2999], not [2002, 2999]. 2002–2099 stays reserved as headroom for future fixed well-known identities (malmo-app/malmo-shared live at 2000/2001), so a future fixed identity never collides with a dynamic allocation on an upgraded box. 900 dynamic slots ≫ any realistic single-box app count.
- Reservation = the passwd entry itself. No allocation table on the host side;
malmo-svc-<uid>existing is the claim, durable across host-agent restarts and reconstructible by inspection. The account is UID-named (32-char username cap) with the instance ID in GECOS for the per-instance idempotency probe. The brain's instance row is the authoritative instance↔UID map, per the brain-commits-first model. - The fake's
svcIdentsmap is deliberately not persisted. The brain persists the pair on the instance row and never re-asks; and the unprivileged dev brain can't chowndata/to a foreign UID anyway (documented warn-and-skip), so fake-side durability would buy nothing.
- Images that hardcode a different internal UID stay curation-rejects.
service_usercovers images that adopt the runtimeuser:; a php-fpm pool pinned towww-datain baked config, or a setuid-dropping entrypoint, still can't be aligned without userns-remap (deferred,NEXT.md; ledger:docs/dev/catalog-import-gaps.md# nonroot-data-ownership). - The unprivileged dev brain can't chown
data/to the allocated UID — the established warn-and-skip fromfolderless-app-data-dir-ownership.mdapplies; the production brain (euid 0) hard-fails. The dockerlive test reproduces the production chown with a root helper container. - The real allocator's shell-outs (
groupadd/useradd/userdel/groupdel) aren't exercised on the dev box — pure helpers and handler delegation are unit-tested; the real-command path is the host-agent-real lane (same posture as the rest ofusermgr, peruser-crud.md). - No catalog app uses
service_useryet — this is the mechanism slice; the first consumer is whichever curation-ledger app needed it (the nginx+php-fpm class).
- Re-attempt the curation-ledger apps that were rejected for non-root data ownership and do adopt the runtime user — they can now ship with
service_user: true. - Userns-remap remains the unlock for the hardcoded-internal-UID class (
NEXT.md).