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
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.