Skip to content

Parametric roles — generalize UserRole.departmentId, narrow teacher, flip resolution to OR

1. Problem distillation

  • The UserRole.departmentId FK is a one-shot parameter slot. It works for department_head but doesn't generalize. A second parametric role (e.g. future head_of_year over Grade, class_teacher over Homeroom, custom "STEM Coordinator" over a Department subset) would require either another nullable FK column with its own partial-unique-index gymnastics, or a polymorphic shape. The 2026-05-25 spec explicitly called this out as the lesser-of-two-evils for "one parametric role".
  • Custom parametric roles are currently impossible. A school admin who wants "Math Coordinator for Departments X+Y" cannot create such a role today: (1) the schema has no parameter slot beyond the dept_head-specific FK; (2) the per-entity policy branches are hard-coded by role name ('department_head'), so a new role key would never trigger narrowing even if it had department assignments.
  • First-match resolution masks the teacher pass-through stub. Today, every *.policy.ts declares { role: 'teacher', build: 'pass-through' } with a TODO comment to narrow it. The landed makeRoleVisibilityResolver returns the first matching branch, so a teacher+dept_head user happens to get the correct dept_head narrowing — but a teacher alone sees the entire tenant. Switching to OR composition naturally surfaces this latent bug; narrowing teacher properly lets OR be correct.
  • The picker filters (isOnlyDeptHead) duplicate the policy WHERE. homerooms.service.ts:findEligibleStudents and subject-groups.service.ts query Students directly with query.departmentId, then hand-roll a guard via isOnlyDeptHead(ctx). The 2026-05-26 EntityAccessPolicy refactor established that policy WHEREs are the source of truth for record visibility; pickers should compose with them, not parallel them.

Success criteria (observable behavior that proves this works):

  • A Postgres schema with no UserRole.departmentId column. Department assignments for department_head live in UserRoleParameter rows keyed by valueId. Role.parameterDim declares which dimension a parametric role consumes.
  • RecordAccessContext exposes parametric values under a typed parameters substruct (parameters.departmentIds) instead of a flat departmentIds field. A new teacherDepartmentIds field carries the same shape for the identity-narrowed teacher branch.
  • definePolicy accepts an optional parametricBranches: [{ dim, narrow }]. Per-policy 'department_head' role-keyed branches are deleted and replaced with { dim: 'DEPARTMENT', narrow } in parametricBranches. The 'teacher' role-keyed branch returns a narrowing build (using ctx.teacherDepartmentIds), not 'pass-through'.
  • EntityAccessPolicy.where(ctx) returns the OR of all matching branches (parametric + role-keyed) rather than the first match. Platform-admin and tenant-full roles (admin/hr/principal/staff) short-circuit by virtue of contributing pass-through to the OR.
  • RolesGuard admits a user whose role allowlist does not overlap policy.roles if their parametric dimensions overlap policy.parametricDimensions. This is the lever that admits future custom parametric roles whose role-key isn't enumerated anywhere.
  • findEligibleStudents (homerooms and subject-groups pickers) compose StudentsPolicy.where(ctx) into their Prisma WHEREs. The isOnlyDeptHead helper has no remaining call sites and is deleted.
  • Existing E2E suites for every role-aware entity pass without modification under the new resolution. Existing per-policy specs are rewritten to assert OR composition (the prior dept_head precedes staff priority assertions are replaced with dept_head + staff → narrowed by dept and dept_head + admin → pass-through assertions reflecting the OR semantics).

