Skip to content
sign in

==> read the decisions

ADR-0001: A user is the tenant, and the database is the boundary

Status: Accepted. The SQLite revision is superseded by ADR-0010 (2026-10-14): Postgres again, a database per user, the kernel-verified uid as the credential through peer auth.

No cross-linking between accounts is needed yet, so users=tenants was the cheapest thing that could hold. The open question was where isolation is actually enforced.

accepted (revised: the boundary moved from a database role to the filesystem; see below)

  • A user IS the tenant. The schema deliberately has no membership table, so adding one later is additive rather than a rewrite.
  • Each user gets their OWN directory, /var/lib/cs/users/<username>, owned by their own operating-system account (cs-<username>, ADR-0009), mode 0700.
  • A project's data is a SQLite file inside that directory, one file per project, mode 0600.
  • The control database is a single SQLite file holding identity, sessions and the project registry. No tenant data.
  • The platform opens a user's data file on their behalf and hands the handle to their code.

The first version of this decision used PostgreSQL: one database per user, owned by a per-user role, with CONNECT revoked from PUBLIC. That worked and was verified, but at this scale a database server is a liability, and one file per user is a more literal reading of "a user's data is theirs" -- hand-over is a directory copy and backup is per file.

What changed is the ENFORCEMENT POINT, not the guarantee. There is no per-user credential any more: the credential IS filesystem access, enforced by uid/gid and the mode bits. That has a consequence recorded in ADR-0006 -- a user's service must be its own process, because there is no role to hand out, and a module running under the platform's uid could simply open another path.

  • Isolation is enforced by the kernel, not by application code, so a routing bug cannot read across users even in principle.
  • The mode bits are the whole mechanism, so they should be tested as the mechanism rather than assumed. api/test-isolation.sh was written to do that -- check 0700/0600 on every user directory and data file, then prove through the API that two users owning a project with the same slug get different rows -- but it provisioned through the retired provision.ts and hard-coded paths, and was deleted with it (2026-10-04). Until a test against today's layout replaces it, the guarantee rests on the fleet's module (the mode bits, a uid per user) and is not tested.
  • SQLite is synchronous, so a query blocks the event loop. At this scale that is a feature (no callback overhead, no pool); it would stop being one if a handler ran a long analytical query.
  • Backups, restores and hand-over become per user, and "hand a user their data" is a directory copy.
  • A user's data can be capped with XFS project quotas on the users volume, and the whole volume encrypted once rather than per user: per-user LUKS only helps if its key is not auto-unlocked from the same host, which is where it would live.
  • One database plus row-level security: a weaker boundary, and it puts the isolation burden on every query.
  • One schema per user: shared credentials, so a bug reaches everything.