Skip to content

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 for db-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) as app_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 in rooms/subject-levels/evaluation-scales services 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 through AdminPrismaService (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) and getActiveRoleKeys also needed withTenantGucuser_roles/roles/role_permissions/user_role_parameters are 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), matching refresh()'s replay branch. 4. Isolation-matrix Class-S representative is users (not grade_entries) for fixture economy — the predicate shape is identical and grade_entries' policy existence is asserted structurally. 5. Index audit verdict: all 23 new Class-S tables already have a tenant_id-leading index — no index migration. 6. Migration 20260702130000_extend_rls_full_coverage kept separate from the uncommitted 20260702082621 (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-layer where: { 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_URL fallback 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 AdminPrismaService RLS-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.prisma for policies — RLS is not modeled by PSL; the migration is hand-authored DDL (same as 20260702082621).
  • 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 table ENABLE + FORCE ROW LEVEL SECURITY + CREATE POLICY tenant_isolation_<table>, mirrored header comments + per-class rollback runbook (DROP POLICY / DISABLE, same as 20260702082621).
  • Data backfill: none.
  • Hazards from chapter 12 checklist: CREATE POLICY/ALTER TABLE take brief ACCESS EXCLUSIVE locks — 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 on invitations/refresh_tokens/users break login/refresh/accept on the old code. Railway runs migrate deploy then 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 PRIVILEGES from provisioning already cover every table.
  • CI drift check: migrate diff does 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 tenantId filters and composite indexes; Class-C EXISTS probes are parent-PK lookups (always indexed). Audit each of the 23 new Class-S tables for a tenant_id-leading index and add @@index([tenantId]) only where missing and the table is row-heavy (scheduled_lessons, grade_entries, audit_logs are 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.tenantIdwithTenantGuc
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)

  1. Env validation (src/config/env.validation.ts): APP_DATABASE_URL becomes @ValidateIf((o) => isProductionLikeEnv(o.NODE_ENV)) @IsString() @IsNotEmpty() — exact CORS_ORIGIN shape. Local/test keep the superuser fallback for DX.
  2. 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_roles is 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

  • findUnique lint ban on tenanted models (deferred from the Wave-4 plan Phase 3) — dropped as superseded: under full RLS coverage a findUnique({ id }) cross-tenant probe returns null structurally, so the ban's remaining value is style, not safety. Follow-up: none; recorded here and removed from docs/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-constraints policy/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_deferred memory 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_id columns?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/invitations under RLS need credential-shaped pre-tenant reads: widen the AdminPrismaService surface 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 findUnique lint 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 — parses prisma/schema.prisma, classifies every model (nullable/required/absent tenantId), asserts the 4-class partition in rls-coverage.ts is exhaustive and mutually exclusive: every required-tenantId table ∈ S, every nullable ∈ M, every no-tenantId model ∈ 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 of src/, bans AdminPrismaService references 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_URL required on production-like NODE_ENV, optional on local/test (mirror the CORS_ORIGIN cases).
  • E2E specs:
  • test/db-constraints.e2e-spec.ts (extend) — policy name + relrowsecurity + relforcerowsecurity for all 58 tables, list imported from rls-coverage.ts (no second hand-kept list).
  • test/rls-isolation.e2e-spec.ts (extend) — matrix over the 4 policy classes: Class S via grade_entries, Class M via evaluation_scales (global preset visible under both tenants' GUCs; tenant-A custom scale invisible under B's GUC and under no GUC), Class C via refresh_tokens or grade_criterion_marks, grandchild via curriculum_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=development and no APP_DATABASE_URL → process exits with the validation error; point APP_DATABASE_URL at the superuser and boot → onModuleInit throws 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.