Skip to content

Curriculum Coordinator role + HR / Secretary preset split


1. Problem distillation

  • The preset role catalogue has one combined hr role labeled "HR / Secretary" that mixes two jobs: personnel/employment management (teachers + staff) and school-office administration (students, enrolment, configuration, class composition). Product wants them as two roles so schools can hire/assign them separately — and the standing rule ("HR stays out of non-personnel surfaces") makes the combined grant set wrong for actual HR people.
  • There is no role for the person who owns a curriculum (e.g. the IB coordinator): dept-head is the closest fit but is scoped to a whole department. We add curriculum_coordinator — the second parametric role, parameterDim = CURRICULUM — with dept-head-shaped grants narrowed to the assigned curricula.
  • The parametric-roles machinery (2026-05-27 spec) was explicitly designed for a second dimension; this is its first exercise. The semantic mapping (which entities narrow by curriculum, and how) is the design work; the plumbing is paved.

Success criteria (observable behavior that proves this works): - An admin can assign curriculum_coordinator over one or more curricula via POST /role-assignments (parameter values validated against the curricula table, cross-tenant rejected). - A coordinator sees and edits only their assigned curricula (PATCH /curricula/:id on an unassigned curriculum → 404), sees/creates/deletes homerooms and subject groups only under those curricula, sees students only where curriculumSelection.curriculumId is in their set (read-only), and reaches the placement boards + eligibility pickers self-narrowed to their slice. - A secretary user can do everything the old combined role could on students/configuration/composition/invitations; an hr user can do everything the old role could on teachers/staff/invitations — and neither can write the other's surfaces (403/FORBIDDEN_FIELDS on scope writes, 403 on actions). - rbac-grants.db-sync e2e is green against the updated expectedPresetGrants mirror — including the absence of the grants hr lost. - Existing tenants (hosted) receive the two new preset roles on next deploy without manual intervention; existing hr assignees keep working with the narrowed grant set.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No recipient-type gate on invitations (HR can invite students, secretary can invite teachers — accepted overlap for v1). - No teacher↔curriculum narrowing (coordinator sees teachers tenant-wide, read-only directory). - No coordinator write access on student records, selection windows, or department/grade configuration. - No curricula.create/delete for the coordinator (see §7 — incoherent for an id-scoped role). - No standing full-reconcile seed semantics (deletion of drifted grants stays a one-off migration; the Wave-3 #4 "standing reconcile deferred" decision stands). - No widening of POST /students/import beyond admin (deferral, §9).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-05-27-parametric-roles-design.md + src/common/constants/parameter-dim.ts The whole dimension mechanism: enum value, DIMENSION_TARGETS row, ParametricContext type inference, loadParameterValues case, RolesGuard dimension-overlap admission, app-layer userRoleParameter.deleteMany cascade (src/departments/departments.service.ts:541) Fits cleanly — this is the "future values land alongside the first role using each" path the spec reserved
prisma/seed/roles.ts (seedRoleForTenant + rule-set constants) + prisma/seed/helpers/expected-preset-grants.ts Declarative write-set/excluded-set/action-set constants, exported and reused by the drift mirror Upserts never delete: narrowing hr leaves stale WRITE/action rows → needs a one-off data migration (§4)
src/permissions/role-clone.ts (cloneRolesIntoTenant) Additive-idempotent preset materialization per tenant Runs only at Tier-2 seed / tenant provisioning — existing hosted tenants never re-clone; needs a Tier-1 backfill step for missing roles (§4)
src/students/students.policy.ts, src/homerooms/homerooms.policy.ts, src/subject-groups/subject-groups.policy.ts (DEPARTMENT parametricBranches) The parametric branch shape, NEVER_MATCH_WHERE fail-closed default, role-agnostic dimension activation Curriculum has no FK on students/teachers — narrowing paths differ per entity and two entities get no CURRICULUM branch at all (staff, referents)
src/homerooms/homerooms.service.ts:358 (assertDepartmentVisible) Write-side create gate expressed as policy-visibility (DepartmentsPolicy.where(ctx) AND payload id) Homeroom create validates only the department today; the curriculum payload FK needs the same treatment for the coordinator to be fenced (§5)
docs/superpowers/specs/2026-06-09-role-grant-editing-design.md Frozen-role list on PATCH /roles/:key (admin + profile-coupled frozen; management presets editable) Fits cleanly — secretary and curriculum_coordinator are management presets, editable by data like hr/department_head

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Roles/grants ride existing tenant-cloned preset rows; UserRoleParameter values validated tenant-owned in RoleAssignmentsService.validateParameters; all policy narrowing composes from buildBase({ tenantId }) No new tables
Academic-year scope yes (inherited) Curriculum rows are AY-scoped, so CURRICULUM parameter values bind to a specific AY's rows — same semantics as DEPARTMENT (departments are AY-scoped too): rollover requires re-assignment Consistent with the existing dimension; no new mechanism
RBAC entity key existing No new entity; both roles are grant-matrix deltas over existing entities
Scopes existing only No new PermissionScope rows; secretary/curriculum_coordinator/narrowed hr recombine existing scopes
Actions existing only No new action keys; role↔action grant matrix changes only
Service base n/a No new domain module
queries.ts shape existing loadParameterValues (role-assignments.queries.ts) gains a CURRICULUM case (prisma.curriculum.findMany, label = name) The void DIMENSION_TARGETS[dim] tripwire forces this update
Error codes existing INVALID_ROLE_ASSIGNMENT_PARAMETER already carries parameterDim in params — works for CURRICULUM unchanged
DTO conventions existing AssignRolesDto.parameterValueIds is dimension-agnostic; RoleAssignmentDto.parameterValues[].label resolves from curriculum name
File-backed sub-resources n/a
Custom fields yes (grants only) others-scope grants per role (see §6)
Profile completeness no n/a — roles are not person-profile data

