Skip to content

Split students.assignment — extract students.curriculum_selection and add per-scope route gating

1. Problem distillation

  • The students.assignment scope was documented in chapter 04 (the "Dual-surface scope" callout) as gating BOTH (a) field-level access to the enrollmentDate / departmentId / gradeId / studentType / identificationCode / status columns on the students table, AND (b) the per-student curriculum-selection sub-resource at GET/PATCH /students/:id/curriculum-selection (RUS-4). The doc framed this as a deliberate convergence.
  • In code, the convergence is not enforced. The curriculum-selection routes use @RequireScopes(STUDENTS, 'read'|'write'). ScopeGuard.checkEntityAccess accepts any student scope at the required level — a user with students.identity:WRITE and students.assignment:NONE passes the gate. The actual enforcement is the route's @AppliesPolicy(CurriculumSelectionRead/WritePolicy) (admin/teacher/referent) + service-level window + StudentReferentLink.canWrite. The scope key assignment never participates in the check.
  • The Parent role's students.assignment:WRITE grant is simultaneously load-bearing (in the doc's mental model) and a latent footgun (in the code). The seed grants referents assignment:WRITE. The doc's footnote-2 claims this maps to the curriculum-selection sub-resource and "not the field-level dept/grade assignment columns." But because the sub-resource doesn't read the scope grant, and FieldWriteGuard does accept assignment as a body key for any user with assignment:WRITE, a referent could in fact PATCH /students/:id with { assignment: { enrollmentDate: ... } } and have it succeed (record gate passes for linked students; FieldWriteGuard accepts the key). That isn't intended parent behavior.
  • The role matrix can't express the curriculum-selection axis. A school admin managing custom roles has no scope-level lever for "this role can edit curriculum selections". The information lives in curriculum-selection.policies.ts (which roles are admitted) and curriculum-selection.service.ts (the window/link gates). The matrix-as-source-of-truth contract from chapter 04 is not honored for this surface.
  • Sub-resource routes need a per-scope-key gate. ScopeGuard today only knows "user has any scope on entity at level X". A separate students.curriculum_selection scope without a per-scope-key check would not actually gate the route — we would just be adding a column to the matrix that no guard reads. The split has to come with a small new RBAC primitive (a singular @RequireScope(entity, scopeKey, mode) decorator) for the matrix to mean what it says.

