Skip to content

date: 2026-05-20 slug: rus4-curriculum-selection status: Approved clickup_us: RUS-4 (Referent: Curriculum Selection) + US-29 (Manage Curricula) minimal guard epic: Epic 5 (Curricula & Selection) coverage: docs/coverage/per-us/RUS04-curriculum-selection.md related_specs: - docs/superpowers/plans/2026-05-19-us32-selection-window.md (US-32 v1 plan, landed) - docs/superpowers/plans/2026-05-20-us32-v2-window-per-department.md (US-32 v2 plan, landed) supersedes: none


RUS-4 — Referent Curriculum Selection (+ US-29 minimal guard)

1. Problem distillation

  • Today a referent has no way to record a curriculum + option-block-subject choice for their student; US-32 v2 shipped the window but no selection surface.
  • A referent must be able to (a) read the curricula applicable to their student and the chosen structure, (b) submit a selection composed of one curriculum + a set of option-block-subject picks satisfying per-block min/max, (c) re-submit any number of times while the per-department window is OPEN.
  • An admin must be able to write the same payload via the same endpoint, in or out of window, attribution stamped from JWT role.
  • A curriculum that is referenced by at least one selection must not be deletable, and its READY → DRAFT demote path must be blocked, so US-29 stops cascading data loss.
  • Structural curriculum edits (subjects/option blocks/subjects under blocks) must not leave selections referencing dead structure — the cascade wipes invalid choices and resets the header's confirmedAt so the FE can prompt re-submission.