Non-goals (in-scope-shaped things this iteration is explicitly not doing):

  • Custom-role admin CRUD endpoints. There is no src/roles/ module or Controller('roles') today; admins cannot create custom roles via API. The schema supports it; the surface lands when product asks.
  • Pre-registering additional ParameterDim enum values (GRADE, HOMEROOM, ROOM). Conservative path: add an enum value in the migration that lands the first role using it.
  • Reworking narrowRolesForActiveProfile. The synthetic-profile prepend is harmless under OR + narrowed teacher: a profile=teacher user with no real RBAC grants has ctx.teacherDepartmentIds = [], so the teacher branch returns NEVER_MATCH and contributes nothing to the OR.
  • Narrowing the staff branch. staff stays 'pass-through' in v1 — a staff-profile user's tenant-wide visibility is desired for now; write-side narrowing is already enforced by scope grants + FieldWriteGuard. Revisit when product asks.
  • Splitting the assignment scope dual-surface. Tracked separately.
  • Permission caching (Redis). Deferred; request-scoped memoization remains.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/common/utils/entity-access-policy.ts + access-context-helpers.ts The landed definePolicy + makeRoleVisibilityResolver shape — branches, pass-through marker, NEVER_MATCH_WHERE sentinel, platform-admin short-circuit. The new parametric branches are a strict superset of the existing branch type; policy.parametricDimensions is a new sibling of policy.roles derived from the new branch array. First-match resolution is replaced by OR composition; comments naming "synthetic staff" are corrected to "synthetic active-profile role (staff or teacher depending on the user's Person profile)" while editing each policy file.
docs/superpowers/specs/2026-05-25-preset-management-roles-dept-scoped-assignment-design.md The dept-head record-narrowing matrix per entity (direct-FK for Students/Grades/Curricula/etc., relational join for Teachers/StudyPlans/SubjectGroups) is preserved verbatim — predicates move from role-keyed build functions to dimension-keyed narrow functions, predicate shape unchanged. UserRole.departmentId and its partial-unique-index dance are replaced by the new UserRoleParameter polymorphic-value join table. The INVALID_ROLE_ASSIGNMENT_DEPARTMENT error code generalizes to INVALID_ROLE_ASSIGNMENT_PARAMETER.
docs/superpowers/specs/2026-05-26-entity-access-policy-design.md The lockstep-collapse motivation — @RequireRoles ↔ helper-branch drift was the load-bearing reason for definePolicy. The parametric design dissolves the lockstep further: for parametric roles, the policy doesn't enumerate role keys at all; it enumerates dimensions. Custom parametric roles plug in by data without policy edits. The role allowlist (policy.roles) auto-derivation from role-keyed branches is unchanged. The new policy.parametricDimensions is a parallel sibling, not a replacement.
src/files/files.service.ts (polymorphic (ownerType, ownerId) pattern) and the File model in prisma/schema.prisma Polymorphic-ID precedent. UserRoleParameter.valueId is interpreted by UserRole.role.parameterDim (declared on Role) similarly to how File.ownerType declares the interpretation of File.ownerId. App-layer cleanup-on-target-delete (no native FK), drift-guard spec for orphans — same mitigation pattern. File carries the discriminator (ownerType) on the same row as the polymorphic ID. UserRoleParameter reads the discriminator off UserRole.role.parameterDim instead, because every parameter row under a UserRole shares the same dimension (the role declares it once).
src/permissions/permissions.service.tsgetAccessContextSlice + fetchDeptHeadDepartmentIds The fast-path pattern: skip the DB hit when the active role keys can't possibly need a slice. Generalizes to "fetch parametric assignments only when active roles include any parametric role" and "fetch teacher department IDs only when 'teacher' is in active role keys". The single-purpose fetchDeptHeadDepartmentIds becomes a generalized fetchParametricAssignments returning a per-dimension map. Type signature widens; same query shape.
src/teachers/TeacherDepartment (alias TeacherDepartmentAssignment) M2M Source of truth for teacher↔department links. getAccessContextSlice reads from this table to populate ctx.teacherDepartmentIds when 'teacher' is in active role keys. This is a domain table, not a role-parameter table. It stays unchanged; the parametric design is a consumer.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged. buildBase continues to return { tenantId: ctx.tenantId }. Parametric branches return a narrowed version of base; the tenant predicate is preserved through all compositions. Tenant isolation is invariant.
Academic-year scope yes Unchanged per-entity opt-in. Year-snapshotted entity policies (Students, Teachers, Staff, Homerooms, SubjectGroups, Curricula, StudyPlans) keep their year handling at the service layer. Parametric narrowing operates on the same base WHERE. No change in year semantics; parametric branches ride on the existing base.
RBAC entity key existing — none added Every policy still declares its entityKey. assertPolicyScopeAlignment boot-time check unchanged. The parametric refactor is orthogonal to entity identity.
Scopes no changes Scope-level enforcement remains in ScopeGuard + FieldWriteGuard + FieldFilterInterceptor. Parametric narrowing governs which records, not which fields. Out of scope.
Actions no changes Action-level enforcement remains in ActionGuard. Parametric values do not gate actions. Out of scope.
Service base no changes to BaseTenantedCrudService itself Services consume <Entity>Policy.where(ctx) exactly as today. The OR composition lives inside the policy; services see the same TWhere | NEVER_MATCH_WHERE return type. The refactor is at the policy layer, not the base service.
queries.ts shape unchanged Include/select constants and named query functions per module are untouched. permissions.queries.ts widens fetchDeptHeadDepartmentIdsfetchParametricAssignments + adds fetchTeacherDepartmentIds. Existing convention preserved.
Error codes one rename, no new codes net INVALID_ROLE_ASSIGNMENT_DEPARTMENTINVALID_ROLE_ASSIGNMENT_PARAMETER with params { roleKey: string, parameterDim: string \| null, reason: 'MISSING_VALUES' \| 'UNEXPECTED_VALUES' \| 'CROSS_TENANT' \| 'UNKNOWN_VALUE' }. The rename communicates that the validation generalizes beyond departments. Per feedback_breaking_changes_acceptable.md, no deprecation alias needed.
DTO conventions no new DTOs in this iteration UserRole assignment endpoints don't exist yet (no src/roles/ module). When they land, request DTOs will carry parameterValueIds: string[]. Out of scope (admin CRUD deferred).
File-backed sub-resources n/a — no file surface n/a
Custom fields n/a — no custom-field surface n/a
Profile completeness n/a — completeness gates do not consult parametric data n/a

