Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2026 Quality Runtime contributors
# SPDX-License-Identifier: Apache-2.0

DATABASE_URL=postgres://postgres:postgres@localhost:5432/qualityruntime
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- run: bun install --frozen-lockfile
- run: bun run check
# PGlite runs PostgreSQL in-process, so the migration and schema tests
# need no service container.
- run: bun run test

dco:
# Trust is PR-level: the GitHub App is the only identity that can open a PR
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
node_modules/
/tmp/
.env
.env.*
!.env.example
.env.local
.env.*.local
.DS_Store
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Read `ARCHITECTURE.md` before making structural changes.

Deployment adapters may depend on core code; core code must not depend on deployment adapters or provider-specific concerns such as Cloudflare.

Apps go in `apps/`, shared code in `packages/` — extract a package only when the boundary is real.

## Changes

- Make the smallest coherent change that solves the problem.
Expand Down Expand Up @@ -67,4 +69,4 @@ Hosted-service concerns such as billing, metering, customer provisioning, and in

## Before finishing

Run `bun run check` (Vite+ format, lint, and type checks) and `uvx --from 'reuse[charset-normalizer]' reuse lint`.
Run `bun run check` (Vite+ format, lint, and type checks), `bun run test`, and `uvx --from 'reuse[charset-normalizer]' reuse lint`.
273 changes: 272 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions docs/adr/0001-drizzle-orm-and-better-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 1. Drizzle ORM for persistence, Better Auth for authentication

Date: 2026-09-18

## Status

Accepted

## Context

The runtime needs database access and authentication before any domain work can start. PostgreSQL is already a committed choice (DATA-01), and tenant isolation is an invariant (TENANT-01), so the organization concept has to exist in the schema from the first migration rather than be retrofitted around it.

Authentication is a large surface — sessions, credentials, social providers, email verification, MFA, invitations — and building it is not what makes this product valuable.

## Decision

Use **Drizzle ORM** for schema definition, queries, and migrations, and **Better Auth** for authentication, tenancy, and instance administration.

Drizzle's schema is plain TypeScript that compiles to SQL we can read in review, and `drizzle-kit generate` produces checked-in, forward-only SQL migrations (MIGRATION-01). It stays close to SQL, so the PostgreSQL capabilities the architecture leans on remain reachable.

Better Auth ships the tenancy model the product needs in its `organization` plugin, so organizations, memberships, and invitations are one source of truth rather than a parallel model beside the auth library's own. The schema supports these plugins; application authentication is not implemented yet:

| Plugin | Why |
| -------------- | --------------------------------------------------------------------- |
| `organization` | The tenant boundary: organizations, memberships, invitations |
| `admin` | Instance operator actions — roles, suspension, impersonation |
| `twoFactor` | MFA, a named control in the standards the product is built to support |

Teams and dynamic access control are deliberately off. Both add tables, and neither has a requirement behind it yet.

The Drizzle schema is written by hand rather than generated into the repository on every change. Two things keep it honest, and they cover different ground:

- Better Auth 1.7 runs the authoritative **structural** check when its adapter initializes, against the Drizzle schema object rather than the live database: every table and column it writes must exist, and no column it never fills may be required. It does not compare types, uniqueness, indexes, or references, and it does not prove equivalence with the generated DDL.
- `packages/db/schema/auth.test.ts` asserts the same structural properties from `getAuthTables`, plus an identifier format per model — before any application exists to initialize an adapter.

Nothing automatically verifies column types, uniqueness, indexes, or references against Better Auth's definitions. When upgrading, compare a reference schema generated from the matching Better Auth configuration with the maintained schema. `migrations.test.ts` exercises selected database-level deviations and invariants against a real engine; it does not verify every deviation.

Migrations are generated with `drizzle-kit` and applied with it during development. Deployment applies the same checked-in migration history by a target-appropriate mechanism.

## Consequences

Better Auth owns the shape of the `user`, `session`, `account`, `verification`, `organization`, `member`, `invitation`, and `two_factor` tables. Changes to fields it writes must keep the Better Auth configuration, Drizzle schema, and new migrations aligned. Upgrades can add fields, and the schema test is what makes that visible.

