==> 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.
Context
Section titled “Context”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.
Status
Section titled “Status”accepted (revised: the boundary moved from a database role to the filesystem; see below)
Decision
Section titled “Decision”- 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.
Revision note
Section titled “Revision note”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.
Consequences
Section titled “Consequences”- 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.shwas 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 retiredprovision.tsand 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.
Alternatives rejected
Section titled “Alternatives rejected”- 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.