4. Data model plan

Schema deltas

  • New enum ParameterDim { DEPARTMENT }. Single value in v1; future values added one-at-a-time in the migration that lands the first role using each.
  • Role.parameterDim ParameterDim? (nullable). Set at creation, immutable post-creation (service-layer assert; PATCH on Role does not exist today, but the assertion future-proofs).
  • New table UserRoleParameter:
    model UserRoleParameter {
      id         String   @id @default(uuid()) @db.Uuid
      userRoleId String   @map("user_role_id") @db.Uuid
      valueId    String   @map("value_id") @db.Uuid
    
      userRole UserRole @relation(fields: [userRoleId], references: [id], onDelete: Cascade)
    
      @@unique([userRoleId, valueId])
      @@index([valueId])
      @@map("user_role_parameters")
    }
    
  • Drop UserRole.departmentId column + FK constraint + user_roles_department_id_idx.
  • Drop the composite unique @@unique([userId, roleId, tenantId, departmentId]) and the partial unique user_roles_unique_when_no_department. Restore the simpler @@unique([userId, roleId, tenantId]).

Migration shape

  • Destructive — drops a column with data. Per feedback_breaking_changes_acceptable.md, we run npm run docker:reset (drop dev DB, recreate from migrations, reseed). No data backfill.
  • Single Prisma migration produces SQL roughly:
    -- New enum + table
    CREATE TYPE "ParameterDim" AS ENUM ('DEPARTMENT');
    ALTER TABLE "roles" ADD COLUMN "parameter_dim" "ParameterDim";
    CREATE TABLE "user_role_parameters" (
      "id"           UUID PRIMARY KEY,
      "user_role_id" UUID NOT NULL REFERENCES "user_roles"(id) ON DELETE CASCADE,
      "value_id"     UUID NOT NULL,
      UNIQUE ("user_role_id", "value_id")
    );
    CREATE INDEX "user_role_parameters_value_id_idx" ON "user_role_parameters"("value_id");
    
    -- Drop the dept_head FK shape
    DROP INDEX "user_roles_unique_when_no_department";
    DROP INDEX "user_roles_user_id_role_id_tenant_id_department_id_key";
    DROP INDEX "user_roles_department_id_idx";
    ALTER TABLE "user_roles" DROP CONSTRAINT "user_roles_department_id_fkey";
    ALTER TABLE "user_roles" DROP COLUMN "department_id";
    ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_role_id_tenant_id_key"
      UNIQUE ("user_id", "role_id", "tenant_id");
    
  • Hazards from chapter 12 checklist: #1 (DROP COLUMN of a non-null-required column) — accepted because we drop+reseed; not safe for prod data but prod doesn't exist yet. #2 (CREATE UNIQUE INDEX) — safe; the new constraint is at least as strict as the prior one when restricted to non-parametric roles (parametric assignments now live elsewhere). #7 (ADD CONSTRAINT FOREIGN KEY via the new table) — safe; new table, no rows.

