IB subject levels (HL/SL) + curriculum-rule enforcement¶
1. Problem distillation¶
- The grid remodel dropped the planned
CurriculumSubject.levelfield; subjects today carry no level dimension, so the IB HL/SL distinction is inexpressible. - IB DP imposes three selection constraints we cannot currently express or enforce: 3–4 subjects at Higher Level (HL), 2–3 at Standard Level (SL), and the same subject may not be taken at both levels.
- The same-subject-both-levels rule is structurally free under the agreed grid layout: each IB group is one
OptionBlockwithmaxSelections=1, so the HL and SL variants of a course are co-located alternatives and cannot both be picked. No dedicated exclusivity primitive is needed. - The 3–4 HL / 2–3 SL counts are cross-block, level-partitioned counts. The rule engine has no level dimension and — critically —
CurriculumRuleis authored/validated/echoed but never evaluated at selection time (findSelectionInconsistenciesignores it entirely). Enforcement must be built.
Success criteria (observable behavior that proves this works):
- An admin can define a tenant-visible level catalog (platform presets Higher Level / Standard Level + tenant-added custom levels) and tag any curriculum subject with a level.
- An admin can attach LEVEL-scoped rules to a curriculum (AT_LEAST 3 / AT_MOST 4 on HL; AT_LEAST 2 / AT_MOST 3 on SL).
- A student selection that violates any attached rule (LEVEL, BLOCK, or ENTIRE_PLAN) is rejected at write time, and an already-confirmed selection that a structural curriculum edit pushes out of compliance is invalidated by the post-edit sweep (confirmedAt → null).
- A valid IB selection (one subject per group, HL/SL counts within bounds) is accepted.
Success criteria (backoffice / preset catalog) — see §5b:
- A domain expert can manage global HL/SL subject-level presets via /subject-levels/presets (platform-admin), parallel to evaluation-scale presets.
- A curriculum preset can tag subjects with a (platform) level and carry LEVEL rules; the backoffice editor + JSON-import template round-trip them.
Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Carry-forward executor changes (stays dormant; level/rules don't touch it). - The tenant-facing SIS frontend (separate repo): grid rendering, level chips on subject names, SubjectGroup/Homeroom level display. (The in-repo backoffice SPA preset editor is addressed — §5b.) - A free-standing "MUTUALLY_EXCLUSIVE" rule primitive (exclusivity is structural under layout A). - Per-track LEVEL counts (LEVEL rules count across the student's track-applicable subjects; no per-track rule sub-scoping). - Multi-round / late selection, notifications.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/evaluation-scales/ |
Tenant catalog with platform presets verbatim: tenantId String?, visibility { OR: [{ tenantId }, { tenantId: null }] }, partial-unique on platform name, EntityKey.EVALUATION_SCALES + configuration scope + create/delete actions, referenced by FK-by-UUID (gradingScaleId) |
Scales own polymorphic value rows + a type discriminator; SubjectLevel is a flat label row (name/code/ordinal), so no child-value table |
docs/superpowers/specs/2026-06-03-curriculum-grid-remodel-design.md |
The grid spec already specified CurriculumSubject.level VarChar(20)? and SubjectInputDto.level?; re-introduce as a catalog FK instead of a free string. Containment model (trackId, appliesToTrack) |
The string level never shipped and was never constraint-bearing; we replace it with a catalog FK + a real rule engine |
src/curriculum/selection-consistency.ts |
findSelectionInconsistencies pure-validator shape, appliesToTrack containment predicate, loadConsistencyStructure one-shot load, revalidateConfirmedSelectionsForCurriculum in-tx sweep |
Validator currently evaluates only block min/max + structural checks — it loads no rules and no subject levels. The rule-evaluation half must be built from scratch |
src/curriculum/curriculum.validation.ts assertValidRules + curriculum-configuration.dto.ts CurriculumRuleInputDto.blockRef |
Authoring-time rule shape validation; name-keyed blockRef resolution pattern |
Levels are a separate catalog → referenced by UUID levelId (like gradingScaleId), not name-keyed like inline blocks. New LEVEL branch in assertValidRules |
src/evaluation-scales/evaluation-scales.controller.ts POST/PATCH/DELETE /presets (@PlatformAdminOnly()) + src/curriculum-presets/ PresetGridDto |
Platform-admin preset CRUD route family for HL/SL; PresetGridDto already reuses the grid DTOs verbatim so levelId flows into the preset path for free |
Preset levelId must resolve to a platform level (tenantId IS NULL), not a tenant level — preset-context validation needed (same constraint a preset gradingScaleId already implies) |
backoffice/src/types.ts + curriculum-import.ts + api/lookups.ts |
SPA mirror of the grid DTOs + name-keyed friendly import template + listScales() lookup pattern |
RuleScopeType mirror lacks LEVEL; SubjectInput/RuleInput lack levelId; import template is name-keyed (no UUIDs) so level refs need name/code→id resolution against fetched platform presets |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes (with platform-preset exception) | SubjectLevel.tenantId String?; reads filter { OR: [{ tenantId }, { tenantId: null }] }; writes always stamp the caller's tenantId (a tenant can never write a tenantId=null preset) |
Mirrors EvaluationScale exactly |
| Academic-year scope | no | SubjectLevel is an AY-agnostic tenant catalog; CurriculumSubject/CurriculumRule already inherit AY via their Curriculum parent |
Catalogs (scales, rooms, levels) are not AY-scoped |
| RBAC entity key | reuse curricula (no new key) |
none | Decision (Fabio, 2026-06-08): subject levels are not their own RBAC entity — they are gated as a sub-resource of curriculum configuration |
| Scopes | curricula.configuration via the singular @RequireScope sub-resource gate (the students.curriculum_selection precedent); no new scope |
tenant routes: @RequireScope(EntityKey.CURRICULA, 'configuration', 'read'\|'write'). A route gated ONLY by the singular decorator is subResourceShape → FieldFilterInterceptor passes the flat SubjectLevel response through untouched, and FieldWriteGuard runs only the NEVER_WRITABLE_FIELDS net |
A dedicated entity/scope would force the flat response through scope-group filtering (→ assertScopedShape throws) unless added to FLAT_DTO_ENTITIES; the singular sub-resource gate is the established escape and avoids a new scope/role grant entirely |
| Actions | none | mutations use the singular @RequireScope(..., 'write') (no create/delete action keys); presets use @PlatformAdminOnly() |
Avoiding @RequireAction keeps subResourceShape true (an action decorator would re-enable filtering); curriculum-config WRITE is the single grant that authorizes level management |
| Service base | custom (mirror EvaluationScalesService) |
not BaseTenantedCrudService — the platform-preset OR-visibility + delete-guard (in-use by a subject/rule) is custom, exactly as scales |
Scales already chose custom for the same reasons |
queries.ts shape |
new src/subject-levels/subject-levels.queries.ts |
subjectLevelVisibleWhere(tenantId), subjectLevelRefSelect; curriculum side: extend loadConsistencyStructure select to pull rules + per-subject levelId + mandatory/common subjects |
one-shot loads, named functions, no repository class (queries convention) |
| Error codes | reuse + 1 new | reuse the existing selection-inconsistency error code (extend its params violation union with the new rule kinds); add SUBJECT_LEVEL_IN_USE for the catalog delete-guard (parallels EVALUATION_SCALE_IN_USE) |
rule violations ride the existing selection error; delete-guard needs its own typed 409 |
| DTO conventions | scope sub-DTOs + small catalog DTOs | subject-levels/dto/* (create/update/response/ref, mirror scales); add levelId?: string \| null to CurriculumSubjectInputDto + CurriculumRuleInputDto; add level: SubjectLevelRefDto \| null to CurriculumSubjectResponseDto/CurriculumRuleResponseDto |
catalog-by-UUID reference, same as gradingScaleId/EvaluationScaleRefDto |
| File-backed sub-resources | n/a — no files | levels carry no documents | |
| Custom fields | no | catalog labels are fixed-shape | |
| Profile completeness | no | not a person entity |
4. Data model plan¶
Schema deltas¶
- New
model SubjectLevel { id, tenantId String? (Cascade), name VarChar(150), code VarChar(30)?, ordinalPosition Int, createdAt, updatedAt }— clone ofEvaluationScale. CurriculumSubject.levelId String? @db.Uuid→SubjectLevel(onDelete: Restrict),@@index([levelId]).CurriculumRule.levelId String? @db.Uuid→SubjectLevel(onDelete: Restrict).enum RuleScopeTypegainsLEVEL.- Back-relations on
SubjectLevel:subjects CurriculumSubject[],rules CurriculumRule[],tenant Tenant?.
Migration shape¶
- Additive — one new table, two nullable FK columns, one enum value. No backfill (all new columns nullable, no existing rows carry a level).
- Plus a destructive-safe data seed: 2 platform-preset rows (
tenantId=null): Higher Level (codeHL) / Standard Level (codeSL), seeded via the seed script (mirror the eval-scale preset seed), not the migration SQL. - Hazards from chapter 12 checklist: new enum value on an existing enum — additive, safe. New FK with
Restrict— safe (no existing data). Partial-unique index (below) — safe on empty set. Per CLAUDE.md, run the chapter-12 hazard audit on the generatedmigration.sqlbefore commit, and check for an uncommitted prior migration to fold. - A raw CHECK on
CurriculumRule:level_id IS NOT NULL = (scope_type = 'LEVEL')and (carry existing)option_block_id IS NOT NULL = (scope_type = 'BLOCK')— Prisma can't model it; add via raw SQL in the migration (curriculum_rules_scope_ref_chk).
Indexes and uniqueness¶
@@unique([tenantId, name])onSubjectLevel+ partial uniquesubject_levels_platform_name_key ON (name) WHERE tenant_id IS NULL(mirrorevaluation_scales_platform_name_key).@@index([tenantId])onSubjectLevel;@@index([levelId])onCurriculumSubject.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /subject-levels |
@RequireScope(CURRICULA, 'configuration', 'read') |
— (list) | SubjectLevelResponseDto[] (platform ∪ tenant) |
| GET | /subject-levels/:id |
@RequireScope(CURRICULA, 'configuration', 'read') |
— | SubjectLevelResponseDto |
| POST | /subject-levels |
@RequireScope(CURRICULA, 'configuration', 'write') |
CreateSubjectLevelDto |
SubjectLevelResponseDto |
| PATCH | /subject-levels/:id |
@RequireScope(CURRICULA, 'configuration', 'write') |
UpdateSubjectLevelDto |
SubjectLevelResponseDto |
| DELETE | /subject-levels/:id |
@RequireScope(CURRICULA, 'configuration', 'write') |
— | 204 (guarded: 409 SUBJECT_LEVEL_IN_USE) |
| POST/PATCH/DELETE | /subject-levels/presets[/:id] |
@PlatformAdminOnly() |
Create/Update DTO | SubjectLevelResponseDto / 204 |
All tenant routes use the singular @RequireScope (not plural @RequireScopes, not @RequireAction) so the response/body are treated as a sub-resource shape — no scope-group filtering.
Platform presets (tenantId=null) are read-only to tenants: PATCH/DELETE on a preset → 403/404 (mirror scale behavior — confirm which scales use, then match).
The curriculum grid endpoints are unchanged in shape; they gain the levelId input field and level ref in the response (see §3 DTO row).
Swagger considerations¶
SubjectLevelRefDto({ id, name, code }) reused in curriculum subject/rule responses — register so Scalar picks it up.- Selection-write error example: extend the existing inconsistency error example with a LEVEL-rule violation entry (chapter 06 error-examples drift guard applies — keep the example union in sync).
5b. Backoffice / preset-catalog impact¶
The backoffice (chapter 17) edits the global preset catalog only (platform-admin; tenantId IS NULL), never per-tenant data. Two preset surfaces are affected. This work lives in this repo (backend + backoffice/ SPA).
Backend (preset routes + validation):
- New POST/PATCH/DELETE /api/v1/subject-levels/presets[/:id], @PlatformAdminOnly(), mirroring the evaluation-scale preset routes (declare the static presets segment before :id routes). HL/SL ship as seeded presets and are editable/extendable here by experts.
- PresetGridDto (src/curriculum-presets/dto/preset-definition.dto.ts) reuses CurriculumSubjectInputDto/CurriculumRuleInputDto directly → levelId is inherited with no DTO change. Add preset-context validation: any levelId referenced by a preset subject/rule must resolve to a platform SubjectLevel (tenantId IS NULL) — a preset cannot pin a tenant-owned level. (Parallels the existing platform-scale constraint on preset gradingScaleId; reuse that validation seam.)
- CurriculumService.expandPreset (tenant materialization) carries levelId straight through — platform levels are visible to every tenant via the OR-visibility filter, so no remap is needed (unlike grade keys).
SPA (backoffice/):
- backoffice/src/types.ts: add 'LEVEL' to RuleScopeType; add levelId?: string | null to SubjectInput + RuleInput; add a SubjectLevel type. (Contract-coupling rule from chapter 17: reconcile types.ts whenever the grid DTOs change.)
- backoffice/src/api/subject-levels.ts + lookups.ts: listSubjectLevels() (→ /subject-levels, filter to presets), and a Subject-Levels presets tab cloning the eval-scales presets tab (list/create/edit/delete platform levels).
- backoffice/src/curriculum-import.ts + the grid editor: a per-subject level picker (options = platform level presets) and LEVEL rule support. Because the friendly template is name-keyed, level is referenced by name/code ("HL"/"SL") and resolved to the platform preset id at import (the SPA fetches the level presets, same as it will for the picker) — mirroring how blockRef resolves by name.
- Doc reconciliation: chapter 17 currently claims preset subjects render "name/level inline" and documents only ENTIRE_PLAN/BLOCK rules — both are stale (level was never wired; LEVEL scope is new). Update chapter 17 with this iteration.
Scope (resolved, §10): full backoffice in this iteration — backend preset routes + preset-context validation + types.ts mirror + subject-levels presets tab and the curriculum-preset editor level picker + LEVEL-rule authoring UI + curriculum-import.ts level/LEVEL-rule name→id resolution, all landing together.
6. RBAC seed plan¶
None — subject levels reuse curricula.configuration via the singular @RequireScope sub-resource gate. No new PermissionScope, PermissionAction, actionScopeRequirements, ScopeFieldMapping, *_SCOPES constant, EntityKey, or roles.ts grant. The rbac-catalogue.drift.spec is unaffected.
The only src/common/constants delta is in error-codes.ts: add SUBJECT_LEVEL_NOT_FOUND {levelId}, SUBJECT_LEVEL_NAME_TAKEN {name}, SUBJECT_LEVEL_PRESET_READONLY {levelId}, SUBJECT_LEVEL_IN_USE {levelId, references:{curriculumSubjects, curriculumRules}}.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
findSelectionInconsistencies evaluates only block min/max + structural checks |
We add a generic CurriculumRule evaluation pass (ENTIRE_PLAN / BLOCK / LEVEL) |
IB needs LEVEL counts; building the generic pass is marginal extra cost and finally enforces the dormant rules | Previously-dormant ENTIRE_PLAN/BLOCK rules become enforced — could invalidate existing confirmed selections, but only at selection time (write + sweep), never at authoring; greenfield + no real IB selections ⇒ accepted |
Inline grid entities referenced by name (blockRef) |
Levels referenced by UUID levelId |
Levels are a separate catalog like EvaluationScale, not defined inline in the grid payload |
One more catalog lookup at authoring validation; consistent with gradingScaleId |
EvaluationScale catalog has its own RBAC entity/scope/actions |
Subject levels reuse curricula.configuration via the singular @RequireScope sub-resource gate (no own entity/scope/actions) |
Fabio's call (2026-06-08): no new scope; subject levels belong to curriculum config. Avoids a new role grant and the flat-response field-filter conflict | Catalog management auth = curriculum-config WRITE (coarser than a dedicated scope); acceptable since the same admins own both |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "add level back and exclusivity, we should be good" (chat) | Exclusivity needs no new primitive under layout A (block maxSelections=1 covers it); and "level back" alone is insufficient — the rule engine never evaluated anything |
Add level catalog + a LEVEL rule scope and build the missing rule-evaluation engine; drop the dedicated exclusivity primitive |
Resolved (agreed in chat: layout A, generic engine) |
Original grid spec modeled level as VarChar(20)? free string |
A free string is not constraint-bearing and can't be counted reliably | Catalog FK (SubjectLevel) with platform presets |
Resolved |
9. Deferrals¶
- Carry-forward executor interaction with levels — none needed now; revisit at the year-end-rollover iteration. Follow-up:
project_selection_granularity_design. - Per-track LEVEL sub-scoping (count HL within a single track) — YAGNI for IB (no tracks). Follow-up: revisit if a track-bearing curriculum needs level counts.
- FE/admin grid rendering of levels + SubjectGroup level display — frontend iteration.
- A general MUTUALLY_EXCLUSIVE rule primitive — only if a future layout puts level variants in separate blocks. Follow-up: revisit if layout B is ever requested.
10. Open questions¶
- Layout: per-group blocks (exclusivity structural) — resolved: layout A.
- Level catalog scope: per-tenant catalog with platform presets — resolved: mirror EvaluationScale.
- Rule-engine scope: generic (all scope types) — resolved.
- Count semantics / track-awareness / out-of-scope defaults — resolved (chat: mandatory-with-level counts; track-applicable only; backend-only).
- Module placement: resolved — dedicated
src/subject-levels/module, a near-exact clone ofevaluation-scales/(tenant catalog, not curriculum-owned; ownsubject_levels.configurationscope). - Backoffice SPA scope (§5b): resolved — full backoffice in this iteration. Backend preset routes + preset-context validation +
types.tsmirror + subject-levels presets tab and the curriculum-preset editor level picker, LEVEL-rule authoring UI, andcurriculum-import.tslevel/LEVEL-rule name→id resolution all land together. No fast-follow.
11. Verification plan¶
- Unit specs:
src/subject-levels/subject-levels.service.spec.ts— OR-visibility (tenant ∪ platform), create stamps tenantId, preset write/delete rejected, delete-guardSUBJECT_LEVEL_IN_USEwhen a subject/rule references it.src/subject-levels/subject-levels.queries.spec.ts—subjectLevelVisibleWhere.src/curriculum/selection-consistency.spec.ts— extend: LEVELAT_LEAST/AT_MOST/EXACTLYevaluation; the full IB scenario (6 groups, count HL 3–4 / SL 2–3); mandatory-with-level counted; track-applicability of the count; ENTIRE_PLAN + BLOCK rule evaluation; structural-edit sweep invalidates a now-non-compliant confirmed selection.src/curriculum/curriculum.validation.spec.ts—assertValidRulesLEVEL branch:levelIdrequired for LEVEL, forbidden for ENTIRE_PLAN/BLOCK.curriculum-configuration.dto.spec.ts—levelIdaccepted/validated on subject + rule inputs.- E2E specs:
subject-levels.e2e-spec.ts— catalog CRUD + visibility + delete-guard (followfeedback_e2e_isolation_patterns: unique markers, deleteMany cleanup).- extend the curriculum-selection e2e — author an IB-shaped curriculum (levels + LEVEL rules), submit a valid selection (accepted) and an over-HL selection (rejected), and edit structure to trip the sweep.
subject-levelspreset routes (*.controller.spec.ts/ e2e):@PlatformAdminOnlygating, preset create/edit/delete, delete-guard when in use.curriculum-presets(*.service.spec.ts): a preset subject/rule with a platformlevelIdvalidates; a tenant-ownedlevelIdin a preset is rejected (preset-context validation).- SPA checks (
backoffice/):curriculum-import.spec.ts(if present) — level name/code→id resolution + LEVEL-rule parsing intemplateToPreset;types.tsmirrors the new DTO fields (contract-coupling). - Manual verification: author HL/SL levels → build a 2-group IB curriculum with LEVEL rules → POST a selection violating HL max → expect typed 400.
Patterns: chapter 09 (testing); feedback_e2e_isolation_patterns.md.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-06-08
- Chat reference: brainstorm with Fabio 2026-06-08 — layout A (block=course, exclusivity structural), per-tenant catalog w/ platform presets, generic rule-evaluation engine, full backoffice in scope. The three default calls (mandatory-with-level counts, track-applicable counts, tenant-FE out of scope) accepted without objection. Signed off with "go on with the plan".
Until this section is filled, no implementation code is written.