Success criteria (observable behavior that proves this works):

  • A new students.curriculum_selection scope key exists in the seed with zero ScopeFieldMapping rows (gate-only scope). STUDENT_SCOPES.curriculum_selection = [] in src/common/constants/scope-fields.ts. The drift-guard spec (prisma/seed/rbac-catalogue.drift.spec.ts) passes.
  • A new @RequireScope(entity, scopeKey, mode) (singular) decorator + a new PermissionsService.checkScope(permissions, entity, scopeKey, mode) method exist. ScopeGuard reads the singular metadata when present and calls checkScope; falls back to the existing entity-wide checkEntityAccess when only @RequireScopes (plural) is on the handler. A handler cannot carry both forms — the guard rejects the request with a clear error at runtime, and a unit spec covers the contradiction case.
  • CurriculumSelectionController swaps @RequireScopes(STUDENTS, 'read'|'write')@RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write') on both routes. Behavior for admin and referent under the standard preset matrix is unchanged.
  • The Parent (referent) preset's seed grant on students.assignment is narrowed from WRITEREAD. The Parent gains students.curriculum_selection:WRITE. A regression E2E asserts that a referent calling PATCH /students/:id with { assignment: { enrollmentDate: '…' } } now returns 403 FORBIDDEN_FIELDS.
  • A user with students.identity:WRITE and students.curriculum_selection:NONE is rejected by ScopeGuard when calling PATCH /students/:id/curriculum-selection — the gap this spec closes. Asserted in scope.guard.spec.ts and curriculum-selection.controller.spec.ts.
  • GET /api/v1/permissions includes the new students.curriculum_selection key in the response payload for any role that holds a grant on it. getUserPermissionsDto flows it through automatically (no DTO change).
  • Chapter 04 "Dual-surface scope" callout is replaced by two single-purpose entries (assignment is now field-level only; curriculum_selection is gate-only). The cross-entity matrix gains a curriculum_selection column. Chapter 11 gains (or amends) the "Add a gate-only scope" recipe with the @RequireScope pattern.

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

  • Student self-selection. The Student role's grant for students.curriculum_selection stays NONE in v1 — the surface for students editing their own selection lands in a follow-up.
  • Splitting students.documents (currently maps to student_documents.* AND gates /students/:id/documents/* sub-routes). Similar shape, separate decision — out of scope here.
  • Renaming students.assignment. The key stays; only its semantics narrow.
  • Any change to CurriculumSelectionReadPolicy / CurriculumSelectionWritePolicy — the role/record-level filtering stays as-is.
  • Any change to the service-layer window + canWrite gate.
  • Custom-role admin CRUD endpoints (no src/roles/ module exists yet; same deferral as the parametric-roles spec).
  • Migrating other potential sub-resource gates (e.g. future students.health_records, students.attendance_records) to @RequireScope. Done one-at-a-time when the surface lands.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/permissions/decorators/require-scope.decorator.ts + src/permissions/guards/scope.guard.ts SetMetadata + Reflector pattern, PermissionRequirement shape ({ entity, mode }), InsufficientScopeException for denial. The new @RequireScope emits parallel metadata under a sibling key (REQUIRED_SCOPE_KEY); ScopeGuard is extended to branch on metadata shape. The new metadata adds a third field (scopeKey). Plural @RequireScopes keeps its current shape — the singular form is additive, not a replacement.
docs/superpowers/specs/2026-05-26-entity-access-policy-design.md Pattern of adding a small declarative primitive (@AppliesPolicy then; @RequireScope now) backed by a service method, with route-level decorator composition. The two decorators co-exist on curriculum-selection routes after this spec lands — @RequireScope for the scope gate, @AppliesPolicy for role + record gate. No boot-time invariant needed for this primitive: the new metadata is opt-in per route and doesn't introduce a drift surface like @AppliesPolicy did. The mutual-exclusion check between @RequireScopes plural and @RequireScope singular fires inside the guard, not at boot.
prisma/seed/rbac-catalogue.tsSCOPES.students.school_relationships + SCOPES.curricula.selection_window + SCOPES.referents.health Direct precedent for gate-only / scaffolding-only scopes. Three scopes in the current seed have zero ScopeFieldMapping rows: students.school_relationships (scaffolding, fields deferred), curricula.selection_window (descriptor-only), referents.health (reserved). The drift-guard spec already permits empty *_SCOPES arrays. The new students.curriculum_selection extends this pattern with explicit gating intent. None — the existing precedents are silent on intent (scaffolding, descriptor, reserved). This spec is the first to declare a scope as deliberately gate-only and to wire a per-scope-key guard against it. Chapter 04 + chapter 11 get updated to name the pattern.
src/permissions/permissions.service.ts:checkEntityAccess Same ScopeAccess enum, same meetsAccess plumbing, same permissions.scopes[entity] lookup path. The new checkScope(permissions, entity, scopeKey, mode) is a one-key sibling of checkEntityAccess — reads entityScopes[scopeKey] instead of Object.values(entityScopes).some(...). No new caching layer; the per-request memoization on request.permissions already covers it. None — pure additive method.
src/students/curriculum-selection.policies.ts Existing role/record-level enforcement stays. The policy continues to drive which records the caller can see; the new @RequireScope drives whether the caller has the gate at all. The two layers compose cleanly. Nothing about the policy file changes in this spec.
docs/04-rbac.md §"Lookup GETs" + §"Recipe: scope spanning multiple tables" Pattern of adding a sub-section to chapter 04 documenting a new RBAC primitive with a "when to reach for it / when not" framing. The new "Gate-only scopes" subsection follows the same shape. None — additive.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged. CurriculumSelectionService already filters by tenantId; this spec only changes the route gate. Tenant isolation is invariant.
Academic-year scope yes Unchanged. The selection is year-scoped via the chosen Curriculum / StudyPlan. No change in year semantics.
RBAC entity key existing — students No new entity. New scope under existing entity. One entity, two surfaces — the whole point of this spec is to separate the gates without splitting the entity.
Scopes new: students.curriculum_selection (gate-only, zero field mappings) New entry in SCOPES.students array. Empty array in STUDENT_SCOPES.curriculum_selection. No FIELD_MAPPINGS row. Gate-only sub-pattern; precedent in students.school_relationships, curricula.selection_window, referents.health (which are all gate-only by coincidence today). This spec is the first to declare gate-only as a deliberate sub-pattern.
Actions none No new action keys. Read/Write semantics handled by scope mode. Consistent with [[feedback_rbac_actions_convention]] / [[feedback_rbac_no_update_action]].
Service base n/a — CurriculumSelectionService unchanged The gate is at the decorator/guard layer, not the service. This is a permissions-layer change.
queries.ts shape unchanged No new queries.
Error codes existing — INSUFFICIENT_SCOPE reused When the singular @RequireScope check denies, ScopeGuard throws InsufficientScopeException exactly as for the plural form. The log line includes the scope key for debuggability. The response shape is unchanged. One error code keeps the FE error surface stable; the log delta is server-side only.
DTO conventions unchanged WriteSelectionDto and SelectionReadResponseDto keep their current shape. Body stays under the historical assignment wrapper ({ assignment: { curriculumId, choices } }) for FE compatibility — we are NOT reshaping to { curriculum_selection: { ... } }. The wrapper used to participate in the scope-grouped PATCH convention; under the singular @RequireScope, FieldWriteGuard skips per-key WRITE checks on this route (sub-resource body, not scope-grouped), so the wrapper is now FE-compat shape rather than a permission contract. See divergence ledger §7 row 4. Body-reshape was considered (would have leveraged FieldWriteGuard automatically) and rejected: it solves only writes, asymmetric for reads, and forces a body-shape change that exists solely to satisfy a guard. @RequireScope is the symmetric, declarative choice.
File-backed sub-resources n/a
Custom fields n/a — gate scope has no fields, so no customFields JSONB key A gate-only scope does not surface a { scope: { customFields } } group in any response. Custom fields are intrinsically field-grouped; gate-only is a different shape.
Profile completeness n/a Curriculum-selection completeness is governed by the study-plan-selections command-center surface, not by missingFields on the Student response. No COMPLETION_REQUIRED_REGISTRY change.

4. Data model plan

Schema deltas

None. This spec is purely a permission-layer change. No new tables, columns, FKs, enums, or indexes.

Migration shape

  • No Prisma migration. npx prisma migrate dev is not run.
  • Seed delta only. Re-run npx prisma db seed after pulling.

Indexes and uniqueness

n/a — no schema changes.


5. API surface

Verb Path Decorators (post-change) Request DTO Response DTO
GET /students/:id/curriculum-selection @RequireScope(EntityKey.STUDENTS, 'curriculum_selection', 'read') + @AppliesPolicy(CurriculumSelectionReadPolicy) + @AggregateResponse() n/a SelectionReadResponseDto (unchanged)
PATCH /students/:id/curriculum-selection @RequireScope(EntityKey.STUDENTS, 'curriculum_selection', 'write') + @AppliesPolicy(CurriculumSelectionWritePolicy) + @AggregateResponse() WriteSelectionDto (unchanged) SelectionReadResponseDto (unchanged)

Decorator signature

// src/permissions/decorators/require-scope.decorator.ts — augment existing file
export const REQUIRED_SCOPE_KEY = 'requiredSpecificScope';

export interface SpecificScopeRequirement {
  entity: EntityKeyValue;
  scopeKey: string;
  mode: 'read' | 'write';
}

export const RequireScope = (
  entity: EntityKeyValue,
  scopeKey: string,
  mode: 'read' | 'write',
) =>
  SetMetadata(REQUIRED_SCOPE_KEY, {
    entity,
    scopeKey,
    mode,
  } satisfies SpecificScopeRequirement);

ScopeGuard extension

// src/permissions/guards/scope.guard.ts — extend canActivate
const specificReq = this.reflector.get<SpecificScopeRequirement | undefined>(
  REQUIRED_SCOPE_KEY,
  context.getHandler(),
);
const entityReq = this.reflector.get<PermissionRequirement | undefined>(
  PERMISSIONS_KEY,
  context.getHandler(),
);

if (specificReq && entityReq) {
  // Mutually exclusive — fail loudly. A route picks one gate, not both.
  this.logger.error(
    `Route declares both @RequireScopes and @RequireScope — pick one`,
  );
  throw new AppException(
    ErrorCode.NO_AUTHENTICATED_USER, // placeholder — see error-code note below
    'Conflicting scope decorators on route',
    HttpStatus.INTERNAL_SERVER_ERROR,
  );
}

if (!specificReq && !entityReq) return true;
// … existing platform-admin bypass + permission load …

if (specificReq) {
  const ok = this.permissionsService.checkScope(permissions, specificReq);
  if (!ok) throw new InsufficientScopeException();
  return true;
}

// fall through to existing checkEntityAccess flow

Open question on the error code for the contradictory-decorator case is resolved in §10: introduce ErrorCode.SCOPE_DECORATOR_MISCONFIGURED (server-side dev error, returned as 500 — never surfaced to clients in well-formed builds; the boot-time scanner described below catches it before requests arrive).

PermissionsService.checkScope

checkScope(
  permissions: CompiledPermissions,
  requirement: SpecificScopeRequirement,
): boolean {
  const entityScopes = permissions.scopes[requirement.entity];
  if (!entityScopes) return false;
  const access = entityScopes[requirement.scopeKey] ?? ScopeAccess.NONE;
  const requiredLevel =
    requirement.mode === 'read' ? ScopeAccess.READ : ScopeAccess.WRITE;
  return meetsAccess(access, requiredLevel);
}

Boot-time invariant (scope-decorator exclusivity)

To prevent runtime-only detection of @RequireScopes + @RequireScope on the same handler, add a boot-time scanner in the same style as assertPolicyScopeAlignment:

// src/common/dto/scope-decorator-invariant.ts (new file)
// Walks every controller; fails dev/CI if a handler carries both
// PERMISSIONS_KEY and REQUIRED_SCOPE_KEY metadata. Production logs and proceeds.
// Wired in src/main.ts alongside the two existing invariants.

This is sync metadata on handlers (not return-type erasure), so the scanner is full-coverage — no async-erasure caveat like assertAggregateResponseInvariant.

Swagger considerations

  • GET /api/v1/permissions automatically includes students.curriculum_selection once seeded — getUserPermissionsDto iterates permissions.scopes[entity] and emits each key. No DTO change.
  • The two curriculum-selection routes' Swagger error annotations are unchanged: @ApiInsufficientScope() (if present) still applies.
  • No new error-examples.ts entries needed — SCOPE_DECORATOR_MISCONFIGURED is an internal misconfiguration that doesn't reach end users when the boot-time scanner is in place.

6. RBAC seed plan

Seed file Delta
PermissionScope (prisma/seed/rbac-catalogue.ts) Add { key: 'curriculum_selection', label: 'Curriculum Selection', description: 'Per-student curriculum selection (option-block choices)', sortOrder: 8 } under SCOPES.students (after referents, sortOrder 7).
PermissionAction none
ScopeFieldMapping none — gate-only scope has zero mappings (see §7 divergence)
Role grants (prisma/seed/roles.ts)
*_SCOPES runtime constant Add curriculum_selection: [] as const to STUDENT_SCOPES in src/common/constants/scope-fields.ts. Satisfies the drift-guard rule (SCOPES entry must have a matching *_SCOPES runtime constant — [] for gate-only).

Role grants on students.curriculum_selection

Role Access Why
admin WRITE Full control.
hr WRITE Confirmed in brainstorm — broad student-onboarding remit; pairs naturally with admin grants on the same entity.
principal READ Read-only across the school.
department_head WRITE Same access level as their other Students grants; record-level narrowing handled by StudentsPolicy / CurriculumSelectionReadWritePolicy (dept-head branch when the parametric-roles spec lands; until then, this role doesn't reach this surface in production presets).
teacher (internal) READ Read-only — same as students.assignment today.
teacher (external) READ Same as internal teacher for this scope.
staff (internal) READ Read-only.
staff (external) NONE No access.
accountant NONE Curriculum selection is not financial.
admissions_officer WRITE Confirmed in brainstorm — admissions sets the initial curriculum at enrollment.
referent (parent) WRITE The load-bearing grant for the parent workflow; previously implicit via students.assignment:WRITE (which never actually gated the sub-resource), now explicit.
student NONE Self-selection deferred — see §9.

Role grant delta on students.assignment

Role Before After Reason
referent WRITE READ Closes the latent footgun: today's grant authorizes PATCH /students/:id with { assignment: { enrollmentDate, studentType, identificationCode, status } } for any referent on a linked student. That was never intended behavior (per the chapter-04 footnote, which was wishful — the code doesn't restrict referent field-level writes the way the doc claimed). After this spec, referents have READ on assignment (matrix consistency with their child's record visibility) and WRITE on the new curriculum_selection scope (their actual workflow).

All other role grants on students.assignment are unchanged.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Every permission_scopes row maps to one or more scope_field_mappings rows (the "scopes group fields" rule from chapter 04 §"The Entity-Scope Permission Model") Adding students.curriculum_selection with zero scope_field_mappings rows, declared as gate-only by intent. Sub-resources with @AggregateResponse() bypass FieldFilterInterceptor entirely; their permission gate is a logical reachability check, not a field-group filter. Mapping the scope to fields on related tables (e.g. student_curriculum_selections.*) would be cosmetic — FieldFilterInterceptor never sees those rows in the sub-resource response. Precedent already exists (students.school_relationships, curricula.selection_window, referents.health) but as side-effects, not declared intent. This spec names the pattern. Chapter 04 gets a new "Gate-only scopes" subsection that distinguishes field-group scopes (drive FieldFilterInterceptor stripping + FieldWriteGuard body-key checks) from gate-only scopes (drive only @RequireScope route gates). Chapter 11 "Add a new scope" recipe gets a gate-only variant. Future maintainers reading the matrix can tell which is which by the empty STUDENT_SCOPES.curriculum_selection array.
ScopeGuard enforces "user has ANY scope on entity at mode X" via @RequireScopes(entity, mode) plural Adding @RequireScope(entity, scopeKey, mode) (singular) + PermissionsService.checkScope(...) + a ScopeGuard branch that handles the singular form when present. The plural form is the right tool for multi-scope routes (e.g. PATCH /students/:id accepting any of identity / contacts / health / … in the body — FieldWriteGuard then per-key-checks). The singular form is the right tool for routes that gate on exactly one scope — sub-resources today, more later. Mixing them on one route is a bug; the guard fails the request and the boot-time scanner fails the build. One new decorator + one new service method + one guard branch + one boot-time scanner. ~50 lines net. Mutual exclusivity is enforced at boot-time (chapter-04 invariant section gets a new entry); runtime path returns 500 with SCOPE_DECORATOR_MISCONFIGURED if the build slipped through.
Referent preset role holds students.assignment:WRITE (and the chapter-04 matrix footnote-2 explains the intent as "this maps to curriculum-selection, not field-level columns") Narrow referent's students.assignment to READ; introduce students.curriculum_selection:WRITE as the explicit grant. The current state is dishonest in two directions: the grant does NOT gate curriculum-selection (the policy + service do), and it DOES authorize field-level PATCH of assignment columns on linked students (the footnote's "not the field-level columns" claim is wrong against the code). The matrix should mean what it says — split the grants so each one corresponds to one observable capability. Breaking change to the referent preset's effective field-level capabilities on assignment columns. Per feedback_breaking_changes_acceptable.md, no shim. FE was never expected to PATCH assignment fields as a referent (no such surface exists in the FE prototype). Regression E2E asserts the new 403.
Body shape for PATCH /students/:id/curriculum-selection is flat ({ curriculumId, choices, ... }) Keep the flat body. The brainstorm considered reshaping the body to { curriculum_selection: { ... } } so FieldWriteGuard would per-key-check the scope name for free. Rejected: it solves only writes (reads have no body), forces an FE-visible shape change that exists only to satisfy a guard, and would conflict with the @AggregateResponse() symmetry on the read route. @RequireScope is the symmetric, declarative choice. One small new RBAC primitive instead of a body reshape. Future sub-resource gates use the same decorator without body acrobatics.

8. Pushback log

US says Conflicts with Proposed instead Status
n/a — no user story drives this. The work was sized from the 2026-05-27 brainstorm with Fabio after the RBAC-model audit surfaced the divergence between the chapter-04 "Dual-surface scope" claim and the code's actual enforcement. n/a — Resolved

9. Deferrals

  • Student self-selectionstudents.curriculum_selection grant for the student role stays NONE in v1. The surface for students editing their own selection (with appropriate gating via assertCallerIsSelfOrAdmin + open-window) is a follow-up. Follow-up: revisit alongside the RUS-4 student-side surface.
  • Splitting students.documents — analogous shape (the scope maps to student_documents.* field columns AND gates /students/:id/documents/* sub-routes). Could be split into students.document_files (field-group, for the JSONB columns and identity-document FK columns) + students.document_routes (gate-only, for the sub-resource). Out of scope; revisit when product asks or when another sub-resource bug surfaces on that key. Follow-up: ad-hoc spec at the time.
  • Migrating other sub-resource gates to @RequireScope — none exist today besides curriculum-selection. As new sub-resource routes land (potential students.health_records, students.attendance_records, financial sub-resources), they should adopt @RequireScope from day one. Pattern documented in chapter 04 + chapter 11.
  • Custom-role admin CRUD endpoints — same deferral as the parametric-roles spec. No src/roles/ module exists; admins can't create custom roles via API today.
  • Performance optimizationcheckScope reads from the same per-request memoized request.permissions. No additional cache.

10. Open questions

All resolved in the 2026-05-27 brainstorm; recorded here for traceability:

  • ~~Scope key naming (curriculum_selection vs curriculum vs electives vs selection)?~~ Resolved: curriculum_selection. Verbose but unambiguous; curriculum collides with the curricula entity, electives mis-describes (mandatory option blocks exist), selection too generic for future expansion.
  • ~~Field mappings — zero or comprehensive?~~ Resolved: zero (gate-only). Declared as a deliberate sub-pattern, documented in chapter 04 + chapter 11.
  • ~~Per-scope gating mechanism — new @RequireScope decorator, body-reshape, or manual PermissionsService calls?~~ Resolved: new @RequireScope (singular) decorator. Body-reshape was rejected (asymmetric reads/writes); manual calls inverts the EntityAccessPolicy lesson (declarative beats imperative).
  • ~~HR/Secretary access level on curriculum_selection?~~ Resolved: WRITE. Matches their broad student-onboarding remit.
  • ~~Admissions Officer access level on curriculum_selection?~~ Resolved: WRITE. They place students into curricula at enrollment.
  • ~~Build as a reusable pattern (vs one-off for this surface)?~~ Resolved: build as reusable. More sub-resource gates expected; documenting the pattern once is cheaper than three ad-hoc gates.
  • ~~Close the parent's latent field-level assignment-PATCH capability?~~ Resolved: yes. Narrow referent's students.assignment from WRITE to READ; the curriculum-selection capability lives on the new scope.
  • ~~Error code for the mutually-exclusive @RequireScopes + @RequireScope runtime case?~~ Resolved: new ErrorCode.SCOPE_DECORATOR_MISCONFIGURED (server-side; the boot-time scanner catches it before requests in well-formed builds, so it's effectively dead code in production).
  • ~~Need a boot-time scanner for the new exclusivity invariant?~~ Resolved: yes. Same DiscoveryService + MetadataScanner pattern as assertPolicyScopeAlignment; sync metadata so no async-erasure caveat.

11. Verification plan

  • Unit specs:
  • src/permissions/permissions.service.spec.ts — new checkScope cases: returns true when permissions.scopes[entity][scopeKey] ≥ required level; false when entity absent, scope absent, NONE, or below required level. Covers both read and write modes.
  • src/permissions/decorators/require-scope.decorator.spec.ts — extend: RequireScope(entity, scopeKey, mode) emits SpecificScopeRequirement metadata under REQUIRED_SCOPE_KEY, distinguishable from the existing PERMISSIONS_KEY (plural form).
  • src/permissions/guards/scope.guard.spec.ts — extend:
    • Singular metadata present → calls checkScope; passes when access ≥ required, throws InsufficientScopeException otherwise.
    • Plural metadata present → existing checkEntityAccess path unchanged.
    • Both present → throws AppException(SCOPE_DECORATOR_MISCONFIGURED, 500) (or whatever the final code is) with a log line naming both keys.
    • Neither present → passes through (existing behavior).
    • Platform admin bypass unchanged for both forms.
  • src/common/dto/scope-decorator-invariant.spec.ts (new file) — fixture controller with one good route, one route carrying both decorators; assertion that the scanner reports the offending handler in strict mode (throws), and logs in non-strict (production) mode.
  • src/students/curriculum-selection.controller.spec.ts — replace @RequireScopes(STUDENTS, 'read'|'write') assertions with @RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write') assertions. New negative test: user with students.identity:WRITE and curriculum_selection:NONE → ScopeGuard rejects.
  • prisma/seed/rbac-catalogue.drift.spec.ts — assert that SCOPES.students contains curriculum_selection AND STUDENT_SCOPES.curriculum_selection = [] is present (drift-guard exists today; this just adds a new row that satisfies it).
  • prisma/seed/roles.spec.ts (or wherever role-grant tests live) — assert the new grants table (admin WRITE, hr WRITE, principal READ, internal teacher READ, external teacher READ, internal staff READ, external staff NONE, admissions WRITE, referent WRITE, student NONE, accountant NONE).

  • E2E specs:

  • test/curriculum-selection.e2e-spec.ts — extend:
    • Admin + open window + valid payload → 200 (existing).
    • Referent + open window + linked student with canWrite=true + valid payload → 200 (existing; now driven by curriculum_selection:WRITE instead of assignment:WRITE).
    • Teacher (read-only on curriculum_selection) calling PATCH → 403 INSUFFICIENT_SCOPE (new — the scope key is now what's checked).
    • Custom-role test fixture with students.identity:WRITE only → PATCH /students/:id/curriculum-selection → 403 INSUFFICIENT_SCOPE (regression — the exact gap this spec closes).
  • test/students.e2e-spec.ts — extend:
    • Referent + linked student + PATCH /students/:id with { assignment: { enrollmentDate: '…' } } → 403 FORBIDDEN_FIELDS (regression: the referent no longer holds students.assignment:WRITE).
    • Referent + linked student + GET /students/:id → response still includes the assignment scope group (READ access retained).
  • test/permissions.e2e-spec.ts (or wherever /api/v1/permissions is exercised) — admin response includes students.curriculum_selection: WRITE; referent response includes both students.curriculum_selection: WRITE and students.assignment: READ (narrowed).

  • Manual verification:

  • npm run docker:reset && npx prisma db seed. Confirm GET /api/v1/permissions as admin includes students.curriculum_selection: WRITE; as referent (linked-parent fixture) includes both students.curriculum_selection: WRITE and students.assignment: READ.
  • As referent: PATCH /students/:id/curriculum-selection with a valid payload → 200. PATCH /students/:id with { assignment: { enrollmentDate: '2026-09-01' } } → 403 FORBIDDEN_FIELDS.
  • As admin: full read/write on both surfaces unchanged.
  • Confirm boot logs show the new assertScopeDecoratorExclusivity(...) (or equivalent name) invariant ran with zero violations.

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 chat 2026-05-27 after the RBAC-model audit identified students.assignment as the next cleanup; brainstorm resolved six design questions (scope key naming, gate-only field mappings, @RequireScope decorator, HR/admissions WRITE, parent assignment W→R, pattern documented as reusable); three open flags (boot-time scanner, new error code, dept_head grant) acknowledged with "go on".