==> read the decisions
ADR-0009: A user release is their UI plus their service, content-addressed
Status: Accepted (2026-09-30; brought up to date 2026-10-01: the socket path, where migrations run, the data root, and the first user, who is now hasel)
Context
Section titled “Context”ADR-0008 made user repos flakes over the shared library, but only half of a user deploys: the fleet
composes their UI onto the CDN and nothing runs their backend. The deployed API loads user code
in-process from CS_REPOS_ROOT, which the fleet never sets, so ADR-0006's process boundary exists
only in users.nix, with no execStart to use it. Provisioning (the user's row, their projects,
their migrations) runs only from dev.sh. And the release segment is the literal dev, against
ADR-0003, which asks for content-addressed releases under t/<user>/<release>/.
Decision
Section titled “Decision”- The contract grows one output. Beside
ui = shared.build { src = self; }(per system, one bundle per project, as before), a user repo declaresservice = shared.service { src = self; }: per system, the user's backend, built from the module itsbackendattribute names (backend/index.ts) with the shared toolchain and wrapped in the platform's service host (below). One per user: it is the union of their projects (ADR-0006). - And a second:
shared.release { src = self; user = "..."; }, one store path per user release (ADR-0013 revised this in, when user repos left the fleet's configuration). Everything a push to that repo ships, so a host can install it as a profile generation and nothing has to be evaluated on the host at all:service.mjs,projects/<slug>/{migrations,lang}, the UI att/<user>/<release>/<slug>.js, andmanifest.json-- the release's OWN projects and paths.useris a parameter the user's own repo supplies: a username is an identifier from the operator's inventory, and it never appears in the shared library. - The service host is the platform's, the handlers are the user's. The host is a small Node
entry in the shared library (
lib/host.ts), bundled with the user's module intoservice.mjs. It runs as the user's uid, applies each project's migrations, listens on/run/cs/users/<user>/service.sock, and runs the routes against actxbuilt from what the API sends. That directory is the user's and group-owned bycs-platform(2750, setgid so the socket inherits the group): the user can create the socket, the API can connect, and no other user can even enter. The API stops loading user code and forwards the verified request -- HTTP and WebSocket alike -- over that socket, with anx-cs-contextheader (base64url JSON: user, project, lang, Accept-Language) in place of the cookie or token, which never cross.ctx.dbis opened by the host, one SQLite file per project inside the user's own data directory, so the kernel boundary of ADR-0006 is real. - One release per user, content-addressed. A user's UI lands at
t/<user>/<release>/<slug>.js, where<release>is the hash of that user's built UI. Projects stay separate bundles so a page loads only its own; the release is per user, as ADR-0003 has it. - The registry learns paths from the RELEASE, not from a fleet build. The fleet's manifest is a
ROSTER: the usernames the inventory lists, and no projects. Each release carries its own
manifest.json, andcs-provisionreads it out of the profile that release installed (CS_USERS_PROFILES/<user>/manifest.json), merging the roster with it. Soui_pathcomes from the same build that produced the bytes, and the fleet never learns anyone's project set. The distinction is load-bearing: absent from the ROSTER means departed (login disabled, rows kept); absent from the projects only means "nothing is deployed here yet" (registry left alone). - Provisioning is a deploy step. A oneshot on prospit (
cs-provision, which runsapi/src/reconcile.ts) ensures each user's row and project registry from the roster and the installed releases. It runs before the API starts, and again from a user's release deploy BEFORE that user's unit is (re)started -- so the rows the API checks are there first. It does not run a project's migrations: those run as the user (below).
How a user comes to exist on the fleet
Section titled “How a user comes to exist on the fleet”- Who exists is the inventory's
platform.users. The fleet turns that list into the roster -- accounts, units, CDN routes, the hub's repo set -- and nothing else: it does not build a user's projects (ADR-0013). Those come from the user's own release. Adding a user is an inventory line and creating their repo; their first push deploys them. - A tenant's OS account is always
cs-<username>, created by the module. Never an account the host already has: the first user is the operator, and the operator's login account is inwheel, so attaching a tenant's service to it would run tenant code as someone who can become root. The username stayshaseleverywhere users see it (login, URLs, the CDN); only the uid is namespaced (cs-hasel).createAccountgoes away. - The control schema is migrated by the runner, as a deploy step.
control.sqlbecomes the first control migration, run with the same runner and ledger as a project's (ADR-0002) bycs-provisionbefore the API starts. The API stops creating tables. cs-provisionreconciles the control rows, as the API's own uid, from the roster and each user's LIVE release manifest (the profile's current generation, never a retired one): it creates or updates each user's row, the row recording their data root, and their project registry. From a release it takes only that user's own entry, and only UI paths undert/<user>/; anything else is a tenant writing someone else's registry and is ignored. It never deletes a user or their data: a user removed from the inventory loses their service and their login, and with the login every session and token (ADR-0005, revised); their rows and data stay until removed by hand. A project their release stops declaring is unregistered, its data kept.- The data root is
/var/lib/cs/users/<user>, mode 0700 and owned bycs-<user>, created by the fleet's module rather than by the reconciler, which runs as the API's uid and could not make it the user's. - A project's migrations run as its user, in the service host at start, before it serves anything (ADR-0002: the runner is the platform's, bundled into the host), so the data files they create are owned by that user's uid from the first write.
- Credentials come from sops, never from the manifest (the manifest is in the world-readable
store). One secret holds every user's password hash as JSON keyed by username (key names in a sops
file are plaintext, so a secret per user would publish the roster);
cs-provisionsets each hash from it, so rotating a password is editing that secret and deploying. The session keypair that this line originally named is gone: sessions are opaque now (ADR-0005, revised). A user's own app credentials are a different thing and are not sops: a release may carry them encrypted under that user's name, and the unit decrypts them for that user's service alone at start (nixos2 ADR-0014).
Consequences
Section titled “Consequences”- A push to a user repo is a whole user release: their UI on skaia and their service on prospit move together, from one revision, as one profile generation on each (ADR-0013). No fleet deploy runs.
- A bundle is safe to cache forever, because its URL changes whenever its bytes can, and since
2026-10-13 the CDN says so (
immutable, a year). Store files carry a fixed mtime, so caddy's default ETag could not tell versions apart; every served tree now carries content ETags instead. The flip side: the CDN serves only the profile's current generation, so the previous hash 404s once a release switches (ADR-0003, revised). - The roster is not enumerable by URL, as ADR-0003 wanted.
- In-process loading and
CS_REPOS_ROOTare gone from the API.dev.shnow runs each user's service host over a socket, as the fleet does, but it is broken (it cannot list a repo's projects, never reconciles, and lays UIs out in the pre-release shape), so dev does not yet exercise the real path. - Supersedes the
ui-only contract of ADR-0008; the rest of 0008 stands.