Success criteria (observable behavior that proves this works): - Referent with StudentReferentLink.canWrite = true can PATCH /api/v1/students/:id/curriculum-selection while the corresponding (AY, Department) window is OPEN; payload { curriculumId, choices: [{ optionBlockId, optionBlockSubjectId }, …] } is persisted; confirmedAt = now(); lastEditedBy = REFERENT. - Referent can re-submit the same endpoint with a different curriculumId or different choices while the window is OPEN; each PATCH wipes prior choices for the same selection and rewrites them in a single transaction; confirmedAt advances. - Window CLOSED (today > AY.gracePeriodEnding) → referent PATCH returns 422 SELECTION_OUTSIDE_WINDOW reason='closed'; window NOT_OPENED → 422 reason='not_open'; both states still allow GET (read-only visibility into the structure is fine). - Admin PATCH succeeds in any window state; selection is stamped lastEditedBy = ADMIN. The referent canWrite link gate does not apply to admins. - GET /api/v1/students/:id/curriculum-selection returns 200 even when no row exists, with a flag status: 'NOT_STARTED', plus the embedded applicable-curricula tree so the FE has everything to render Option Blocks immediately. - DELETE /api/v1/curricula/:id (when any selection references the curriculum) returns 409 CURRICULUM_IN_USE_BY_SELECTIONS params.selectionCount; the same code blocks PATCH /api/v1/curricula/:id with status: 'DRAFT' if any selection references the curriculum. - Structural edit of a referenced curriculum that removes an OptionBlock or OptionBlockSubject succeeds; choices for the removed structure are deleted (Postgres ON DELETE CASCADE on the FK); for any selection whose choice-set was touched, confirmedAt is reset to NULL by the service hook.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Notifying anyone (admin or otherwise) when a referent changes an already-submitted selection — deferred, even though the user has flagged that class-assignment-formed scenarios eventually need an alert. - Acting on a changed selection (e.g. moving the student to a different class, regenerating class assignments, propagating to teacher dashboards) — deferred; US-33 territory. - Auto-resolving "no actual choice" students (a curriculum with one applicable plan + zero option blocks) — referent must still submit explicitly. - StudyPlanRule cross-block constraint enforcement (e.g. "exactly 3 HL across the plan"). v1 only enforces per-block minSelections / maxSelections. - Voiding selections when a student transitions ACTIVE → non-ACTIVE; the row persists. - Admin monitoring tab in src/command-center/ showing per-student selection state. - Late-enrollment selection flow (student joins after window closes); the admin override is the workaround. - Reopen / republish / cancellation-scope flows from the Epic 5 working doc.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/students/students.controller.ts:168-244 — student documents sub-resource (POST/PATCH/GET :id/documents) The nested @Controller('students') + :id/<sub> route shape, @RequireScopes(EntityKey.STUDENTS, 'write'\|'read') + @RequireRoles('admin', 'referent', …) decorator stack, @AggregateResponse() for flat payloads Documents use multer + per-file slots; selection is a single JSON payload with a children sub-array. No file plumbing here.
src/students/students.service.ts:810-832assertReferentCanWrite(ctx, studentId) Drop-in copy: admin shortcut on ctx.isPlatformAdmin \|\| ctx.roles.includes('admin'), then studentReferentLink lookup on (studentId, referent.userId) with canWrite test, throw ACTION_NOT_PERMITTED on miss. None — this is the canonical pattern for referent-write gating.
src/curriculum/selection-window.service.ts:295-300todayUtc() + status derivation in buildWindowResponse The exact "today within window" predicate: today >= startDate && today <= ay.gracePeriodEnding. Selection service will load the (AY, student.department) window and apply the same check before any non-admin write. Window service operates on the window row directly; selection service has to look it up from the student's department first.
src/curriculum/curriculum.service.ts:1043-1172bulkSync with $transaction(async (tx) => { … }) for header + children diff The transaction shape for "wipe choices, re-insert choices, upsert header" in a single round-trip; tx.studentOptionBlockChoice.deleteMany followed by tx.studentOptionBlockChoice.createMany. bulkSync syncs a set of header rows; selection PATCH only ever touches one header. Simpler subset.
src/common/constants/error-codes.ts:117-189ErrorParamsMap typed-params pattern (esp. SELECTION_WINDOW_PREREQ_FAILED: { gaps: [...] } and SELECTION_WINDOW_INVALID_DATES: { reason: ... }) Mirror shape for CURRICULUM_IN_USE_BY_SELECTIONS: { curriculumId, selectionCount } and SELECTION_OUTSIDE_WINDOW: { reason: 'not_open' \| 'closed', windowStatus }. None.
docs/superpowers/specs/2026-04-20-unified-students-api-design.md — scope-grouped vs flat DTO conventions Use a flat @AggregateResponse() payload (selection is a single concept, not a multi-scope aggregate). Most of the student API is scope-grouped; selection is the exception, like documents.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Every query in selection.queries.ts filters tenantId; header row carries tenantId. Choice rows skip tenantId (denormalize) — reachable only via header FK, isolation enforced by header. Same pattern as OptionBlockSubject (no own tenantId; reaches Tenant via StudyPlan).
Academic-year scope yes (implicit, not denormalized) Selection has NO own academicYearId column. It inherits AY through TWO FKs that are both AY-scoped at the schema level: Student.academicYearId (schema.prisma:611-ish) AND Curriculum.academicYearId (schema.prisma:1212). The applicable-curricula projection in selection.queries.ts filters Curriculum.academicYearId = Student.academicYearId, which is the load-bearing AY-consistency invariant — selection writes can never cross AY boundaries because the curriculum simply won't appear in the applicable set. Rollover creates a new Student row → no selection on it → referent re-selects for the new year. Avoids duplicating year-state and AY-rollover migration headaches; the FK chain already pins AY.
RBAC entity key reuse STUDENTS No new entity; use the existing students.assignment scope. Selection is conceptually an "assignment" — the same scope already carries enrollmentDate and dept/grade assignment fields. Curriculum + study-plan choice slots cleanly here.
Scopes reuse students.assignment No seed additions to PermissionScope. No additions to STUDENT_SCOPES (the assignment field-list stays as-is; selection is a sub-resource, not field-level data). Grant audit: students.assignment already in admin catch-all + REFERENT_READ_SCOPE_KEYS ([[project_person_scope_rename]]); referent write grant set must include (students, assignment) write — verify, don't duplicate. Teacher read on assignment already exists. Reusing the existing scope is a convergence with the canonical pattern; no drift to enforce.
Actions none PATCH uses @RequireScopes(EntityKey.STUDENTS, 'write') (implicit-update convention from [[feedback_rbac_no_update_action]]) + the assignment scope read for GET. @RequireRoles('admin', 'referent') narrows the door. Service introspects role to branch write semantics + attribute lastEditedBy. No bespoke verb is warranted — admin/referent both perform the same action with different gates handled in service.
Service base custom (CurriculumSelectionService, not BaseTenantedCrudService) Selection is a singleton-per-student with custom validation + transaction; CRUD base doesn't carry its weight here. Files: src/students/curriculum-selection.{service,controller,queries,swagger}.ts + sibling specs. Naming explicit to avoid collision with src/curriculum/selection-window.*. Same call we made for SelectionWindowService.
queries.ts shape new — src/students/curriculum-selection.queries.ts with named functions and SELECT consts findSelectionByStudent, findApplicableCurricula, loadCurriculumStructure, countSelectionsForCurriculum, snapshotSelectionsForCurriculumStructure(curriculumId, removedOptionBlockIds, removedOptionBlockSubjectIds). No repository class. Project convention ([[feedback_queries_convention]] in CLAUDE.md). Naming explicit (curriculum-selection.*) to avoid grep collision with src/curriculum/selection-window.*.
Error codes new: CURRICULUM_IN_USE_BY_SELECTIONS, SELECTION_OUTSIDE_WINDOW, SELECTION_VALIDATION_FAILED, SELECTION_CURRICULUM_NOT_APPLICABLE Each gets a typed ErrorParamsMap entry; Swagger examples added in error-examples.ts. See §5 for shapes.
DTO conventions flat (single-concept), like documents SelectionResponseDto extends AggregateResponseDto (the [[feedback_swagger_jsdoc_is_public]] reminder applies). Embed applicable-curricula tree under a curricula field. WriteSelectionDto carries curriculumId + choices: { optionBlockId, optionBlockSubjectId }[]. No scope grouping — selection is one concept.
File-backed sub-resources n/a — no files
Custom fields no
Profile completeness no Selection completeness is a separate concept tracked by US-33 / command-center, not folded into completion-required-fields.ts. Avoids cross-coupling the existing per-scope completeness machinery.