How a deployed instance applies migrations is left open: a deployment may run them from its pipeline or use `drizzle-orm`'s migrator. That decision belongs in `docs/deployment.md` once a target exists. Development uses `drizzle-kit`; tests apply the same SQL history through Drizzle's PGlite migrator.

Deviations from Better Auth's generated schema are deliberate and documented in `auth.ts`: `timestamptz` columns, database defaults on `createdAt` and `updatedAt`, a unique membership per organization and user, a unique `(providerId, accountId)` per account, a foreign key on `session.activeOrganizationId`, and indexes on the foreign-key columns that parent deletion walks. These preserve the library’s required fields while adding database integrity checks and deletion support.

No Drizzle `relations()` are declared. They serve Better Auth's opt-in join mode and Drizzle's relational queries; neither is in use. Add relations when one of those features needs them.

`createDatabase` takes a `pg` `Client` or `Pool` rather than a connection string. Connection lifecycle differs enough between a long-lived server and a Worker behind Hyperdrive that choosing one here would put a deployment concern in the core (ARCH-01); the deployment owns it and injects the result.

Domain tables join the same package and the same migration history. `packages/db` depends on `better-auth` only as a development dependency, for the schema test; the server configuration that calls `betterAuth()` will live with the application that serves it, and must both use the same schema-affecting plugin configuration the test declares — plugin options such as `teams` add tables — and pass `generateId` from [ADR 0002](0002-prefixed-identifiers.md):

```ts
betterAuth({
plugins: [organization(), admin(), twoFactor()],
advanced: { database: { generateId } },
});
```
59 changes: 59 additions & 0 deletions docs/adr/0002-prefixed-identifiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# 2. Prefixed identifiers

Date: 2026-09-18

## Status

Accepted

## Context

Identifiers in a quality and compliance system are long-lived and widely handled. They appear in audit history, exported records, support tickets, and URLs, often separated from the query that produced them. An opaque identifier gives a reader nothing, and an identifier pasted into the wrong endpoint resolves to nothing rather than announcing the mistake.

[ADR 0001](0001-drizzle-orm-and-better-auth.md) settled on text primary keys generated by the application. This decides what goes in them.

## Decision

Every primary key Quality Runtime generates is `<prefix>_<random>`, for example `usr_v1stgxr8z5jdhi6b`. This governs row identifiers, not human-facing document numbers, standard clause references, or identifiers minted by other systems.

The prefix identifies the record type, so an identifier is self-describing wherever it turns up. Naming the _type_ rather than the table keeps the identifier format independent of physical table naming — `twoFactor` records carry `tfa_` and live in `two_factor`.

