Skip to content
sign in

==> 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)

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>/.

  • The contract grows one output. Beside ui = shared.build { src = self; } (per system, one bundle per project, as before), a user repo declares service = shared.service { src = self; }: per system, the user's backend, built from the module its backend attribute 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 at t/<user>/<release>/<slug>.js, and manifest.json -- the release's OWN projects and paths. user is 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 into service.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 a ctx built from what the API sends. That directory is the user's and group-owned by cs-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 an x-cs-context header (base64url JSON: user, project, lang, Accept-Language) in place of the cookie or token, which never cross. ctx.db is 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, and cs-provision reads it out of the profile that release installed (CS_USERS_PROFILES/<user>/manifest.json), merging the roster with it. So ui_path comes 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 runs api/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).
  • 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 in wheel, so attaching a tenant's service to it would run tenant code as someone who can become root. The username stays hasel everywhere users see it (login, URLs, the CDN); only the uid is namespaced (cs-hasel). createAccount goes away.
  • The control schema is migrated by the runner, as a deploy step. control.sql becomes the first control migration, run with the same runner and ledger as a project's (ADR-0002) by cs-provision before the API starts. The API stops creating tables.
  • cs-provision reconciles 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 under t/<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 by cs-<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-provision sets 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).
  • 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_ROOT are gone from the API. dev.sh now 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.