Enforcement Layers
How bare database access is structurally prevented through import restrictions, the /client subpath pattern, and CI architecture tests.
RLS policies only protect against cross-tenant access when queries run inside withTenantContext. If a developer (or an AI agent) uses the bare db connection for a tenant-scoped query, RLS is bypassed entirely. This page documents the multi-layer enforcement system that prevents that from happening.
The Problem: TRO-10
During TRO-10, a proactive code review discovered that tenantProcedure was passing the bare db pool to ctx.db instead of the transaction-scoped tx from withTenantContext(). The code compiled. The app functioned. Single-tenant tests passed. But downstream queries were executing with no RLS context, meaning they would return all tenants' data.
The fix was a one-character change: db to tx. The incident drove an evolution through three enforcement phases.
Enforcement Evolution
Phase 1: Advisory (TRO-8)
CLAUDE.md warned: "never use bare db for tenant-scoped queries." This caught nothing -- AI agents do not reliably read or follow advisory warnings in documentation.
Phase 2: ESLint Ban (TRO-88)
no-restricted-imports rules in ESLint blocked importing { db } from @repo/db in router and MCP tool directories. This caught direct imports at lint time, but could be circumvented by importing from @repo/db/client directly.
Phase 3: Structural Removal (TRO-94)
The bare db object was removed from @repo/db's main entry point entirely. It now lives only at @repo/db/client -- a deliberate import path that signals "I know what I'm doing and I have a legitimate reason." The default import (@repo/db) exports withTenantContext, schema definitions, and query helpers, but not db.
Current Enforcement Stack
| Layer | Mechanism | What It Catches |
|---|---|---|
| Package structure | db not exported from @repo/db main path | TypeScript error on import { db } from "@repo/db" |
| ESLint | no-restricted-imports on @repo/db in routers/tools | Import of db in directories that should only use ctx.db |
| Architecture tests | Vitest tests scan router files for authorizedProcedure usage | Router files that skip the full auth+RLS middleware chain |
| Code review | Arch review checklist Section 1 | Imports from @repo/db/client in tenant-scoped code |
| AI review | /arch-review skill | Same as code review, automated on PRs |
The /client Subpath Pattern
Only four legitimate use cases import from @repo/db/client:
- AI usage recording -- system-level telemetry that crosses tenant boundaries
- MCP auth resolution --
resolveOrganizationId()runs before a tenant context exists - Health checks --
SELECT 1has no tenant context - Migrations and seeds -- schema changes require superuser access
If you see import { db } from "@repo/db/client" in any other context, it is a violation.
For the full pattern and examples of legitimate bare db usage, see Data & Storage -- Non-Tenant Queries.
Zero ESLint Exceptions
The PAT router initially used eslint-disable to import bare db (PATs are user-scoped, not tenant-scoped). Rather than allow the exception, the PAT query functions were refactored into @repo/db as named functions (listUserPATs, createPAT, revokePAT), eliminating the only eslint-disable in the boundary enforcement system.
The principle: no exceptions means no precedent for future exceptions. Every eslint-disable for an architectural rule requires justification strong enough to survive an architecture review.
Architecture Tests
The Vitest architecture test in packages/api/src/__tests__/arch.test.ts scans all router files and asserts that they use authorizedProcedure. An explicit allowlist exists for the small number of routers that legitimately use lower-level procedures (health checks, pre-auth endpoints).
Expanding the allowlist without justification is a red flag in architecture review. See Review Rules for what to check.
What AI Agents Get Wrong
AI agents writing code take the shortest path to working functionality. Common bypass patterns:
- Importing from
@repo/db/clientinstead of usingctx.db-- works around the main export restriction - Modifying
@repo/db/src/index.tsto re-exportdb-- removes the structural protection at its source - Adding entries to the architecture test allowlist with weak justifications like "CASL not needed yet"
- Using
eslint-disablecomments to suppressno-restricted-imports
Each of these is caught by a different layer of enforcement. The redundancy is intentional -- defense-in-depth applies to the enforcement system itself.
Adding Enforcement for a New Package
When creating a new @repo/* package that touches tenant-scoped data:
- Add
restrictedImports("@repo/new-package")to itseslint.config.js - The restriction map in
packages/config-eslint/restrictions.jscontrols which SDKs and internal packages are banned in which directories - Run
pnpm lintto verify zero violations - Run
pnpm dep-cruiseto validate the dependency graph
For the full procedure, see the "Package Boundaries" section in the project CLAUDE.md.
ADR-003: Shared Schema + Row-Level Security
Decision record for choosing shared schema with PostgreSQL RLS over schema-per-tenant, database-per-tenant, and application-layer filtering.
Review Rules
Architecture review checklist items for tenant isolation -- what to verify in every PR that touches tenant-scoped code.