Indexes and uniqueness

  • UserRoleParameter @@unique([userRoleId, valueId]) — a single parameter value cannot appear twice on the same assignment.
  • @@index([valueId]) on UserRoleParameter — for cascade-cleanup lookups when a dimension target (e.g. Department) is deleted at the app layer.
  • UserRole @@unique([userId, roleId, tenantId]) — restored simple unique. Parametric roles can still have multiple UserRoles per (user, role, tenant) only if they differ in time-window (validFrom/validUntil). Multiple parameter values per (user, role, tenant) live on a single UserRole's UserRoleParameter[].

Cascade & cleanup

  • UserRoleParameter → UserRole: native Prisma onDelete: Cascade. Deleting a UserRole or a Role (cascade → UserRole) wipes the parameter rows.
  • UserRoleParameter ← Department (and future dimension targets): app-layer cleanup. DepartmentsService.remove deletes UserRoleParameter rows where valueId = <deleted dept id> AND the parent UserRole.role.parameterDim = 'DEPARTMENT'. Symmetric pattern for future dimensions.
  • Drift-guard spec verifies no orphan UserRoleParameter rows after seed (similar in spirit to rbac-catalogue.drift.spec.ts).

5. API surface

No new URL routes. No request/response shape changes on existing routes. The contract is preserved end-to-end.

Decorator surface (unchanged)

Every existing @AppliesPolicy(<Entity>Policy) on every role-gated route stays as-is. The decorator continues to compose RequireRoles(...policy.roles) + SetMetadata(POLICY_METADATA, policy). The only diff is that policy itself now carries parametricDimensions and the resolver internally composes OR.

definePolicy signature

// src/common/utils/entity-access-policy.ts
export interface EntityAccessPolicy<TWhere> {
  readonly entityKey: EntityKeyValue;
  readonly roles: readonly string[];                  // unchanged — from role-keyed branches in declaration order
  readonly parametricDimensions: readonly ParameterDim[];  // NEW — from parametricBranches
  readonly where: (ctx: RecordAccessContext) => TWhere | typeof NEVER_MATCH_WHERE;
}

export function definePolicy<TWhere>(args: {
  entityKey: EntityKeyValue;
  buildBase: (ctx: RecordAccessContext) => TWhere;
  branches: ReadonlyArray<{
    role: string;
    build: RoleVisibilityBranch<TWhere>;
  }>;
  parametricBranches?: ReadonlyArray<{                // NEW
    dim: ParameterDim;
    narrow: (base: TWhere, valueIds: string[]) => TWhere;
  }>;
}): EntityAccessPolicy<TWhere>;

Resolution algorithm (replaces makeRoleVisibilityResolver body)

function where(ctx): TWhere | typeof NEVER_MATCH_WHERE {
  const base = buildBase(ctx);
  if (ctx.isPlatformAdmin) return base;

  const narrowings: TWhere[] = [];

  // Role-keyed branches: any matching `pass-through` branch short-circuits.
  // OR(base, narrow) reduces to base semantically — the first disjunct
  // already matches every row the second does. Short-circuiting on
  // pass-through gives the same result with a cleaner SQL predicate and
  // simpler test assertions.
  for (const branch of branches) {
    if (!ctx.roles.includes(branch.role)) continue;
    if (branch.build === 'pass-through') return base;
    const w = branch.build(ctx, base);
    if (w !== NEVER_MATCH_WHERE) narrowings.push(w);
  }

  // Parametric branches: contribute a narrowed where when the user has values
  // in the corresponding dimension.
  for (const branch of parametricBranches ?? []) {
    const ids = ctx.parameters[`${branch.dim.toLowerCase()}Ids`];
    if (ids.length === 0) continue;
    narrowings.push(branch.narrow(base, ids));
  }

  if (narrowings.length === 0) return NEVER_MATCH_WHERE;
  if (narrowings.length === 1) return narrowings[0];
  // OR of pure narrowings only — pass-through has already short-circuited.
  // Tenant filter is in every narrowing (via base), so OR preserves tenant
  // isolation.
  return { OR: narrowings } as TWhere;
}