4. Data model plan

Schema deltas

  • enum ParameterDim gains CURRICULUM (additive).
  • No table/column changes.

Migration shape

  • Migration 1 (schema, additive): ALTER TYPE "ParameterDim" ADD VALUE 'CURRICULUM';
  • Migration 2 (data, hand-authored DML) — the hr-split cleanup, idempotent:
  • Relabel: UPDATE roles SET label = 'HR' WHERE key = 'hr' AND label = 'HR / Secretary' (global preset + tenant clones; the label-guard preserves any custom relabel).
  • Delete role_permissions rows for key = 'hr' roles whose scope is not in the narrowed hr grant set (i.e. drop WRITE+READ rows for the scopes hr now excludes, and downgrade handled by seed's update: { access } for the WRITE→READ ones — the migration only deletes rows for newly-excluded scopes; access-level downgrades flow from the Tier-1 reseed).
  • Delete role_action_permissions rows for key = 'hr' roles whose action is not in the narrowed HR_ACTION_KEYS.
  • Written as key-based SQL (joins through roles.key/permission_scopes.key/permission_actions.key) so it applies uniformly to the global row and every tenant clone.
  • Preset backfill for existing tenants (seed, not migration): Tier-1 gains a step after seedGlobalRolePresets — for every existing tenant, clone only the preset roles the tenant is missing (create role + grants; never touch roles the tenant already has). Implemented as a backfillMissingPresetRoles(prisma) sibling to cloneRolesIntoTenant in src/permissions/role-clone.ts. This is how secretary + curriculum_coordinator reach hosted tenants; it is additive-only, so it cannot revert per-tenant grant edits.
  • Data backfill: none beyond the above (existing hr UserRole assignments stay valid; nobody is auto-granted secretary).
  • Hazards (ch. 12): enum ADD VALUE — safe/additive; DML migration — key-guarded and idempotent; check for an uncommitted migration in tree before migrate dev and fold if present.

Indexes and uniqueness

  • None new.

5. API surface

No new routes. Deltas to existing surfaces:

Verb Path Change
POST/PATCH /role-assignments Accepts roleKey: 'secretary' (non-parametric) and 'curriculum_coordinator' (+ parameterValueIds validated against curricula) — no code change beyond the loadParameterValues case; missing values on a parametric role already → INVALID_ROLE_ASSIGNMENT_PARAMETER
GET /roles Two new rows appear from data; no code change
PATCH /roles/:key secretary/curriculum_coordinator grants editable (management presets, not frozen) — no code change
PATCH /curricula/:id (+ /status) Row must resolve through CurriculaPolicy.where(ctx) so a coordinator only edits assigned curricula — verify/thread ctx on the write path if not already
POST /homerooms New write-side gate: payload curriculumId must be visible via CurriculaPolicy.where(ctx) (404 otherwise) — fences the coordinator; dept-head passes via the DEPARTMENT branch, admin via pass-through
POST /subject-groups Anchor already resolves through CurriculumSubjectsPolicy — gains the CURRICULUM branch, self-fencing
GET /homerooms/placement-board, /subject-groups/placement-board, /students/eligible-for-{homeroom,subject-group} Coordinator admitted via RolesGuard dimension-overlap (policy parametricDimensions now includes CURRICULUM); responses self-narrow through the policies. Verify each route carries @AppliesPolicy (the overlap admission reads policy metadata); add it where missing rather than enumerating the role key
POST /teachers/import, /staff/import Allowlists unchanged (admin,hr,department_head / admin,hr) — personnel stays HR
any routes listing 'hr' in @RequireRoles on non-personnel surfaces Audit sweep: re-map to secretary (or add it) per the split — grep says today's literals are only the two import routes above + a comment in departments.service.ts; the sweep confirms

Swagger considerations

  • None — role keys are data; examples in error-examples.ts already use department_head for the parametric error shapes.

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) see matrices below
*_SCOPES runtime constant none (no new scopes)

