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 → DRAFTdemote 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
confirmedAtso 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-832 — assertReferentCanWrite(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-300 — todayUtc() + 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-1172 — bulkSync 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-189 — ErrorParamsMap 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 toselection_editor_role. - New model
StudentCurriculumSelection: id uuid PKtenantIdFK → Tenant (Cascade)studentIdFK → Student (Cascade),@unique— one selection per student. Forecloses multi-round selection (e.g. v1 then v2 within the same AY) — see §9 deferral.curriculumIdFK → Curriculum,onDelete: Restrict— DB-level US-29 belt; service throws the typed error first. Service MUST translate PrismaP2003on this FK toCURRICULUM_IN_USE_BY_SELECTIONSto handle the count-then-delete TOCTOU race (mirrors theP2002→SELECTION_WINDOW_ALREADY_OPENtranslation added in US-32 v2).confirmedAt DateTime?lastEditedBy SelectionEditorRole?— null until first write; set on every successful PATCH.lastEditedByUserId String? @db.Uuid— FK → User withonDelete: SetNull. Audit attribution must not block user deletes; if the editor user is later removed, the selection survives withlastEditedByUserId = nulland we surface "(user removed)" on the FE.createdAt,updatedAt- New model
StudentOptionBlockChoice: id uuid PKselectionIdFK → StudentCurriculumSelection (Cascade)optionBlockIdFK → OptionBlock (Cascade) — structural cascadeoptionBlockSubjectIdFK → 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:
- Snapshot. BEFORE running the structural edit, compute the sets
removedOptionBlockIds: Set<string>andremovedOptionBlockSubjectIds: Set<string>(the OptionBlocks and OptionBlockSubjects the edit will delete — derive from the diff between current and new structure). QueryStudentOptionBlockChoiceforDISTINCT selectionIdwhereoptionBlockId IN removedOptionBlockIds OR optionBlockSubjectId IN removedOptionBlockSubjectIds. Call this setaffectedSelectionIds. The query lives incurriculum-selection.queries.tsassnapshotSelectionsForCurriculumStructure. - Mutate. Run the structural edit (cascade fires, choices for removed structure are deleted by Postgres).
- 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 NULLadded to populated tables → safe. - No unique constraint added retroactively → safe.
- The
RestrictoncurriculumIdFK is the deliberate gate; this matches §6 hazard "FK strategy must be explicit". - The
CascadeonOptionBlock/OptionBlockSubjectFKs 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
optionBlockSubjectIdthroughout, matching the original draft schema. The shipped code now usesstudyPlanSubjectIdbecauseOptionBlockSubjectwas collapsed intoStudyPlanSubject(with a nullableoptionBlockId+ a CHECK constraint distinguishing mandatory vs option-block subjects). Read everyoptionBlockSubjectIdin this section asstudyPlanSubjectIdreferencing a row whereoptionBlockId IS NOT NULL. See memoryproject_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
referentrole already hides students the caller isn't linked to. Reuse that filter at the route handler: iffindStudentForCaller(:id, ctx)returns null, throw 404STUDENT_NOT_FOUNDBEFORE 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-levelassertReferentCanWritethrows 403ACTION_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.tsco-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) inerror-examples.ts. SelectionValidationFailedexample must show a populatedviolationsarray 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 |
none — STUDENT_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 enforcement — why: 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 existingStudyPlanRulemodel 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 studentIdon the header forecloses it at the schema level — follow-up: if revived, drop the unique and addroundType/roundNumberon 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 change — why: 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 flow — why: 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" students — why: 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 doc — why: out of scope per cherry-pick — follow-up: only if product re-asks.
- Email/notification when a selection is invalidated by a structural curriculum edit — why: 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.
- ~~
lastEditedByenum: 2-value (REFERENT,ADMIN) or 3-value (alsoSYSTEM)?~~ 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
OptionBlockorOptionBlockSubjectchoices were physically removed by the cascade. Algorithm is snapshot-then-mutate (see §4): snapshot the affectedselectionIdset BEFORE the structural edit, run the edit (cascade fires), thenUPDATE … 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[],applicableCurriculapopulated when ≥1 READY curriculum matches. - GET on student with confirmed selection →
status: 'CONFIRMED', choices populated,windowStatusreflects current(AY, dept)window state. - GET on student with row +
confirmedAt = null(post-cascade) →status: 'INVALIDATED'. - GET response payload does NOT include any
gradingScale*orcriteriakeys underapplicableCurricula[*].studyPlan(regression guard against evaluation-scale leak). - GET response payload DOES include
applicableCurricula[*].studyPlan.rulespopulated when the seed has StudyPlanRule rows. - GET
canWriteistruefor admin,truefor referent withlink.canWrite=truewhile window OPEN,falsefor referent while window CLOSED,falsefor referent withlink.canWrite=false,falsefor teacher. - PATCH referent, window OPEN, valid choices → 200; row materialized;
lastEditedBy = REFERENT;lastEditedByUserId = ctx.userId;confirmedAtstamped. - PATCH referent, window CLOSED → 422
SELECTION_OUTSIDE_WINDOWreason='closed'. - PATCH referent, window NOT_OPENED → 422
SELECTION_OUTSIDE_WINDOWreason='not_open'. - PATCH referent,
canWrite=falseon link → 403ACTION_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
curriculumIdnot in applicable set for student → 422SELECTION_CURRICULUM_NOT_APPLICABLE. - PATCH with too few choices in an option block (below
minSelections) → 422SELECTION_VALIDATION_FAILEDviolations[].kind = 'below_min'. - PATCH with too many choices in a block → 422
SELECTION_VALIDATION_FAILEDviolations[].kind = 'above_max'. - PATCH with
optionBlockSubjectIdnot in the namedoptionBlockId→ 422SELECTION_VALIDATION_FAILEDviolations[].kind = 'wrong_block'. - PATCH with
optionBlockIdnot in the chosen curriculum's study plan → 422SELECTION_VALIDATION_FAILEDviolations[].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
optionBlockSubjectIdin the same payload → 422SELECTION_VALIDATION_FAILEDviolations[].kind = 'duplicate'. - PATCH re-submission with different
curriculumIdwhile OPEN → 200; old choices wiped; new ones inserted in sametx;confirmedAtupdated. - Audit-survival: a header whose
lastEditedByUserIdreferences a now-deleted user → returns 200 withlastEditedByUserId: null(FK SetNull worked); regression guard.
- GET on student with no selection →
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 404STUDENT_NOT_FOUNDand the selection service is NOT invoked.src/curriculum/curriculum.service.spec.tsadditions:- DELETE curriculum with at least one selection → throws
CURRICULUM_IN_USE_BY_SELECTIONSwithselectionCount≥ 1. - PATCH curriculum
status: 'DRAFT'while selections reference it → same error. - P2003 race translation: simulate a Prisma
P2003ondelete(e.g. by stubbing the prisma client) → service translates toCURRICULUM_IN_USE_BY_SELECTIONS(mirrors theP2002test forSELECTION_WINDOW_ALREADY_OPEN). - PATCH curriculum that removes an OptionBlock → cascade wipes affected
StudentOptionBlockChoicerows; service hook (usingsnapshotSelectionsForCurriculumStructure) resetsconfirmedAt = nullon the snapshotted selectionIds; untouched selections (whose choices reference only surviving structure) keepconfirmedAt != 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 haveconfirmedAt = nullafter rollback (regression guard against the "snapshot leaks if mutate aborts" failure mode).
- DELETE curriculum with at least one selection → throws
- 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,confirmedAtadvanced. (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 = false→ 403ACTION_NOT_PERMITTED. - Referent against a student in deptB (no window) → GET
windowStatus: 'NOT_OPENED', PATCH → 422SELECTION_OUTSIDE_WINDOWreason='not_open'. - Admin PATCHing any student in any window state → 200; response
lastEditedBy: 'ADMIN',lastEditedByUserIdset 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[*].studyPlanprojection has nogradingScale*/criteriakeys but does includerules[]when fixtures have them. Single assertion test against a known curriculum row. - Isolation:
prisma.studentCurriculumSelection.deleteMany({})+prisma.studentOptionBlockChoice.deleteMany({})inbeforeAllandafterAll([[feedback_e2e_isolation_patterns]]); stamp any uniqueness-bound test fields withDate.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,
lastEditedByUserIdSetNull, P2003 translation,block_not_in_planvalidation, explicitcanWriteformula, 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 — seedocs/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.
- Log in as admin; open window for deptA via
POST /api/v1/selection-windows{ departmentId: <A>, startDate: <today> }. - Log in as a referent linked to a student in deptA.
GET /api/v1/students/:studentId/curriculum-selection→status: 'NOT_STARTED',applicableCurriculapopulated,windowStatus: 'OPEN',canWrite: true. - PATCH with
{ curriculumId, choices: [{ optionBlockId, optionBlockSubjectId }] }(correctly satisfying every block's min/max) → 200; subsequent GET →status: 'CONFIRMED',lastEditedBy: 'REFERENT'. - PATCH again with a different
optionBlockSubjectIdin the same block → 200;confirmedAtadvanced. - PATCH with a payload missing a required block → 422
SELECTION_VALIDATION_FAILEDlistingkind: 'below_min'violations. - Log in as a referent linked to a deptB student (no window). PATCH → 422
SELECTION_OUTSIDE_WINDOWreason='not_open'. GET still works. - As admin, PATCH the same deptB student's selection (no window) → 200;
lastEditedBy: 'ADMIN'. - As admin,
DELETE /api/v1/curricula/:idon the curriculum the referent chose in step 3 → 409CURRICULUM_IN_USE_BY_SELECTIONSparams.selectionCount: 1. - As admin, re-point the affected selection via PATCH override, pointing at a different
curriculumId; repeat the DELETE → 200. - 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. - 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. - GET payload shape: hit
/api/v1/students/:id/curriculum-selectionfor a student whose applicable curriculum has at least one StudyPlanRule row. ConfirmapplicableCurricula[0].studyPlan.rulesis populated; confirm nogradingScale*orcriteriakeys appear anywhere in the tree (regression guard against evaluation-scale leak). - Cross-link visibility: as referent A,
GET /api/v1/students/:bStudentId/curriculum-selection(B's student, no link) → 404STUDENT_NOT_FOUND(NOT 403). As same referent, PATCH on a student they ARE linked to but withlink.canWrite = false→ 403ACTION_NOT_PERMITTED. - User-delete survival: capture a selection's
lastEditedByUserId, hard-delete that user (admin operation), GET the selection → 200 withlastEditedByUserId: null(no FK error blocking).