The random part is [Nano ID](https://github.com/ai/nanoid) over lowercase base36 (`0-9a-z`). Nano ID's own default is a 64-character URL-safe alphabet including `-` and `_`; both are dropped so the first underscore is unambiguously the prefix separator.

Case is dropped as well. These identifiers are read aloud on support calls, retyped out of exported records, and compared by eye in audit history, and a single case rule makes all of that safe: nothing downstream can lose a row by normalizing case, and the CHECK constraints reject a mixed-case identifier outright. Mixed case would add about 12 bits per 16 characters, which is a micro-optimization on top of an amount already far past sufficient.

A **row identifier is not a credential**, and length follows what an identifier does rather than how many rows a table holds:

| Length | Tables | Why |
| ------ | --------------- | ------------------------------------------------------------- |
| 16 | everything else | An ordinary primary key |
| 24 | `invitation` | The identifier itself participates in a possession-based flow |

Sixteen base36 characters is about 83 bits (36^16 ≈ 8 × 10^24) — past any collision concern for a database identifier, and one rule covers almost the whole schema.

`invitation` is the exception because Better Auth takes an invitation _by id_ in `accept`, `reject`, and `get`. It treats a predictable invitation id as security-relevant, gating those calls on a verified email whenever ids are not its own opaque ones. Acceptance also checks the invitation's email against the session's, so the id alone is not a bearer token — but it is the one identifier here that is worth guessing, so it gets the wider size.

Sizing by table cardinality — wider for `session` and `verification`, narrower for `organization` — was considered and rejected. It is the wrong axis: `session.token` authenticates a session and `verification.value` proves a verification, so widening those `id` columns hardens nothing, and narrowing `organization` buys four characters in exchange for a third rule and a CHECK constraint that is painful to widen later.

`packages/db/id.ts` owns the prefix and length of every table and is the single place where identifiers are generated. Two things consume it:

- Each Drizzle `id` column defaults to `createId(type)`, so ordinary inserts need not think about it.
- `generateId` is shaped for Better Auth's `advanced.database.generateId`, which is how the library is told to generate these instead of its own.

Each table carries a CHECK constraint on the format:

```sql
CONSTRAINT "user_id_format" CHECK ("id" ~ '^usr_[0-9a-z]{16}$')
```

## Consequences

The format is enforced by the database, not by convention. A writer that has not been wired to `id.ts` — most importantly a Better Auth configuration that forgets `generateId` — fails on its first insert instead of quietly storing identifiers in a second format that later has to be migrated. `migrations.test.ts` applies the migrations to PostgreSQL and asserts exactly that rejection.

Adding a table means choosing its prefix and its length. `auth.test.ts` fails when Better Auth writes a table that `idFormats` does not cover, and `generateId` throws rather than inventing a format.

Changing a table's alphabet or length is a migration, because the CHECK constraints encode both. This is the intended cost: it makes the identifier format part of the schema rather than a convention that drifts. It also means a length should be chosen with room to be wrong in the safe direction — widening later is cheap, narrowing invalidates existing rows.

Passing a custom `generateId` changes one Better Auth default: because the function is not its own generator, `requireEmailVerificationOnInvitation` defaults to on, so accepting an invitation by id requires a verified email. That is a safe default and is left as it stands — the heuristic assumes custom ids may be predictable, which these are not, so a deployment that wants the plain emailed-invitation flow can set the option to `false` deliberately.

PostgreSQL text comparison is case-sensitive, but the alphabet is not, so lowercasing an identifier anywhere in the stack is a no-op rather than a lost row.
38 changes: 37 additions & 1 deletion docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,40 @@

What Quality Runtime knows: entities, relationships, organization scoping, identifiers, lifecycle state, versioning, and the invariants the schema enforces.

This document will be expanded as the model is implemented.
The schema lives in [`packages/db`](../packages/db): table definitions in `schema/`, checked-in SQL in `migrations/`.

## Identity and tenancy

These tables are owned by [Better Auth](https://better-auth.com) and defined in `schema/auth.ts`. See [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) for why, and for the deviations from its generated schema.

| Table | Holds |
| -------------- | -------------------------------------------------------------------------------------- |
| `user` | A person. Instance-wide identity, independent of any organization |
| `account` | A credential or linked social provider; unique per (provider, external id) |
| `session` | An authenticated session, including the organization it is acting in |
| `verification` | Short-lived tokens for email verification, password reset, and OTP delivery |
| `two_factor` | TOTP secrets and backup codes |
| `organization` | **The tenant boundary.** Every tenant-owned record belongs to exactly one organization |
| `member` | A user's membership and role in an organization; unique per (org, user) |
| `invitation` | An invitation to join an organization, including its status |

## Identifiers

Every record's primary key uses `<prefix>_<random>`, for example `usr_v1stgxr8z5jdhi6b`. The prefix identifies the record type, and the random portion is a lowercase base36 value generated with [Nano ID](https://github.com/ai/nanoid). [ADR 0002](adr/0002-prefixed-identifiers.md) documents the decision. `packages/db/id.ts` is the single place where identifiers are generated.

| Table | Prefix | Random length |
| -------------- | ------ | ------------- |
| `invitation` | `inv_` | 24 |
| `user` | `usr_` | 16 |
| `session` | `ses_` | 16 |
| `account` | `acc_` | 16 |
| `verification` | `ver_` | 16 |
| `organization` | `org_` | 16 |
| `member` | `mem_` | 16 |
| `two_factor` | `tfa_` | 16 |

A row identifier is not a credential: `session.token` authenticates a session and `verification.value` proves a verification. `invitation` is wider because Better Auth takes an invitation by id. Identifiers are allocated before insertion and reveal no row count. Each table enforces its own prefix, length, and alphabet with a CHECK constraint, so an identifier belonging to another table — or one carrying uppercase — is rejected rather than stored.

## Domain entities

Quality and compliance entities — documents, requirements, controls, evidence, risks, audits, findings, incidents, CAPAs, training, suppliers, approvals, and workflows — are not implemented yet. They join the same package and the same migration history, and each carries the organization it belongs to.
34 changes: 28 additions & 6 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,49 @@ Install dependencies:
bun install
```

Create the required local environment configuration from the provided example when one exists.

## Development

```sh
bun run check # format, lint, and type checks
bun run fmt # apply formatting
bun run lint # lint only
bun run test # run tests
```

Vite+ offers more (`vp dev`, `vp build`, `vp test`); those are documented here once the application scaffold uses them. Commands shown in this document work as written; do not assume undocumented ones exist.
Vite+ offers more (`vp dev`, `vp build`); those are documented here once the application scaffold uses them. Commands shown in this document work as written; do not assume undocumented ones exist.

## Database

PostgreSQL is the application database.
PostgreSQL is the application database. The schema, the Drizzle client, and the migrations live in `packages/db`.

Point `DATABASE_URL` at a database — copy `.env.example` to `.env` in the repository root and adjust it:

```sh
docker run -d --name qualityruntime-postgres \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=qualityruntime \
-p 5432:5432 postgres:18
```

`.env` is ignored, as are `.env.local` and `.env.*.local`. Mode-specific `.env.<mode>` files are **not** ignored; never put secrets in one.

Change a table in `packages/db/schema/`, then generate and apply its migration:

```sh
bun run db:generate --name=add_controls # writes packages/db/migrations/NNNN_add_controls.sql
bun run db:migrate # applies pending migrations to DATABASE_URL
```

Both are also `bun run generate` / `bun run migrate` inside `packages/db`; the Drizzle config loads the root `.env` either way. `db:generate` needs no database; `db:migrate` fails if `DATABASE_URL` is unset.

Always pass `--name`: it names the file and its `tag` in `migrations/meta/_journal.json` together. That file is drizzle-kit's, so never hand-edit it. Add the SPDX header to the generated `.sql`. Never edit a migration that may already have been applied; add a new one. See `AGENTS.md` for the rules that govern migration history, and test migrations against realistic existing data when the change is non-trivial.

`packages/db/schema/migrations.test.ts` applies the migrations to PostgreSQL (via PGlite, so nothing needs to be running) and checks the constraints they create. `packages/db/schema/auth.test.ts` checks the Drizzle tables against what Better Auth says it writes.

Schema changes add a new migration; see `AGENTS.md` for the rules that govern migration history. Test migrations against realistic existing data when the change is non-trivial. Database setup and migration commands are documented here once implemented.
After upgrading `better-auth`, run `bun run test`, then compare `schema/auth.ts` with Drizzle reference output from the matching `auth` CLI version, generated from the same schema-affecting plugin configuration. Generating it needs a Better Auth config; the exact command belongs here once the application provides one. Those tests are structural only; [ADR 0001](adr/0001-drizzle-orm-and-better-auth.md) records what they do not cover.

## Before finishing

Run `bun run check`, plus the REUSE lint.
Run `bun run check` and `bun run test`, plus the REUSE lint. CI runs `check` and `test`; the REUSE lint has its own workflow.

For REUSE validation on macOS:

Expand Down
6 changes: 6 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@ The security model: trust boundaries, authentication, authorization, tenant isol

For vulnerability reporting, see [`.github/SECURITY.md`](../.github/SECURITY.md).

## Tenant authorization

`session.activeOrganizationId` records which tenant a request is acting in. It is **context, not authorization**. Tenant authorization must use the caller’s current `member` row — membership and role — resolved per request (TENANT-01).

Never treat possession of a session carrying an organization id as proof of access to that organization, and never filter by it alone.

This document will be expanded as authentication, tenancy, and file access are implemented.
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@
"name": "@qualityruntime/runtime",
"private": true,
"license": "Apache-2.0",
"workspaces": [
"apps/*",
"packages/*"
],
"type": "module",
"scripts": {
"check": "vp check",
"fmt": "vp fmt",
"lint": "vp lint"
"lint": "vp lint",
"test": "vp test",
"db:generate": "bun run --cwd packages/db generate",
"db:migrate": "bun run --cwd packages/db migrate"
},
"devDependencies": {
"@types/bun": "^1.4.2",
Expand Down
Loading
Loading