hr (narrowed; key stays hr, label → "HR")

  • WRITE: teachers.{identity,contacts,employment,documents,health}, staff.{identity,contacts,employment,documents,health}, invitations.management.
  • READ: everything else not excluded (keeps tenant-wide visibility — students, config, composition — per brainstorm decision).
  • Excluded (unchanged): evaluation_scales.configuration, timetable_templates.configuration, referents.*, — plus nothing new.
  • Actions: teachers.{create,delete}, staff.{create,delete}, invitations.{send,reset}.
  • Others-scopes: WRITE on {teachers, staff}, READ elsewhere (was ALL_WRITE).

secretary (new preset, non-parametric)

  • WRITE: students.{identity,contacts,assignment,curriculum_selection,documents}, departments.configuration, grades.configuration, rooms.configuration, curricula.configuration, homerooms.composition, subject_groups.composition, invitations.management.
  • READ: everything else not excluded — including teachers/staff directory.
  • Excluded: evaluation_scales.configuration, timetable_templates.configuration, referents.* (as hr), plus teachers.health, staff.health (directory yes, health no — mirrors the teacher-role precedent).
  • Actions: students.{create,delete}, departments.{create,delete}, grades.{create,delete}, rooms.{create,delete}, curricula.{create,delete,open_selection_window,edit_selection_window}, homerooms.{create,delete}, subject_groups.{create,delete}, invitations.{send,reset}.
  • Others-scopes: WRITE on {students, departments, grades, rooms, homerooms, subject_groups}, READ elsewhere.

curriculum_coordinator (new preset, parameterDim: 'CURRICULUM')

  • WRITE: curricula.configuration, homerooms.composition, subject_groups.composition.
  • READ: students.{identity,contacts,assignment,curriculum_selection}, teachers.{identity,contacts}, departments.configuration, grades.configuration, curricula.selection_window, school.configuration, academic_years.*, attendance.register, grade_entries.record (record-level policies still fail closed where no CURRICULUM branch exists).
  • Excluded: users.profile, evaluation_scales.configuration, timetable_templates.configuration, rooms.configuration, invitations.management, referents.*, staff.*, students.{health,documents,referents_and_guardians,school_relationships}, teachers.{employment,documents,health}.
  • Actions: homerooms.{create,delete}, subject_groups.{create,delete} only. No curricula.{create,delete}, no window actions, no people actions.
  • Others-scopes: WRITE on {homerooms, subject_groups}, READ on entities where a native scope is granted; the exclusion set has no others-scope analog, so unlisted entities default READ (same shape as dept-head).