4. Data model plan

Schema deltas

  • New enum SelectionEditorRole { REFERENT, ADMIN } mapped to selection_editor_role.
  • New model StudentCurriculumSelection:
  • id uuid PK
  • tenantId FK → Tenant (Cascade)
  • studentId FK → Student (Cascade), @unique — one selection per student. Forecloses multi-round selection (e.g. v1 then v2 within the same AY) — see §9 deferral.
  • curriculumId FK → Curriculum, onDelete: Restrict — DB-level US-29 belt; service throws the typed error first. Service MUST translate Prisma P2003 on this FK to CURRICULUM_IN_USE_BY_SELECTIONS to handle the count-then-delete TOCTOU race (mirrors the P2002SELECTION_WINDOW_ALREADY_OPEN translation added in US-32 v2).
  • confirmedAt DateTime?
  • lastEditedBy SelectionEditorRole? — null until first write; set on every successful PATCH.
  • lastEditedByUserId String? @db.Uuid — FK → User with onDelete: SetNull. Audit attribution must not block user deletes; if the editor user is later removed, the selection survives with lastEditedByUserId = null and we surface "(user removed)" on the FE.
  • createdAt, updatedAt
  • New model StudentOptionBlockChoice:
  • id uuid PK
  • selectionId FK → StudentCurriculumSelection (Cascade)
  • optionBlockId FK → OptionBlock (Cascade) — structural cascade
  • optionBlockSubjectId FK → OptionBlockSubject (Cascade) — structural cascade
  • Back-relations on Tenant, Student, Curriculum, User, OptionBlock, OptionBlockSubject.

Cascade invalidation algorithm (snapshot-then-mutate)

Naïve "re-fetch after cascade to find affected selections" is unimplementable — once Postgres fires the cascade, the rows are gone and we have no way to identify the parent selectionIds. The hook at CurriculumService.update and .remove MUST use the snapshot pattern:

  1. Snapshot. BEFORE running the structural edit, compute the sets removedOptionBlockIds: Set<string> and removedOptionBlockSubjectIds: Set<string> (the OptionBlocks and OptionBlockSubjects the edit will delete — derive from the diff between current and new structure). Query StudentOptionBlockChoice for DISTINCT selectionId where optionBlockId IN removedOptionBlockIds OR optionBlockSubjectId IN removedOptionBlockSubjectIds. Call this set affectedSelectionIds. The query lives in curriculum-selection.queries.ts as snapshotSelectionsForCurriculumStructure.
  2. Mutate. Run the structural edit (cascade fires, choices for removed structure are deleted by Postgres).
  3. Reset. Inside the same transaction, UPDATE student_curriculum_selections SET confirmed_at = NULL WHERE id IN (affectedSelectionIds).

The whole sequence MUST be wrapped in $transaction so an aborted structural edit doesn't leak orphan confirmedAt = null rows. The cascade is FK-only at the DB level; the confirmedAt reset is service-level — there is no DB trigger.

Untouched selections (whose choices reference only structure that survived) keep confirmedAt non-null. This is what makes "untouched selections remain confirmed" a testable behavior.

Migration shape

  • Additive only. Two new tables + one new enum + new back-relations on existing models (Prisma-only; no SQL column additions to those).
  • No data backfill: selection rows are created on first PATCH.
  • Hazards from chapter 12 checklist:
  • No NOT NULL added to populated tables → safe.
  • No unique constraint added retroactively → safe.
  • The Restrict on curriculumId FK is the deliberate gate; this matches §6 hazard "FK strategy must be explicit".
  • The Cascade on OptionBlock/OptionBlockSubject FKs is the deliberate invalidation mechanism — flagged in chapter 12 as "cascade behavior must be intentional"; this row documents intent.

Indexes and uniqueness

  • StudentCurriculumSelection:
  • @unique([studentId]) — singleton per student (already from the FK definition).
  • @@index([tenantId]) — standard.
  • @@index([tenantId, curriculumId]) — supports US-29 "is this curriculum referenced?" lookup.
  • StudentOptionBlockChoice:
  • @@unique([selectionId, optionBlockSubjectId]) — can't pick the same option-block-subject twice.
  • @@index([optionBlockSubjectId]) — supports "is this subject referenced by any selection?" (future US-29 extension).
  • @@index([optionBlockId]) — supports per-block validation grouping inside the write transaction.
  • @@index([selectionId]) — implied by FK; explicit for clarity.