Composition semantics summary:

User has Result
Platform admin base
Any role with a matching 'pass-through' branch (admin, hr, principal, staff) base (short-circuit)
One narrowing role-branch match that narrowing
One parametric-branch match (non-empty dimension values) that narrowing
Multiple narrowing matches across role-keyed and/or parametric branches { OR: [narrowing₁, narrowing₂, ...] }
No branches match (all NEVER_MATCH or no matches) NEVER_MATCH_WHERE

RolesGuard extension

// src/permissions/guards/roles.guard.ts (existing — extend, don't replace)
canActivate(ctx: ExecutionContext): boolean {
  const required = this.reflector.get<string[]>(ROLES_KEY, ctx.getHandler());
  const policy = this.reflector.get<EntityAccessPolicy<unknown> | undefined>(
    POLICY_METADATA, ctx.getHandler(),
  );
  const req = ctx.switchToHttp().getRequest();

  if (req.user.isPlatformAdmin) return true;

  // Today's check: any user role overlaps the required allowlist.
  if (required?.some((r) => req.user.roles.includes(r))) return true;

  // NEW: admit by parametric-dimension overlap. Lets future custom parametric
  // roles enter via data — they aren't enumerated in policy.roles, but their
  // dimension is.
  if (policy?.parametricDimensions?.length) {
    const userDims = req.user.parameterDimensions ?? [];
    if (policy.parametricDimensions.some((d) => userDims.includes(d))) {
      return true;
    }
  }

  throw new ForbiddenException(/* ACTION_NOT_PERMITTED */);
}

req.user.parameterDimensions is populated by the same hook that builds request.permissions (via getAccessContextSlice). It is the set of ParameterDim values for which the user has any active UserRoleParameter row in this tenant.

Swagger considerations

  • INVALID_ROLE_ASSIGNMENT_DEPARTMENTINVALID_ROLE_ASSIGNMENT_PARAMETER rename: update src/common/constants/error-examples.ts and any Swagger error annotation referencing the old code. No new examples needed.
  • The Cross-Entity Preset Role Matrix in docs/04-rbac.md gains a small note that department_head narrowing is now driven by the parametric model. No matrix cell changes (semantics unchanged).

6. RBAC seed plan

Seed file Delta
PermissionScope (prisma/seed/rbac-catalogue.ts) none
PermissionAction (prisma/seed/rbac-catalogue.ts) none
ScopeFieldMapping (prisma/seed/rbac-catalogue.ts) none
Role grants (prisma/seed/roles.ts) department_head preset upsert gets parameterDim: 'DEPARTMENT' in its identity payload. All other preset roles (admin, hr, principal, teacherRole, referentRole) leave parameterDim = null explicit (default). The seed helper seedRoleForTenant accepts a new optional parameterDim argument. The description string for department_head updates from "the parameter lives on UserRole.departmentId" to "narrowing values live in UserRoleParameter rows".
*_SCOPES runtime constant (src/common/constants/scope-fields.ts) none