Policy deltas (record level)

Policy CURRICULUM parametric branch secretary branch
CurriculaPolicy id: { in: ids } pass-through
SelectionWindowsPolicy department: { curricula: { some: { id: { in: ids } } } } pass-through
DepartmentsPolicy curricula: { some: { id: { in: ids } } } pass-through
GradesPolicy (dept grades) department: { curricula: { some: { id: { in: ids } } } } pass-through
HomeroomsPolicy curriculumId: { in: ids } pass-through
SubjectGroupsPolicy curriculumSubject: { curriculumId: { in: ids } } pass-through
CurriculumSubjectsPolicy curriculumId: { in: ids } pass-through
StudentsPolicy curriculumSelection: { curriculumId: { in: ids } } pass-through
TeachersPolicy none — role-keyed { role: 'curriculum_coordinator', build: 'pass-through' } (no teacher↔curriculum path; staff.policy's dept-head listing is the precedent) pass-through
StaffPolicy none (scopes excluded → ScopeGuard blocks first) pass-through
ReferentsPolicy none none (referents stay admin/principal/dept-head)
GuardiansWritePolicy etc. none none — guardian writes stay admin + linked referent
- hr pass-through branches stay everywhere they exist today (visibility retained; WRITE removed at scope level).
- Exact Prisma relation names (Department.curricula, Student.curriculumSelection, SubjectGroup.curriculumSubject.curriculumId) verified at implementation; shapes above are the design.

Dimension plumbing

  • parameter-dim.ts: CURRICULUM entry + DIMENSION_TARGETS.CURRICULUM = { delegate: 'curriculum', accessContextKey: 'curriculumIds' }ParametricContext gains curriculumIds by inference; the @AccessContext() fallback literal (access-context.decorator.ts:32) updates via compile error.
  • loadParameterValues: case 'CURRICULUM' → curriculum rows, label = name.
  • CurriculumService hard-delete path: userRoleParameter.deleteMany({ valueId: id, userRole: { role: { parameterDim: 'CURRICULUM' } } }) before delete — mirrors departments.service.ts:547; extend the orphan drift-guard spec.
  • expectedPresetGrants: rewrite hr, add secretary + curriculumCoordinator from the same exported constants.
  • Seed fixtures (users.ts): e2e fixture users for secretary and curriculum_coordinator (coordinator with a parameter value pointing at an e2e curriculum), mirroring the dept-head fixture.

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Dept-head grant shape ("WRITE on everything in MY ") Coordinator gets no curricula.create, and students are READ-only A role scoped to existing curriculum ids can never validate a create against its parameter set (the new id can't be in the set); student records belong to the department/office, not the curriculum Coordinator asks admin/secretary/dept-head for new curricula and for student-record fixes
Parametric branches are role-agnostic ("by data, not by key") TeachersPolicy admits the coordinator by role key pass-through There is no teacher↔curriculum relation to narrow by; the alternative (narrow via SG-teaching assignments) is circular — you need to see teachers before assigning them to SGs A custom CURRICULUM-dim role won't see teachers without a code change; staff.policy.ts's dept-head listing is precedent
Seed upserts are add/update-only ("standing reconcile deferred", Wave-3 #4) One-off DML migration deletes hr's newly-excluded grants Narrowing a preset is a removal; upserts can't remove, and we deliberately don't want standing delete-reconcile semantics A future re-narrowing needs another migration; the rbac-grants.db-sync drift e2e catches any residue
Tier-2-only tenant role cloning Tier-1 gains additive-only backfillMissingPresetRoles for existing tenants New preset roles must reach hosted tenants without manual SQL; create-only semantics can't clobber per-tenant grant edits Slightly larger Tier-1 seed surface (runs in prod) — bounded to role-existence checks
§6 secretary WRITE set (post-sign-off, found in e2e verification) students.referents_and_guardians added to secretary WRITE (spec listed it READ) The scope block is mandatory on POST /students and FieldWriteGuard requires WRITE on every top-level key of a create body — the spec'd matrix made students.create unusable Secretary edits student-side referent links/contacts; the standalone referents entity stays excluded for both split roles

8. Pushback log

Request says Conflicts with Proposed instead Status
"Curriculum coordinator — same as Dep head but the dimension is the curriculum" curricula.create, selection-window actions, student WRITE, teacher WRITE don't transfer coherently to an id-scoped dimension (see §7) Dept-head-shaped WRITE only where curriculum containment is real (curricula/homerooms/SGs); READ elsewhere Resolved — approved in chat 2026-07-02 ("go on" on the brainstorm matrix)
"Split HR from Secretary" (no grant detail given) HR = personnel (teachers/staff) per the standing hr-scoping rule; secretary = the remainder of the old combined role; both keep invitations un-gated; both keep tenant-wide READ visibility Resolved — approved in chat 2026-07-02

9. Deferrals

  • Recipient-type gate on invitations (HR→teacher/staff only, secretary→student/referent only) — off-axis write-side gate for little v1 risk — follow-up: revisit if product asks for hard separation.
  • POST /students/import widening to secretary — route is admin-only today; widening is a product-visible change nobody asked for — follow-up: revisit with product.
  • Teacher↔curriculum narrowing for the coordinator — no relation to narrow by; needs a real teaching-assignment signal — follow-up: revisit when SG-teaching-derived visibility is wanted.
  • Custom CURRICULUM-dim roles seeing teachers — blocked on the same signal (see §7 divergence) — follow-up: same iteration as above.
  • Coordinator on attendance/grades record-level visibility — scope READ granted but AttendancePolicy/grades visibility builder have no CURRICULUM branch (fail closed) — follow-up: wire when a coordinator-gradebook US lands.

10. Open questions

None — all brainstorm decisions resolved in chat 2026-07-02 (grant matrices §6, invitations overlap, HR visibility retention, students-import deferral, role key curriculum_coordinator).


11. Verification plan

  • Unit specs:
  • Policy specs (each policy touched in §6): CURRICULUM branch narrows correctly, empty curriculumIds falls out of the OR, secretary pass-through admitted, coordinator + secretary fail closed on referents/guardians.
  • roles.guard.spec.ts: dimension-overlap admission for a CURRICULUM-dim user on a policy declaring CURRICULUM; denial when route policy declares only DEPARTMENT.
  • role-assignments.service.spec.ts + role-assignments.queries (via service): CURRICULUM value validation (unknown id, cross-tenant, missing values on parametric role).
  • homerooms.service.spec.ts: create rejects a payload curriculumId outside CurriculaPolicy.where(ctx) (coordinator ctx), passes for dept-head/admin.
  • Curriculum delete cascade: userRoleParameter.deleteMany called with the CURRICULUM filter (mirror of the departments spec case).
  • Seed mirror: expectedPresetGrants includes the three matrices; grant constants exported/consumed by both writer and mirror.
  • E2E specs:
  • rbac-grants.db-sync.e2e-spec.ts: green against the new mirror — proves hr's removed grants are actually gone post-migration+reseed.
  • Adversarial role e2e (extend the Wave-3 pattern): coordinator CRUD inside vs outside their curriculum (curricula PATCH, homeroom/SG create/roster, students list narrowed, placement boards narrowed); secretary blocked on teachers/staff writes + imports; hr blocked on students/config writes + student invitations still allowed (v1 overlap locked in as intended behavior).
  • Role-assignment e2e: assign curriculum_coordinator with curriculum values; assignment over a deleted curriculum cleans its parameter rows.
  • Manual verification: none beyond e2e — no UI in this repo; FE picks up new role keys from GET /roles.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-02
  • Chat reference: "approved" in chat 2026-07-02, after brainstorm of grant matrices + split mechanics and spec walkthrough

Until this section is filled, no implementation code is written.