5. API surface

Field rename note (post-2026-05-26 OB-subject merge). This section uses optionBlockSubjectId throughout, matching the original draft schema. The shipped code now uses studyPlanSubjectId because OptionBlockSubject was collapsed into StudyPlanSubject (with a nullable optionBlockId + a CHECK constraint distinguishing mandatory vs option-block subjects). Read every optionBlockSubjectId in this section as studyPlanSubjectId referencing a row where optionBlockId IS NOT NULL. See memory project_optionblock_subjectgroup_deferred.

Verb Path Decorators Request DTO Response DTO
GET /api/v1/students/:id/curriculum-selection @RequireScopes(EntityKey.STUDENTS, 'assignment'), @RequireRoles('admin', 'teacher', 'referent'), @AggregateResponse() (no body; :id is the student) SelectionReadResponseDto
PATCH /api/v1/students/:id/curriculum-selection @RequireScopes(EntityKey.STUDENTS, 'write') + assignment scope present (verified in guard), @RequireRoles('admin', 'referent'), @AggregateResponse() WriteSelectionDto SelectionReadResponseDto

Access semantics on the per-id route

  • GET → 404 (not 403) for unrelated callers. The students record-level filter for the referent role already hides students the caller isn't linked to. Reuse that filter at the route handler: if findStudentForCaller(:id, ctx) returns null, throw 404 STUDENT_NOT_FOUND BEFORE the selection service is touched. Rationale: a referent must not be able to probe a tenant's roster by guessing student UUIDs. Admin/teacher pass the visibility check by default.
  • PATCH → 403 for linked-but-not-canWrite. When the student IS visible (record-level filter passes) but StudentReferentLink.canWrite = false, the service-level assertReferentCanWrite throws 403 ACTION_NOT_PERMITTED. Distinct from "you can't see this student" — surfaces the policy reason so FE can render "your account has read-only access for this student".

PATCH-as-upsert convention

There is no POST /students/:id/curriculum-selection. Selection is a singleton-per-student (@unique studentId); on the first PATCH the service materializes the header row, on subsequent PATCHes it wipe-replaces choices inside $transaction. This matches the singleton-resource pattern used by /school (PATCH-only, no POST) more than the multi-slot /students/:id/documents (POST appends to a slot, PATCH overwrites a slot). One sentence in Swagger to flag it.

DTO sketches

// write
class WriteSelectionDto {
  @IsUUID() curriculumId!: string;
  @IsArray() @ArrayMaxSize(200) @ValidateNested({ each: true }) @Type(() => ChoiceDto)
  choices!: ChoiceDto[];
}
class ChoiceDto {
  @IsUUID() optionBlockId!: string;
  @IsUUID() optionBlockSubjectId!: string;
}

// read
class SelectionReadResponseDto extends AggregateResponseDto {
  status!: 'NOT_STARTED' | 'CONFIRMED' | 'INVALIDATED';  // INVALIDATED = row exists, confirmedAt = null
  studentId!: string;
  curriculumId!: string | null;
  confirmedAt!: string | null;
  lastEditedBy!: 'REFERENT' | 'ADMIN' | null;
  lastEditedByUserId!: string | null;
  choices!: { optionBlockId: string; optionBlockSubjectId: string }[];
  applicableCurricula!: CurriculumTreeDto[];      // embedded full structure for FE
  windowStatus!: 'NOT_OPENED' | 'SCHEDULED' | 'OPEN' | 'CLOSED';
  canWrite!: boolean;                              // see formula below
}

canWrite formula

The response value MUST be computed by the service as:

canWrite =
     ctx.isPlatformAdmin
  || ctx.roles.includes('admin')
  || (
       ctx.roles.includes('referent')
       && referentLink?.canWrite === true
       && windowStatus === 'OPEN'
     )

Teacher reads → canWrite = false (teacher has neither admin path nor referent path). The FE shows/hides the edit affordance entirely off this flag; it does not re-derive the predicate.

Embedded curricula tree shape

The applicableCurricula array is the FE's input for rendering the selection screen. It deliberately excludes evaluation-scale and criteria data — those are not relevant to the choice and would bloat the payload. Exact projection:

type CurriculumTreeDto = {
  id: string;
  name: string;
  status: 'READY';                // by construction; DRAFTs are filtered out
  studyPlan: {
    id: string;
    subjectLabel: string;
    subjects: Array<{             // StudyPlanSubject — the fixed/mandatory part
      id: string;
      name: string;
      isMandatory: boolean;
      level: string | null;
      ordinalPosition: number;
    }>;
    optionBlocks: Array<{
      id: string;
      name: string;
      minSelections: number;
      maxSelections: number;
      ordinalPosition: number;
      subjects: Array<{           // OptionBlockSubject — the choosable items
        id: string;
        name: string;
        level: string | null;
        ordinalPosition: number;
      }>;
    }>;
    rules: Array<{                // StudyPlanRule — info-only in v1 (see §9 deferral)
      id: string;
      constraint: 'AT_LEAST' | 'AT_MOST' | 'EXACTLY';
      quantity: number;
      scopeType: 'PLAN' | 'OPTION_BLOCK';
      optionBlockId: string | null;
      targetType: 'LEVEL' | 'ALL';
      targetLevel: string | null;
      ordinalPosition: number;
    }>;
  };
};