E2E fixtures that assign department_head to a test user move from prisma.userRole.create({ data: { ..., departmentId } }) to prisma.userRole.create({ data: { ..., parameters: { create: [{ valueId: deptId }] } } }). Mechanical rewrite in test helpers.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
First-match priority in makeRoleVisibilityResolver (every matching role branch evaluated in declaration order; first wins) Replaced with OR composition — every matching branch contributes a Prisma WHERE fragment, the policy returns { OR: [...] } of all contributions (or the single fragment when only one matches). 'pass-through' contributes the base WHERE; NEVER_MATCH_WHERE contributions are filtered out. Multi-role semantics: a teacher who is also a department_head should see the union of what each role grants visibility on, not whichever role happens to be declared first. The recipe's "branch order matters" warning was protecting against a teacher-pass-through stub widening dept_head visibility; we close the root cause by narrowing teacher properly. Slightly larger SQL on multi-role users (OR of several WHERE fragments instead of a single one). Tenant filter is in every contribution via base, so OR preserves tenant isolation. The branch-order comments in every policy file lose their load-bearing meaning and are deleted (or replaced with a one-liner noting that branch order is presentational only).
teacher branch as 'pass-through' (TODO from the EntityAccessPolicy refactor) Replaced with a narrowing build: (ctx, base) => ctx.teacherDepartmentIds.length === 0 ? NEVER_MATCH_WHERE : { ...base, departmentId: { in: ctx.teacherDepartmentIds } } (or the per-entity equivalent — relational join for Teachers/StudyPlans/SubjectGroups, direct FK elsewhere). The pre-unification "teachers see all tenant students" behavior was a stub. The first version of teacher narrowing (this spec) follows the data we have today: TeacherDepartmentAssignment — a teacher sees students/teachers/curricula/etc. in the departments they are assigned to. A finer narrowing (per-class via SubjectGroupAssignment) is a future iteration. Profile=teacher users with zero TeacherDepartmentAssignment rows now see nothing (NEVER_MATCH contribution; OR'd with no other matching branch → NEVER_MATCH overall). This is the correct semantics — a teacher with no assignment shouldn't see arbitrary tenant data — but is a visible behavior change from today. Tests assert it explicitly.
UserRole.departmentId FK + composite unique + partial unique (the 2026-05-25 design) Replaced by Role.parameterDim + UserRoleParameter(userRoleId, valueId) join table. UserRole returns to a simple @@unique([userId, roleId, tenantId]). Generalizes to N dimensions without adding columns. Custom parametric roles plug in by data. valueId is polymorphic — no native FK. Cascade-on-target-delete is app-layer (precedent: File.ownerType + ownerId). Drift-guard spec catches orphans. Renamed error code INVALID_ROLE_ASSIGNMENT_DEPARTMENTINVALID_ROLE_ASSIGNMENT_PARAMETER.
Picker endpoints (findEligibleStudents in homerooms + subject-groups) hand-rolling isOnlyDeptHead-based filtering parallel to the policy Pickers compose StudentsPolicy.where(ctx) into their Prisma where. Empty visibility (NEVER_MATCH_WHERE) → return empty cohort early. isOnlyDeptHead helper deleted (no remaining call sites). Aligns the pickers with the EntityAccessPolicy pattern: policy WHEREs are the single source of record visibility. Closes a known drift surface where picker filtering and policy WHERE could diverge. Also unlocks correct multi-role visibility (a dept_head + teacher in different departments can now request a picker in their teaching dept). Pickers gain a small amount of WHERE complexity (composed AND). Test specs for pickers update to the new shape. The isOnlyDeptHead(ctx) call sites (2 production files + spec) go away — small mechanical cleanup.

8. Pushback log

US says Conflicts with Proposed instead Status
n/a — no user story drives this. The scope was distilled in the 2026-05-27 brainstorm with Fabio, building on the chapter 16 audit and the in-flight feedback that the 2026-05-25 dept_head FK was a one-shot hack. n/a — Resolved

9. Deferrals

  • Custom-role admin CRUD endpoints — no src/roles/ or src/user-roles/ module exists today. The schema supports custom parametric roles after this lands; the admin surface to create/assign them is a separate iteration. Follow-up: future spec when product asks for "schools can create custom roles".
  • Additional ParameterDim values (GRADE, HOMEROOM, ROOM) — conservative path. Each future value lands in the migration of the first role that uses it (head_of_year for GRADE, class_teacher for HOMEROOM, etc.). Follow-up: per-role spec at the time.
  • Finer teacher narrowing (per-class via SubjectGroupAssignment / HomeroomAssignment) — v1 narrows teachers to their TeacherDepartmentAssignment set, mirroring dept_head. The finer narrowing (e.g. "see only students in classes you actually teach") is a future iteration when product confirms the requirement. Follow-up: revisit alongside teacher-assignment curation (see [[project_teacher_assignments_deferred_from_curation]]).
  • Reworking narrowRolesForActiveProfile — the synthetic-profile prepend is harmless under this spec. If a future iteration needs to distinguish "real RBAC grant" from "synthetic profile marker" in ctx.roles, that work lives in a follow-up that re-examines the active-profile design (docs/superpowers/specs/2026-04-27-active-profile-design.md).
  • staff branch narrowingstaff stays 'pass-through'. A staff-profile user's tenant-wide read visibility is desired today; write-side narrowing is already enforced via scope grants + FieldWriteGuard. Revisit when product asks.
  • Permission caching — request-scoped memoization remains. Redis caching deferred per the EntityAccessPolicy spec.

10. Open questions

All resolved in the 2026-05-27 design discussion. Recorded here for traceability:

  • ~~Single-dimension-per-role vs multi-dimension-per-role?~~ Resolved: single. Covers every foreseeable preset; admins can union via multiple roles; keeps aggregation flat.
  • ~~Identity-narrowed roles (referent, student, teacher) — bring them under the parametric model?~~ Resolved: no. Their narrowing comes from user-identity → domain links, not from assignment-time parameters. They stay as role-keyed branches with per-policy build functions.
  • ~~Storage shape — polymorphic ID on UserRole vs UserRoleParameter join table vs per-dimension tables?~~ Resolved: UserRoleParameter join table (polymorphic valueId). Best balance of extensibility and FK integrity within the codebase's existing polymorphic precedents.
  • ~~Composition semantics — first-match priority vs OR?~~ Resolved: OR, conditional on narrowing teacher in the same spec to close the pass-through stub that previously made first-match safer-looking.
  • ~~Bundle teacher narrowing in this spec?~~ Resolved: yes. Narrow to TeacherDepartmentAssignment departments — same shape as dept_head per entity. Required for OR semantics to be correct.
  • ~~Remove staff branch from policies?~~ Resolved: no. Keep as 'pass-through'. The synthetic prepend is harmless under OR + narrowed teacher because staff-profile users either have a real RBAC role (which drives visibility) or get blocked by ScopeGuard (no scope grants).
  • ~~Bundle the picker refactor?~~ Resolved: yes. Aligns the pickers with the post-EntityAccessPolicy pattern; deletes isOnlyDeptHead.
  • ~~RecordAccessContext shape — flat or nested?~~ Resolved: nested parameters: { ... } substruct. Bounded churn (mostly fixture renames), lasting clarity. teacherDepartmentIds is a separate top-level field (it's identity-narrowed data, not parametric).
  • ~~Initial ParameterDim enum size?~~ Resolved: conservative — DEPARTMENT only. Future values added one-at-a-time with their consuming roles.

11. Verification plan

  • Unit specs:
  • src/common/utils/entity-access-policy.spec.ts — rewrite to cover OR composition: multi-role users (e.g. dept_head + teacher in overlapping/disjoint depts), platform-admin short-circuit, NEVER_MATCH contribution filtering, single-contribution unwrap, all-empty → NEVER_MATCH. Add coverage for parametricBranches evaluation.
  • src/common/utils/access-context-helpers.spec.ts — first-match assertions removed; isOnlyDeptHead tests deleted alongside the helper. NEVER_MATCH_WHERE shape test stays.
  • src/common/decorators/applies-policy.decorator.spec.ts — extend: parametricDimensions flows through SetMetadata(POLICY_METADATA, policy) and is readable from the handler via Reflect.getMetadata.
  • src/permissions/guards/roles.guard.spec.ts — add: parametric-dimension overlap admission, no overlap → 403, missing policy metadata → falls back to the existing role-overlap check, platform admin bypass.
  • src/permissions/permissions.service.spec.ts — extend getAccessContextSlice:
    • active roles include 'department_head' → fetches UserRoleParameter rows for DEPARTMENT dim, returns parameters.departmentIds = [...]
    • active roles include 'teacher' → fetches TeacherDepartmentAssignment ids, returns teacherDepartmentIds = [...]
    • active roles include neither → empty slice, no DB hit (fast-path)
    • returns parameterDimensions: ParameterDim[] reflecting which dims have non-empty values
  • src/permissions/permissions.queries.spec.ts — extend with fetchParametricAssignments and fetchTeacherDepartmentIds (replacing fetchDeptHeadDepartmentIds tests).
  • Every src/<entity>/<entity>.policy.spec.ts — fixtures gain parameters: { departmentIds: [...] } and teacherDepartmentIds: [...]. Assertions rewrite:
    • Old: 'dept_head precedes staff' (priority) → New: 'dept_head + staff → base (staff pass-through short-circuits, dept narrowing is dropped because base subsumes it)'. Important semantic note: the prior priority-ordering ensured the narrow won; under the pass-through short-circuit rule, the base wins (staff is intended tenant-wide). This is a deliberate behavior change documented in §7 divergence — write-side narrowing is preserved via scope grants + FieldWriteGuard.
    • Old: 'admin precedes dept_head' → New: 'admin + dept_head → base' (admin pass-through short-circuits).
    • New: 'dept_head + teacher in disjoint departments → OR of two IN-filters' (both contribute narrowings; OR composes).
    • New: 'dept_head with empty departmentIds + teacher with empty teacherDepartmentIds → NEVER_MATCH' (both narrowings are dropped, no other branch matches).
  • src/homerooms/homerooms.service.spec.ts and src/subject-groups/subject-groups.service.spec.ts — picker refactor: assert that findEligibleStudents composes StudentsPolicy.where(ctx) into the WHERE; dept_head outside their depts → empty cohort; dept_head + teacher in disjoint depts → composed cohort.
  • Drift-guard: a fresh seed produces zero orphan UserRoleParameter rows (no valueId referencing a non-existent dimension target).

  • E2E specs:

  • Every existing entity E2E covering role-gated routes (test/students.e2e-spec.ts, test/teachers.e2e-spec.ts, test/staff.e2e-spec.ts, test/departments.e2e-spec.ts, test/curriculum.e2e-spec.ts, test/homerooms.e2e-spec.ts, test/subject-groups.e2e-spec.ts, test/referents.e2e-spec.ts, test/academic-years.e2e-spec.ts, command-center e2es) — re-runs unchanged. Pass = behavioral parity preserved for single-role users.
  • Add a multi-role scenario E2E: a user holding department_head + a (synthetic-via-profile or real) teacher should see the union of dept-headed and teaching departments across Students, Teachers, Homerooms.
  • test/permissions.e2e-spec.ts (if it exists, otherwise add) — RolesGuard admission via parametric-dimension overlap: a user with no role keys in policy.roles but with UserRoleParameter rows in a dimension the policy admits → passes RolesGuard; without overlap → 403.

  • Manual verification:

  • Seed dev DB. Assign department_head to a test user via direct Prisma (no admin endpoint yet) — confirm UserRoleParameter rows are created; UserRole.departmentId no longer exists.
  • Log in as that user; GET /students returns only students in their headed departments.
  • Re-assign the same user as a teacher in a different department (via TeacherDepartmentAssignment); confirm the response now includes students from both the headed and teaching departments (OR composition working).
  • Confirm the homerooms picker correctly returns the dept_head's homerooms when querying their dept, and an empty cohort when querying a dept they neither head nor teach.

Patterns: chapter 09 (testing), and [[feedback_e2e_isolation_patterns]] for E2E discipline.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-27
  • Chat reference: Approved by Fabio in the 2026-05-27 brainstorm — Q1 horizon (b), Q2 single-dimension-per-role, Q3 identity-narrowed carve-out, Q4 OR semantics (re-confirmed at Q11), Q12 conservative DEPARTMENT-only enum, Q13 nested parameters substruct, Q14 keep staff as pass-through, Q15 bundle picker refactor. Implementation completed 2026-05-27 in-session under "no commits between tasks; run spec + code review after each".

Implementation notes (post-landing, 2026-05-27):

  • The e2e dept_head fixture switched from Staff Person profile to Teacher Person profile (with zero TeacherDepartment rows) so the synthetic active-profile prepend yields 'teacher' not 'staff'. Under the OR + pass-through-short-circuit resolver, a Staff-profile dept_head would otherwise be widened to tenant-wide reads by the staff pass-through branch on every entity policy — defeating the parametric DEPARTMENT narrowing the dept-head e2e suite verifies. The Teacher-profile fixture preserves the test intent: teacher branch contributes NEVER_MATCH (no TeacherDepartment rows), parametric DEPARTMENT branch carries the narrowing.
  • Tests that asserted teacher pass-through visibility (e.g. teacher reading curricula across the tenant, teacher reading other departments' selection windows) were updated to assert the new narrowed semantics — see test/curricula.e2e-spec.ts and test/selection-window.e2e-spec.ts.