Concepts
Five ADRs — why the kit is built this way, with source paths
How to read these
These are the five decisions that shape how it feels to live in this codebase. Each record cites the real files; nothing here is aspirational. If a record and the source ever disagree, the source wins — tell us through the contact form at /contact.
ADR-001: One response envelope, one req_ request id per request
Context. Every API consumer — the app UI, the admin console, tests, and a buyer's own clients — needs a predictable success and error shape. Debugging across serverless instances needs a correlation id that survives from the response body into the logs.
Decision. Every route handler runs through a single wrapper: withApi() in apps/web/src/lib/api.ts. The wrapper generates a req_-prefixed request id (createRequestId in packages/api-core), stamps it into every envelope — success and every error status (401/403/404/409/422/429/500) — maps zod validation failures to 422 field errors, and appends queued audit events only after the handler succeeds. Error copy is locked in PRD section 10.4, so the same failure always reads the same way.
Consequences. Handlers cannot drift into bespoke response shapes — there is exactly one place where status mapping lives. The cost: handlers must return envelopes instead of raw responses, and new error cases must be added to the shared mapping first. You can measure the decision from outside: call any product API signed out and read the 401 envelope with its req_ id.
curl -i https://demo.buildgrain.com/api/meADR-002: Deny-by-default RBAC with a resource-aware systemAdmin
Context. Role checks scattered through handlers rot quickly, and a global "admin wins everywhere" rule is a real hazard: an allowlisted operator editing their own workspace should act as a tenant member, not as a superuser falling through every guard.
Decision. The PRD section 8.3 role table is encoded as data in packages/rbac/src/permission-matrix.ts, and can() in packages/rbac/src/can.ts returns true only for an explicit role x resource x action cell — an absent cell denies. effectiveRole() in apps/web/src/lib/api.ts grants the systemAdmin role only on declared admin resources; workspace resources always authorize by the membership role. Demo mode adds exactly one extra rule: authenticated users may *view* admin resources read-only.
Consequences. Adding an endpoint forces declaring its permission, or every role is denied. A contract test sweeps the full role × action × resource space independently of the production matrix (packages/rbac/tests/permission-matrix.test.ts). System admins cannot accidentally mutate tenant data through tenant routes. The matrix and its test render verbatim in the demo's Code tab.
ADR-003: postgres-js as the canonical driver, PGlite for local/test — with a pooler-safe pool
Context. The kit must run its test suite with zero database infrastructure, but the deployed product runs Postgres behind a transaction pooler (Supabase pgbouncer on :6543), which rejects prepared statements. Serverless concurrent runtimes (Vercel Fluid Compute) serve many requests per instance. On 2026-06-12 the demo went down exactly here: a single shared connection turned one slow statement into instance-wide head-of-line blocking.
Decision. packages/db/src/client.ts ships a dual driver behind one DatabaseClient type. With DATABASE_URL set it connects via postgres-js with prepare: false, a per-instance pool (DB_POOL_MAX, default 10), idle_timeout: 20, and connect_timeout: 10. Without it, it boots an in-memory PGlite instance — local development and tests only, never demo or production-kit. Deployment docs pair this with role-level statement_timeout and idle_in_transaction_session_timeout guards (see /docs/deployment).
Consequences. Tests boot in milliseconds with no server, and the same Drizzle schema serves both drivers. The exact failure mode that took the demo down is structurally removed: the pool keeps statements from queueing behind one another, and the pooler multiplexes server connections per statement. The cost: PGlite is not byte-for-byte Postgres — see /docs/troubleshooting for the differences that bite.
ADR-004: Demo is a mode, not a fork — and the test auth adapter cannot leak
Context. The public sample needs hard boundaries — no charge path, read-only admin, no customer data — without maintaining a second codebase. The e2e suite needs to sign in without Google, but a fake login must never be reachable on a public deployment.
Decision. APP_MODE resolves in packages/auth/src/mode.ts to one of development, test, demo, production-kit; an invalid value resolves to a setup blocker, never a silent fallback. Missing OAuth env shows an explicit blocker screen that names the missing keys — the kit never fakes a Google login. The test auth adapter activates only when NODE_ENV=test and AUTH_TEST_ADAPTER=1 (packages/testkit/src/activation.ts), and never activates in demo mode even with both set. The adapter never substitutes for missing env in demo or production-kit modes.
Consequences. One codebase serves the sample and the product; the demo boundary is enforced in the middleware, the RBAC layer, and the mail pipeline rather than by a deployment convention. E2E tests sign in deterministically. Misconfiguration surfaces as a visible blocker instead of a quietly broken auth path — the activation rules are themselves unit-tested (packages/testkit/src/activation.test.ts).
ADR-005: One render source for every email
Context. Email pipelines drift when the admin preview, the test fixture, and the actual sender each render their own markup. Template variables are also an HTML injection surface.
Decision. renderTemplate() in packages/mail/src/render-template.ts is the only renderer: one template definition produces the subject, the complete branded HTML document, and the plain-text part. Unknown variables throw, missing variables throw, and variable values are HTML-escaped in the HTML parts. Delivery is enqueue-then-adapter (packages/mail/src/mailer.ts): the MailEvent row is recorded first, then the adapter attempts delivery once — failures stay visible and retry is manual only. In demo mode the adapter is a local preview queue (packages/mail/src/local-preview-adapter.ts); nothing leaves the process. Mail triggers exist as data (packages/mail/src/trigger-matrix.ts) — adding one requires a PRD update first.
Consequences. The admin mail preview shows exactly the bytes that would be sent, because both run through the same renderer. There is no auto-retry storm by design. The cost: no ad hoc per-mail formatting — all content lives in template definitions with declared variables.