Notes: - No gradingScaleId, no criteriaGradingScaleId, no criteria[]. Evaluation lives in a different surface (src/evaluation-scales/); leaking it here would couple selection to evaluation. - rules[] IS included so the FE can render "exactly 3 HL across the plan" as an info banner. v1 does not validate against these (see §9). The FE is the source of truth for surfacing them to the referent; the BE just transports them. - The applicable-set filter is: status='READY' AND departmentId = student.departmentId AND academicYearId = student.academicYearId AND EXISTS (SELECT 1 FROM curriculum_grades WHERE curriculum_id = curriculum.id AND grade_id = student.gradeId). Bounded to typically 0–2 rows.

Swagger considerations

  • Two new ApiOperation entries in a new selection.swagger.ts co-located with the controller.
  • Error response examples for all four new codes (CURRICULUM_IN_USE_BY_SELECTIONS, SELECTION_OUTSIDE_WINDOW, SELECTION_VALIDATION_FAILED, SELECTION_CURRICULUM_NOT_APPLICABLE) in error-examples.ts.
  • SelectionValidationFailed example must show a populated violations array with at least one of each variant.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none — reuse the existing students.assignment scope (prisma/seed/rbac-catalogue.ts:40).
PermissionAction (rbac-catalogue.ts) none — write uses the existing students.write action.
ScopeFieldMapping (rbac-catalogue.ts) none — selection is a sub-resource, not a field surface on the students table. The assignment scope's existing field mapping (enrollmentDate, etc.) is untouched.
Role grants (roles.ts) Verify-only — no edits expected: admin via catch-all; referent already has students.assignment in REFERENT_READ_SCOPE_KEYS ([[project_person_scope_rename]]); referent write grant on students.assignment should already be present from RUS-1 — verify; teacher read on assignment already in place.
*_SCOPES runtime constant noneSTUDENT_SCOPES.assignment is unchanged. No drift to enforce ([[feedback_rbac_drift_check]] non-applicable since we're not adding a key).

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
"PATCH gates field writes via FieldWriteGuard" ([[feedback_rbac_no_update_action]]) This PATCH has no scope-grouped fields under assignment; the gate is structural (window + referent link), not field-level. We rely on @RequireScopes('write') + the assignment-scope presence check + a service-level gate, with FieldWriteGuard a no-op for the choice payload. Selection is a single nested payload, not field-level data on the students row. The assignment scope's existing fields (enrollmentDate, dept/grade FKs) remain field-write-gated by their existing rules. Lose per-field permission granularity for selection only — acceptable because the whole selection is one product concept.
"Sub-resources are typically file slots" (students :id/documents, :id/passport, etc.) Selection is a JSON sub-resource with a children sub-array, not file-backed. Selection is a choice, not a document. Slightly different controller shape (no multer); already justified by the documents-vs-selection contrast.
"Header row is created up-front, then mutated" (most CRUD) Selection header materializes lazily on first PATCH; GET synthesizes a NOT_STARTED response when absent. Avoids polluting the DB with empty rows for every student × year; simplifies the cascade story when curricula are added/changed before any referent has touched anything. The "row exists but confirmedAt = null" state is now both "invalidated by cascade" AND "never confirmed since cascade reset"; distinguishable via lastEditedBy (only set on first write).
"Field-level scope membership = field array in STUDENT_SCOPES" students.assignment is now overloaded: it gates both field-level access (enrollmentDate etc.) AND a sub-resource (curriculum-selection). Avoids a new scope key for what is conceptually the same product concern ("how the student is assigned for the year"). User-directed at design review. Future reviewers must know the assignment scope has two surfaces. Documented in §3 and in the chapter-04 update.

8. Pushback log

US says Conflicts with Proposed instead Status
Original RUS-4 Gherkin (per ClickUp) implies an irreversible "confirm" lock per scenario S03 of Epic 5 v2 working doc Conflicts with the user's cherry-picked design ("freely re-submittable inside window", [[project_rus4_next_iteration]]) No lock; every PATCH stamps confirmedAt = now(); window-only gate is the sole temporal control. Resolved (user-cherry-picked 2026-05-20)
Epic 5 v2 working doc S08 cancellation-scope flow Conflicts with cherry-pick "no Cancellation Scope" Admin uses ad-hoc admin-override PATCH + delete + restore-curriculum if needed. Resolved
US-29 spec says deleting a curriculum is allowed if selections are wiped Mass-delete of student selections is destructive and out of scope for the minimal guard. Block at DB (Restrict) + service-level error pointing the admin at the manual re-point workflow via admin PATCH override; once re-pointed, delete succeeds. Resolved
User said "if class assignments exists, we'll need to act/inform the admin of the selection changed" (tonight's chat) Conflicts with "do this now" — class assignments are US-33 territory, not built yet. Defer the side-effect logic in its entirety to a follow-up iteration. No notification port wired, no class-assignment lookup attempted. Deferred (see §9)

9. Deferrals

  • Side-effect on changing an already-submitted selection (notify admin when class assignments exist) — why: US-33 (class assignments) not yet built; no notification port plumbed for in-app messaging; the user explicitly deferred during tonight's design conversation — follow-up: revisit after US-33 lands and a notification primitive exists; durably captured in memory entry [[project_rus4_selection_change_side_effects_deferred]].
  • StudyPlanRule cross-block constraint enforcementwhy: v1 validates only per-block minSelections / maxSelections; cross-plan rules are second-order constraints — follow-up: surface in §10 of a future iteration spec; the existing StudyPlanRule model already carries the data, and v1 already transports it in the GET payload as info-only (see §5 tree shape) so FE+BE don't diverge on what's authoritative.
  • Multi-round selection within the same AY (e.g. "v1 selection in Sept, v2 re-open in Jan with a new shape") — why: out of scope per the cherry-pick + the @unique studentId on the header forecloses it at the schema level — follow-up: if revived, drop the unique and add roundType/roundNumber on the header (same evolution shape the US-32 v2 spec foresees for the window). Documented here so future-us notices the constraint before designing the next iteration.
  • Voiding selections on student-status changewhy: out of scope per resolved memory — follow-up: future spec layered on student-status hook (US-32 S11 territory).
  • Admin monitoring tab in src/command-center/why: defer until selection data has settled in production use — follow-up: command-center iteration spec.
  • Late-enrollment selection flowwhy: a student joining after window CLOSED is a rare edge case; admin override + manual entry is acceptable v1 workaround — follow-up: future spec.
  • Auto-resolve "no actual choice" studentswhy: explicit submission keeps the audit trail consistent — follow-up: revisit if product feedback demands it.
  • Reopen / republish / cancellation-scope flows from Epic 5 v2 working docwhy: out of scope per cherry-pick — follow-up: only if product re-asks.
  • Email/notification when a selection is invalidated by a structural curriculum editwhy: no notification port wired for in-app messages; v1 surfaces it via status: 'INVALIDATED' on GET so the FE can render a banner — follow-up: tie into the same notification iteration as the side-effect deferral above.

10. Open questions

All resolved as of 2026-05-20 — see Resolved-during-design notes below.

  • ~~Should the read endpoint embed the applicable-curricula tree, or return IDs only?~~ Resolved: embed. The referent FE needs the structure to render Option Blocks; a second round-trip is wasteful. Caveat: the embedded tree is per-student and may include only 0–2 curricula given (department, grade) filters, so payload size is bounded.
  • ~~lastEditedBy enum: 2-value (REFERENT, ADMIN) or 3-value (also SYSTEM)?~~ Resolved: 2-value. There is no SYSTEM writer in v1 (no auto-resolve, no notification-driven mutation, no cron). If a future iteration adds one, add the enum value then.
  • ~~Selection cascade on Student hard delete?~~ Resolved: yes, Cascade — selection has no meaning without its student. Standard Prisma default.
  • ~~Should structural curriculum edits invalidate ALL selections referencing the curriculum, or only those whose specific choices were affected?~~ Resolved: only those whose OptionBlock or OptionBlockSubject choices were physically removed by the cascade. Algorithm is snapshot-then-mutate (see §4): snapshot the affected selectionId set BEFORE the structural edit, run the edit (cascade fires), then UPDATE … SET confirmed_at = NULL WHERE id IN snapshot. The whole sequence is one $transaction. Selections that picked subjects that were not removed remain confirmed.

11. Verification plan

  • Unit specs:
  • src/students/curriculum-selection.service.spec.ts:
    • GET on student with no selection → status: 'NOT_STARTED', header fields null, choices [], applicableCurricula populated when ≥1 READY curriculum matches.
    • GET on student with confirmed selection → status: 'CONFIRMED', choices populated, windowStatus reflects current (AY, dept) window state.
    • GET on student with row + confirmedAt = null (post-cascade) → status: 'INVALIDATED'.
    • GET response payload does NOT include any gradingScale* or criteria keys under applicableCurricula[*].studyPlan (regression guard against evaluation-scale leak).
    • GET response payload DOES include applicableCurricula[*].studyPlan.rules populated when the seed has StudyPlanRule rows.
    • GET canWrite is true for admin, true for referent with link.canWrite=true while window OPEN, false for referent while window CLOSED, false for referent with link.canWrite=false, false for teacher.
    • PATCH referent, window OPEN, valid choices → 200; row materialized; lastEditedBy = REFERENT; lastEditedByUserId = ctx.userId; confirmedAt stamped.
    • PATCH referent, window CLOSED → 422 SELECTION_OUTSIDE_WINDOW reason='closed'.
    • PATCH referent, window NOT_OPENED → 422 SELECTION_OUTSIDE_WINDOW reason='not_open'.
    • PATCH referent, canWrite=false on link → 403 ACTION_NOT_PERMITTED.
    • PATCH referent, no link at all → 403 ACTION_NOT_PERMITTED. (Distinct from the GET 404 path — PATCH does not pre-filter via record-level visibility because once a write reaches the service we want the policy-specific reason; spec's access-semantics paragraph is the contract.)
    • PATCH admin, window CLOSED → 200 (admin bypasses window gate); lastEditedBy = ADMIN.
    • PATCH admin, no referent link → 200 (admin bypasses link gate).
    • PATCH with curriculumId not in applicable set for student → 422 SELECTION_CURRICULUM_NOT_APPLICABLE.
    • PATCH with too few choices in an option block (below minSelections) → 422 SELECTION_VALIDATION_FAILED violations[].kind = 'below_min'.
    • PATCH with too many choices in a block → 422 SELECTION_VALIDATION_FAILED violations[].kind = 'above_max'.
    • PATCH with optionBlockSubjectId not in the named optionBlockId → 422 SELECTION_VALIDATION_FAILED violations[].kind = 'wrong_block'.
    • PATCH with optionBlockId not in the chosen curriculum's study plan → 422 SELECTION_VALIDATION_FAILED violations[].kind = 'block_not_in_plan' (guards against an attacker submitting an arbitrary OptionBlock UUID that happens to be internally consistent with its subject).
    • PATCH with duplicate optionBlockSubjectId in the same payload → 422 SELECTION_VALIDATION_FAILED violations[].kind = 'duplicate'.
    • PATCH re-submission with different curriculumId while OPEN → 200; old choices wiped; new ones inserted in same tx; confirmedAt updated.
    • Audit-survival: a header whose lastEditedByUserId references a now-deleted user → returns 200 with lastEditedByUserId: null (FK SetNull worked); regression guard.
  • src/students/curriculum-selection.controller.spec.ts: decorator stack assertion (scope/role keys, body validation passthrough), service invocation arguments. Covers GET 404 path: when the per-id route's student-visibility check fails for a referent, the response is 404 STUDENT_NOT_FOUND and the selection service is NOT invoked.
  • src/curriculum/curriculum.service.spec.ts additions:
    • DELETE curriculum with at least one selection → throws CURRICULUM_IN_USE_BY_SELECTIONS with selectionCount ≥ 1.
    • PATCH curriculum status: 'DRAFT' while selections reference it → same error.
    • P2003 race translation: simulate a Prisma P2003 on delete (e.g. by stubbing the prisma client) → service translates to CURRICULUM_IN_USE_BY_SELECTIONS (mirrors the P2002 test for SELECTION_WINDOW_ALREADY_OPEN).
    • PATCH curriculum that removes an OptionBlock → cascade wipes affected StudentOptionBlockChoice rows; service hook (using snapshotSelectionsForCurriculumStructure) resets confirmedAt = null on the snapshotted selectionIds; untouched selections (whose choices reference only surviving structure) keep confirmedAt != null. Asserts BOTH sides of the partition in a single test.
    • The snapshot-then-mutate sequence is wrapped in a single $transaction — abort the structural edit mid-transaction and confirm no rows have confirmedAt = null after rollback (regression guard against the "snapshot leaks if mutate aborts" failure mode).
  • E2E specs (test/curriculum-selection.e2e-spec.ts):
  • Seed e2e-fixtures gets two referent-linked students (deptA grade1, deptB grade2) plus the READY canonical curricula already in place.
  • Open window in deptA via the existing admin endpoint.
  • Referent linked to A's student: GET → status: 'NOT_STARTED', windowStatus: 'OPEN', canWrite: true. PATCH valid payload → 200. GET again → status: 'CONFIRMED'. PATCH again with different choice → 200, confirmedAt advanced. (covers Gherkin S01, S02, S04, S07 of the resolved cherry-pick.)
  • Referent A GETing a student they're NOT linked to (e.g. B's student) → 404 STUDENT_NOT_FOUND (visibility filter; not 403). Mirrors the access-semantics paragraph in §5.
  • Referent A PATCHing a student they're linked to but with link.canWrite = false403 ACTION_NOT_PERMITTED.
  • Referent against a student in deptB (no window) → GET windowStatus: 'NOT_OPENED', PATCH → 422 SELECTION_OUTSIDE_WINDOW reason='not_open'.
  • Admin PATCHing any student in any window state → 200; response lastEditedBy: 'ADMIN', lastEditedByUserId set to the admin's id.
  • Admin DELETE on a referenced curriculum → 409 CURRICULUM_IN_USE_BY_SELECTIONS; admin PATCH demote → same; after admin re-points the referencing selections via PATCH override + admin PATCH delete the curriculum → 200.
  • Admin PATCH on a curriculum that removes an OptionBlock referenced by a confirmed selection → 200; subsequent GET on that student shows status: 'INVALIDATED'.
  • GET payload regression: the applicableCurricula[*].studyPlan projection has no gradingScale*/criteria keys but does include rules[] when fixtures have them. Single assertion test against a known curriculum row.
  • Isolation: prisma.studentCurriculumSelection.deleteMany({}) + prisma.studentOptionBlockChoice.deleteMany({}) in beforeAll and afterAll ([[feedback_e2e_isolation_patterns]]); stamp any uniqueness-bound test fields with Date.now().
  • Manual verification: see §13 below.

Patterns: chapter 09 (testing) + [[feedback_e2e_isolation_patterns]].


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-20
  • Chat reference: Approved 2026-05-20 in chat after one round of pushback (12 hardening items folded: snapshot-then-mutate algorithm, GET 404 vs PATCH 403 access semantics, lastEditedByUserId SetNull, P2003 translation, block_not_in_plan validation, explicit canWrite formula, explicit tree projection without evaluation-scale leak, studyPlan.rules[] transport, PATCH-as-upsert convention, multi-round-foreclosure deferral, side-effects deferral with durable memory entry, file-naming disambiguation). Implementation plan approved via ExitPlanMode same date — see docs/superpowers/plans/2026-05-20-rus4-curriculum-selection.md.

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


13. Manual verification (post-implementation, run when user asks)

Pre-step: npm run docker:reset && npx prisma migrate dev && npx prisma db seed.

  1. Log in as admin; open window for deptA via POST /api/v1/selection-windows { departmentId: <A>, startDate: <today> }.
  2. Log in as a referent linked to a student in deptA. GET /api/v1/students/:studentId/curriculum-selectionstatus: 'NOT_STARTED', applicableCurricula populated, windowStatus: 'OPEN', canWrite: true.
  3. PATCH with { curriculumId, choices: [{ optionBlockId, optionBlockSubjectId }] } (correctly satisfying every block's min/max) → 200; subsequent GET → status: 'CONFIRMED', lastEditedBy: 'REFERENT'.
  4. PATCH again with a different optionBlockSubjectId in the same block → 200; confirmedAt advanced.
  5. PATCH with a payload missing a required block → 422 SELECTION_VALIDATION_FAILED listing kind: 'below_min' violations.
  6. Log in as a referent linked to a deptB student (no window). PATCH → 422 SELECTION_OUTSIDE_WINDOW reason='not_open'. GET still works.
  7. As admin, PATCH the same deptB student's selection (no window) → 200; lastEditedBy: 'ADMIN'.
  8. As admin, DELETE /api/v1/curricula/:id on the curriculum the referent chose in step 3 → 409 CURRICULUM_IN_USE_BY_SELECTIONS params.selectionCount: 1.
  9. As admin, re-point the affected selection via PATCH override, pointing at a different curriculumId; repeat the DELETE → 200.
  10. As admin, PATCH the curriculum from step 3 to remove an OptionBlock that was chosen → 200; GET on the affected student → status: 'INVALIDATED', choices wiped, confirmedAt: null; FE prompts re-submission.
  11. Snapshot-then-mutate partition: create two referents linked to two students, both PATCH selections on the SAME curriculum, where Student A picks a subject in OptionBlock X and Student B picks a subject in OptionBlock Y. As admin, PATCH the curriculum to remove ONLY OptionBlock X. After 200: Student A's GET → status: 'INVALIDATED'; Student B's GET → status: 'CONFIRMED' (unchanged). Confirms the algorithm doesn't over-invalidate.
  12. GET payload shape: hit /api/v1/students/:id/curriculum-selection for a student whose applicable curriculum has at least one StudyPlanRule row. Confirm applicableCurricula[0].studyPlan.rules is populated; confirm no gradingScale* or criteria keys appear anywhere in the tree (regression guard against evaluation-scale leak).
  13. Cross-link visibility: as referent A, GET /api/v1/students/:bStudentId/curriculum-selection (B's student, no link) → 404 STUDENT_NOT_FOUND (NOT 403). As same referent, PATCH on a student they ARE linked to but with link.canWrite = false → 403 ACTION_NOT_PERMITTED.
  14. User-delete survival: capture a selection's lastEditedByUserId, hard-delete that user (admin operation), GET the selection → 200 with lastEditedByUserId: null (no FK error blocking).