RLS full coverage — every tenant-bearing table, hard boot guards, misroute tripwire¶
STATUS 2026-07-02: IMPLEMENTED in tree (same session as sign-off; plan
docs/superpowers/plans/2026-07-02-rls-full-coverage.md, all 12 tasks done). Verified: 733 unit tests green across every touched module; e2e green fordb-constraints(58-table policy+FORCE from the registry),rls-isolation(+ per-class matrix) and the 9 rerouted-flow suites (auth ×2, invitations, role-presets/grants/assignments, room-type-presets, subject-levels, parametric-roles — 117 tests) asapp_user. Deviations from this spec: 1. §5.1 "Platform-admin preset routes: unchanged ✓" was WRONG — the Task-7 audit found the preset in-use guards inrooms/subject-levels/evaluation-scalesservices counting Class-S tables GUC-less (silently 0 → guard defeated, worst case a live-referenced preset scale mutated with no FK backstop). Fixed by routing the usage checks throughAdminPrismaService(they are cross-tenant by definition: "referenced by ANY tenant?"), restoring their pre-RLS global semantics. The allowlist therefore grew to 5 consumers, not 2 (§7 row updated). 2. Guard-stack scope was wider than the sweep hinted:fetchUserRoles,fetchParametricAssignments(permission compilation) andgetActiveRoleKeysalso neededwithTenantGuc—user_roles/roles/role_permissions/user_role_parametersare covered. 3. Found+fixed a latent Phase-1 regression:switchActiveProfile's token-reuse family revocation joined the ambient request tx since Phase 1, so the throw rolled the revocation back; moved to the admin client (commit-through-throw), matchingrefresh()'s replay branch. 4. Isolation-matrix Class-S representative isusers(notgrade_entries) for fixture economy — the predicate shape is identical andgrade_entries' policy existence is asserted structurally. 5. Index audit verdict: all 23 new Class-S tables already have atenant_id-leading index — no index migration. 6. Migration20260702130000_extend_rls_full_coveragekept separate from the uncommitted20260702082621(ch12 Rule-1 decision: the 8-table migration is independently verified; both land in the same commit).
1. Problem distillation¶
- Wave-4 RLS landed on 8 of 58 tenant-bearing tables. The other 50 — including academically sensitive data (
grade_entries,subject_group_assignments,homeroom_assignments,invitations,users) — rely on app-layerwhere: { tenantId }discipline alone. A single missed filter or raw-SQL query on those tables is a cross-tenant leak with no structural backstop. - The
APP_DATABASE_URLfallback is silently permissive: a hosted deploy that skips role provisioning boots on the superuser URL with RLS bypassed and nothing flags it. "Deploy-safe" currently means "silently unprotected". - The GUC-delivery layer (proxy + CLS handoff) has a silent failure mode, proven by the TransactionHost misroute: a query routed to the GUC-less base client fails closed on covered tables (confusing empty results) and leaks nothing — but produces zero signal. There is no runtime tripwire converting a misroute into a loud log.
- The
AdminPrismaServiceRLS-bypass surface has one consumer today and no fence — nothing stops the next feature from adopting it as a convenience escape hatch.
Success criteria (observable behavior that proves this works):
- pg_policies + pg_class show a tenant_isolation_<table> policy with relrowsecurity AND relforcerowsecurity on all 58 in-scope tables, asserted by test/db-constraints.e2e-spec.ts against a single src-level coverage registry.
- The rls-isolation e2e matrix proves cross-tenant invisibility for one representative table of each policy class (simple column, mixed global/tenant, child EXISTS, grandchild EXISTS).
- The full existing e2e suite runs green as app_user with the extended policies — login, refresh, logout, invitation verify/accept, webhook ingestion, platform-admin preset CRUD, setup wizard, imports, timetable generation all still work.
- Boot on NODE_ENV ∉ {local,test} throws when APP_DATABASE_URL is unset, and throws when the connected role is superuser or BYPASSRLS.
- A tenanted-model delegate access outside the tenant transaction while a CLS tenant is present emits a structured warn log (tripwire), unit-tested.
- A drift spec fails when AdminPrismaService is referenced outside the allowlisted files.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- RLS on the platform-global catalog tables (tenants, the 5 RBAC catalog tables, curriculum_preset, processed_webhook_events) — no tenant data by design; excluded with recorded reasons in the coverage registry.
- Denormalizing tenantId onto child tables (see §10 Q1 — EXISTS policies chosen instead).
- The findUnique lint ban deferred from the Wave-4 plan — dropped as superseded (see §9).
- Multi-tenant users redesign (platform admins stay tenant-anchored User rows; unchanged).
- Any endpoint, DTO, or RBAC change — external behavior is identical.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-06-26-wave4-rls-request-scoped-tx-design.md + prisma/migrations/20260702082621_enable_rls_high_value_pii/migration.sql |
The entire policy shape: ENABLE+FORCE, tenant_isolation_<t> naming, the verbatim predicate tenant_id = (SELECT NULLIF(current_setting('app.current_tenant_id', true), ''))::uuid, header comments + rollback runbook |
Covers only simple tenant_id columns; this spec adds two new predicate classes (mixed IS NULL OR, child/grandchild EXISTS) |
src/common/utils/tenant-guc.ts (withTenantGuc) + src/prisma/admin-prisma.service.ts |
The Phase-2/3 auth-surface pattern: GUC-scoped tx wherever a tenant id exists, superuser client only for inherently cross-tenant reads | Auth surface grows: invitations/refresh_tokens/users by-credential lookups are pre-tenant and must move to AdminPrismaService (§5.1) |
src/permissions/no-admin-bypass-literal.drift.spec.ts (Wave-3 authz sliver) |
Grep-guard shape: recursive .ts walk, skip generated/specs, regex ban, single-file allowlist |
Fits cleanly — new spec bans AdminPrismaService references outside an allowlist instead of a roles literal |
src/config/env.validation.ts (CORS_ORIGIN + isProductionLikeEnv, Wave 1) |
@ValidateIf((o) => isProductionLikeEnv(o.NODE_ENV)) @IsNotEmpty() boot-throw shape |
Fits cleanly for APP_DATABASE_URL; the runtime role probe (§5.2) has no precedent — it's a DB query at onModuleInit, called out as a (small) invented pattern |
src/prisma/tenanted-models.ts + tenanted-models.drift.spec.ts |
Schema-parsing drift oracle keeping a hand-maintained registry honest against schema.prisma |
Registry needs a superset: the new rls-coverage.ts classifies all 66 models (simple/mixed/child/excluded), not just the strictly-tenanted 31 |
test/db-constraints.e2e-spec.ts + test/rls-isolation.e2e-spec.ts |
Policy/FORCE existence assertions; fixture-based cross-tenant invisibility proof | Table list moves from a test-local constant to the src coverage registry; isolation matrix parametrizes over policy classes |
On-axis vs off-axis (ch16): mostly on-axis — extending an existing landed pattern (RLS policies, grep-guards, env validation) along its own rails. Two small inventions, called out per the off-axis rule: the child/grandchild EXISTS predicate class (§4) and the runtime role probe + proxy tripwire (§5.2, §5.3). Both are self-contained, no new cross-module side-effects, no new consumers of existing seams.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | This IS the feature: DB-level tenant_id enforcement extended to every tenant-bearing table |
Structural backstop under the existing app-layer filters |
| Academic-year scope | no | n/a — RLS policies key on tenant_id only; AY filtering stays app-layer |
AY is an intra-tenant dimension, not an isolation boundary |
| RBAC entity key | none | n/a — no new endpoints or entities | Infrastructure-only change |
| Scopes | none | n/a — no scope changes | Same |
| Actions | none | n/a — no action changes | Same |
| Service base | n/a | No new services; AdminPrismaService gains one consumer (invitations), PrismaService gains the tripwire + role probe |
No new base-service pattern |
queries.ts shape |
n/a | No new queries; existing auth/invitation queries re-pointed at AdminPrismaService/withTenantGuc per §5.1 |
Mechanical client swaps |
| Error codes | existing | Boot guards throw plain Error at bootstrap (pre-HTTP, matching env.validation.ts); no new ErrorCode |
Failures happen before the exception filter exists |
| DTO conventions | n/a | No DTO changes | External contract untouched |
| File-backed sub-resources | n/a | files already covered by Wave 4 |
— |
| Custom fields | no | custom_field_definitions gains a simple policy; no behavior change |
Values live on person rows already covered |
| Profile completeness | no | n/a — no field changes | — |
4. Data model plan¶
Schema deltas¶
- None to
schema.prismafor policies — RLS is not modeled by PSL; the migration is hand-authored DDL (same as20260702082621). - Possible additive index migration after the index audit (below) — only if a row-heavy simple-policy table lacks any
tenant_id-leading index. - New src constant
src/prisma/rls-coverage.ts— the classification registry (not a schema change).
The coverage classification (66 models → 4 classes)¶
Class S — simple tenant_id column (31 tables; 8 already covered, 23 new). Verbatim Wave-4 predicate, USING + WITH CHECK:
tenant_id = (SELECT NULLIF(current_setting('app.current_tenant_id', true), ''))::uuid
New: custom_field_definitions, users, audit_logs, next_step_completions, user_roles, invitations, schools, academic_years, departments, grades, grade_entries, periods, rooms, curricula, curriculum_selection_windows, homerooms, subject_groups, timetables, scheduled_lessons, day_templates, week_templates, department_week_template_assignments, grade_week_template_assignments.
Class M — mixed global/tenant, nullable tenant_id (4 tables). Platform presets (tenant_id IS NULL) are visible to every tenant AND to GUC-less platform-admin routes; tenant rows only under the matching GUC:
tenant_id IS NULL OR tenant_id = (SELECT NULLIF(current_setting('app.current_tenant_id', true), ''))::uuid
roles, room_types, subject_levels, evaluation_scales.
Note the elegant collapse: platform-admin preset routes are @NoTenantTx with no GUC, so the equality arm is NULL and they see exactly the global rows — which is what the backoffice tabs want. Tenant-context requests see global ∪ own.
Class C — no tenant_id, child of a tenant-bearing anchor (23 tables). One-hop EXISTS to the parent's tenant_id; USING + WITH CHECK:
EXISTS (SELECT 1 FROM <parent> p WHERE p.id = <fk_col> AND p.tenant_id = <guc-predicate>)
| Child table | Anchor (fk) |
|---|---|
refresh_tokens |
users (user_id) |
user_role_parameters |
user_roles (user_role_id) |
student_referent_links |
students (student_id) |
teacher_departments |
teachers (teacher_id) |
canteen_lunch_shifts |
rooms (room_id) |
curriculum_grades, curriculum_tracks, curriculum_subjects, option_blocks, curriculum_rules |
curricula (curriculum_id) |
student_option_block_choices |
student_curriculum_selections (selection_id) |
homeroom_assignments |
homerooms (homeroom_id) |
subject_group_teachers, subject_group_assignments |
subject_groups (subject_group_id) |
grade_criterion_marks |
grade_entries (grade_entry_id) |
time_slots |
day_templates (day_template_id) |
week_template_days |
week_templates (week_template_id) |
role_permissions, role_action_permissions |
roles (role_id) — mixed parent: subquery uses the Class-M predicate (p.tenant_id IS NULL OR p.tenant_id = guc) |
evaluation_scale_values |
evaluation_scales (scale_id) — same mixed-parent predicate |
curriculum_subject_hours, curriculum_subject_rooms, curriculum_subject_criteria |
grandchildren — two-hop explicit join: EXISTS (SELECT 1 FROM curriculum_subjects cs JOIN curricula c ON c.id = cs.curriculum_id WHERE cs.id = <fk> AND c.tenant_id = <guc-predicate>) |
Predicates join to the tenant-bearing ancestor explicitly — they do not rely on the parent's own policy cascading through the subquery (Postgres does also apply the parent's policy inside the subquery for app_user; that double-filter is redundant-but-correct and accepted).
Class X — excluded by design (8 tables, recorded in the registry with reasons). tenants (the tenant directory itself; login tenant-picker needs cross-tenant reads), permission_entities, permission_actions, permission_scopes, scope_field_mappings, action_scope_requirements (platform-global RBAC catalog, no tenant rows), curriculum_preset (platform-global presets), processed_webhook_events (infra dedup keyed by provider event id, no tenant data).
Migration shape¶
- Additive DDL-only, one hand-authored migration
extend_rls_full_coverage: per tableENABLE+FORCE ROW LEVEL SECURITY+CREATE POLICY tenant_isolation_<table>, mirrored header comments + per-class rollback runbook (DROP POLICY / DISABLE, same as20260702082621). - Data backfill: none.
- Hazards from chapter 12 checklist:
CREATE POLICY/ALTER TABLEtake briefACCESS EXCLUSIVElocks — tables are small, downtime-free at this scale. Deploy coupling (the real hazard): the auth-surface code changes (§5.1) and this migration must ship in the same deploy — policies oninvitations/refresh_tokens/usersbreak login/refresh/accept on the old code. Railway runsmigrate deploythen boots the new app, so a single commit is sufficient; flagged for the hosted rollout anyway. Grants: none needed —GRANT ALL ON ALL TABLES+ALTER DEFAULT PRIVILEGESfrom provisioning already cover every table. - CI drift check:
migrate diffdoes not model RLS policies (same as the existing 8) — no drift-guard interaction.
Indexes and uniqueness¶
- No new uniqueness.
- Index audit task (plan): Class-S predicates piggyback on existing app-layer
tenantIdfilters and composite indexes; Class-C EXISTS probes are parent-PK lookups (always indexed). Audit each of the 23 new Class-S tables for atenant_id-leading index and add@@index([tenantId])only where missing and the table is row-heavy (scheduled_lessons,grade_entries,audit_logsare the candidates to check first). Expected outcome: few or none needed.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| — | n/a — no endpoint added, removed, or reshaped |
External behavior is identical. The work below is internal plumbing.
5.1 Auth-surface reroutes (the code half of the migration)¶
Every pre-tenant read of a newly covered table must move off the proxied no-GUC client. Full audited inventory of @NoTenantTx routes touching newly covered tables:
| Flow | Today | Under full coverage |
|---|---|---|
Login step 1 credential match (users + relations) |
AdminPrismaService (Wave 4) |
unchanged ✓ |
Login steps 2–3 (select-tenant, select-profile) + refresh-token issuance |
proxied client, no GUC | tenant known (chosen tenant / user row) → withTenantGuc, or fold into the AdminPrismaService auth surface — plan audits each read; either is acceptable, prefer withTenantGuc wherever the tenant id is already in hand |
Refresh: refreshToken.findUnique({ tokenHash }) (auth.service.ts:642) |
proxied client, no GUC — breaks | AdminPrismaService (token hash is the credential; tenant unknown until the row is read). Post-lookup reads/writes (rotation, user checks): tenant known from user.tenantId → withTenantGuc |
Logout by-hash lookup + revoke (auth.service.ts:800) |
proxied client, no GUC — breaks (silent no-op logout) | AdminPrismaService, same rationale |
Invitation verifyToken/acceptToken by-token lookup |
proxied client, no GUC — breaks | AdminPrismaService for the by-token read only; then GUC-scoped tx (set from invitation.tenantId) re-validates and does everything else — acceptToken restructures to: admin pre-read → open GUC'd tx → re-read invitation under RLS → proceed (existing setTenantGuc-inside-tx code adapts) |
Webhook handleDeliveryReport (invitations.service.ts:853) |
proxied client, no GUC — breaks | whole idempotency tx on AdminPrismaService (inbound Svix-signed webhook, inherently tenant-less; processed_webhook_events is Class X, invitations update is the bypass) |
onMailerSendFailed |
joins ambient GUC tx via afterCommitQueue flush inside the request | unchanged ✓ (verify in plan) |
| JWT strategy | no DB read (claims only) | unchanged ✓ |
| Platform-admin preset routes (roles/room-types/subject-levels/eval-scales/curriculum-presets) | proxied client, no GUC | Class-M/Class-X reads/writes unchanged ✓ — but the Task-7 audit found the preset in-use guards counting Class-S tables (rooms, curriculum grid) GUC-less → fixed: usage checks moved to AdminPrismaService (cross-tenant by definition; see status banner deviation 1) |
| Health checks | no table access | unchanged ✓ |
AdminPrismaService consumer allowlist after this work: src/auth/auth.service.ts, src/invitations/invitations.service.ts (+ the prisma module definition files). Fenced by the new grep-guard (§11).
5.2 Boot guards (two layers)¶
- Env validation (
src/config/env.validation.ts):APP_DATABASE_URLbecomes@ValidateIf((o) => isProductionLikeEnv(o.NODE_ENV)) @IsString() @IsNotEmpty()— exactCORS_ORIGINshape. Local/test keep the superuser fallback for DX. - Runtime role probe (
PrismaService.onModuleInit): on production-like envs,SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user; throw if either is true. Catches the "APP_DATABASE_URL set but pointing at a superuser" misconfiguration that layer 1 can't see.pg_rolesis world-readable; one query at boot, zero steady-state cost.
Operational consequence (accepted): the next hosted deploy fails at boot until provision-app-role.sql has been run and APP_DATABASE_URL set on that environment. That is the point — it converts "silently unprotected" into "loudly unprovisioned". Rollout order per env: provision role → set env var → deploy this change.
5.3 Misroute tripwire (log-only)¶
In the PrismaService proxy get handler: when the accessed prop is a model delegate whose model ∈ STRICTLY_TENANTED_MODELS (precomputed camelCase set), and no ambient tx is in CLS (TENANT_TX_CLS_KEY unset), and CLS tenantId is present (authenticated tenant request) → emit one structured warn (event: 'tenant_tx.model_access_outside_tx', model name) via an injected PinoLogger. Zero hits in healthy operation: withTenantGuc goes through $transaction (not a delegate prop), AdminPrismaService isn't proxied, unauthenticated routes have no CLS tenant. Any hit = the fetchTeacherDepartmentIds bug class or a TransactionHost-style misroute, now loud instead of silently fail-closed. Log-only in all envs (no throw — RLS itself is the enforcement).
Swagger considerations¶
- n/a — no public contract change; nothing enters the OpenAPI doc.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
RLS is tenant isolation, not authorization; the RBAC layer is untouched.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Wave-4 predicate standard (simple tenant_id equality) |
Class-C child/grandchild tables use per-row EXISTS subqueries to the tenant-bearing ancestor |
Children carry no tenant_id column; the alternative (denormalize the column onto 23 tables) means backfill migrations + every create-path in app code setting tenantId |
Per-row indexed-PK probe cost on child-table scans/writes (µs each at K-12 scale) vs. zero app-code churn |
| One-policy-per-table isolation semantics | Class-M permissive policy lets any GUC context write a tenant_id IS NULL row |
Platform-admin preset routes are GUC-less; a write-restrictive split policy would force them onto the bypass client for no isolation gain | RLS does not gate global-row edits — @PlatformAdminOnly (RBAC) remains the sole authorization gate there, unchanged from today |
AdminPrismaService single-consumer discipline |
Allowlist grows 1 file → 5 (auth.service.ts, invitations.service.ts, + rooms/subject-levels/evaluation-scales services — implementation deviation 1) |
Two qualifying shapes: credential-shaped lookups (login email, refresh-token hash, invitation token, webhook correlation id — no GUC can exist before the row is read) and cross-tenant-by-definition preset usage guards ("referenced by ANY tenant?") | Bypass surface widens; fenced by the grep-guard drift spec (any sixth consumer fails CI) and per-consumer JSDoc justification |
| Boot-time env validation is pure class-validator | Layer-2 role probe queries the DB inside onModuleInit |
Env validation cannot see what role a URL resolves to | One extra query at boot; invented-pattern note in §2 |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| None — engineering-driven work; source is the 2026-07-02 architecture-posture review in chat, no US text exists |
9. Deferrals¶
findUniquelint ban on tenanted models (deferred from the Wave-4 plan Phase 3) — dropped as superseded: under full RLS coverage afindUnique({ id })cross-tenant probe returns null structurally, so the ban's remaining value is style, not safety. Follow-up: none; recorded here and removed fromdocs/todo.md.- Per-table exhaustive isolation e2e — the matrix proves one representative per policy class (4 tables); the other 54 are covered structurally by the
db-constraintspolicy/FORCE assertions against the registry. Follow-up: revisit only if a policy-class bug ever slips through. - RLS on
tenants— the tenant directory is the one legitimately cross-tenant table (login tenant-picker); isolating it needs a login redesign for zero gain (it holds names/slugs, not tenant data). Follow-up: none planned. - Session-level GUC / connection pinning alternatives — the request-scoped tx mechanism is landed and verified; not reopened here. Follow-up:
project_wave4_rls_deferredmemory records the constraint. - Rate-limiting / dedup on the tripwire log — ships as log-every-hit; healthy operation is zero hits. Follow-up: revisit only if it ever gets noisy.
10. Open questions¶
All resolved in-chat 2026-07-02 (recommendations accepted as part of sign-off; flag any at sign-off to reopen):
- Q1 — child tables: EXISTS policies vs denormalized
tenant_idcolumns? → EXISTS. Zero schema/app churn, pure-DDL migration; per-row cost is an indexed PK probe, acceptable at K-12 scale. Denormalization remains available later per-table if a hot path ever demands it. - Q2 —
users/refresh_tokens/invitationsunder RLS need credential-shaped pre-tenant reads: widen theAdminPrismaServicesurface or exclude those tables? → Cover them + widen the fenced bypass. The by-credential reads are 4 narrow lookups keyed by unguessable secrets; excluding three of the most sensitive tables to avoid 2 allowlist entries inverts the priorities. Grep-guard fences the surface. - Q3 — boot-throw makes the next hosted deploy fail until each env is provisioned. Accept? → Yes — that is the feature. Rollout order documented in §5.2; local/test unaffected.
- Q4 — drop the deferred
findUniquelint ban? → Drop (§9): structurally superseded by full coverage. - Q5 — tripwire severity: log-only or throw in non-prod? → Log-only everywhere. RLS is the enforcement; a throw adds a behavior fork between envs for no containment gain.
11. Verification plan¶
- Unit specs:
src/prisma/rls-coverage.drift.spec.ts— parsesprisma/schema.prisma, classifies every model (nullable/required/absenttenantId), asserts the 4-class partition inrls-coverage.tsis exhaustive and mutually exclusive: every required-tenantIdtable ∈ S, every nullable ∈ M, every no-tenantIdmodel ∈ C ∪ X, and X members carry a written reason. A new model that isn't classified fails CI.src/prisma/admin-prisma.usage.drift.spec.ts— grep-guard (pattern:no-admin-bypass-literal.drift.spec.ts): recursive walk ofsrc/, bansAdminPrismaServicereferences outside {src/prisma/admin-prisma.service.ts,src/prisma/prisma.module.ts,src/prisma/index.ts,src/auth/auth.service.ts,src/invitations/invitations.service.ts}.src/prisma/prisma.service.spec.ts(extend) — tripwire: delegate access with CLS tenant + no ambient tx → warn logged; with ambient tx → silent; without CLS tenant → silent.src/config/env.validation.spec.ts(extend) —APP_DATABASE_URLrequired on production-likeNODE_ENV, optional onlocal/test(mirror theCORS_ORIGINcases).- E2E specs:
test/db-constraints.e2e-spec.ts(extend) — policy name +relrowsecurity+relforcerowsecurityfor all 58 tables, list imported fromrls-coverage.ts(no second hand-kept list).test/rls-isolation.e2e-spec.ts(extend) — matrix over the 4 policy classes: Class S viagrade_entries, Class M viaevaluation_scales(global preset visible under both tenants' GUCs; tenant-A custom scale invisible under B's GUC and under no GUC), Class C viarefresh_tokensorgrade_criterion_marks, grandchild viacurriculum_subject_hours. Same fixture discipline as the existing suite (getAdminPrisma()fixtures, unique markers, deleteMany cleanup).- Full existing e2e suite green as
app_user— the de-facto regression proof that every rerouted auth flow and every newly covered table still works (user-run gate). - Manual verification: boot with
NODE_ENV=developmentand noAPP_DATABASE_URL→ process exits with the validation error; pointAPP_DATABASE_URLat the superuser and boot →onModuleInitthrows the role-probe error.
Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md.
Documentation deliverables (sign-off condition — first-class, for humans AND agents)¶
Sign-off was conditioned on the result being very well documented for both audiences. The plan carries these as explicit tasks, not afterthoughts:
For humans (docs chapters):
- docs/02-multitenancy.md §4 rewritten around the full-coverage model: the 4-class table (class → predicate → member tables), the auth bypass-surface table (which flows run on AdminPrismaService and why each is inherently pre-tenant), boot guards, tripwire semantics, and a "classifying a new model" recipe (the decision tree a developer follows when adding a Prisma model).
- docs/10-infrastructure.md: APP_DATABASE_URL flips from optional-with-fallback to required on hosted, with the per-env rollout order (provision role → set env var → deploy).
- docs/12-migrations.md: registry row(s) for the new policy migration (and the index migration, if the audit produces one).
- docs/REFERENCE.md: tenant-isolation invariant updated — RLS on every tenant-bearing table, coverage registry as the pointer.
For agents (in-code canonical sources + memory):
- src/prisma/rls-coverage.ts header is the canonical registry doc: class definitions, classification rules, per-exclusion reasons, links to ch02 §4 and this spec. It's the file a drift failure points at.
- Drift-spec failure messages are instructive, not assertive-only: "model X is unclassified — add its table to a class in rls-coverage.ts (see header for the decision tree)".
- Migration header: per-class predicate rationale (incl. why NULLIF, why explicit ancestor-joins, why IS NULL OR on mixed) + per-class rollback runbook, same register as 20260702082621.
- admin-prisma.service.ts JSDoc: the consumer allowlist with per-consumer justification; tripwire code comment links the TransactionHost incident (why a silent misroute is the threat model).
- Memory: project entry for this iteration + MEMORY.md index line; docs/todo.md updated.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-07-02
- Chat reference: "i think this is sound, must be very well documented both for humans and you" — approval conditioned on first-class documentation for humans and agents; condition folded into §11 Documentation deliverables and the plan.
Until this section is filled, no implementation code is written.