Skip to content

IB subject levels (HL/SL) + curriculum-rule enforcement

1. Problem distillation

  • The grid remodel dropped the planned CurriculumSubject.level field; 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 OptionBlock with maxSelections=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 — CurriculumRule is authored/validated/echoed but never evaluated at selection time (findSelectionInconsistencies ignores 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 subResourceShapeFieldFilterInterceptor 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 of EvaluationScale.
  • CurriculumSubject.levelId String? @db.UuidSubjectLevel (onDelete: Restrict), @@index([levelId]).
  • CurriculumRule.levelId String? @db.UuidSubjectLevel (onDelete: Restrict).
  • enum RuleScopeType gains LEVEL.
  • 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 (code HL) / Standard Level (code SL), 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 generated migration.sql before 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]) on SubjectLevel + partial unique subject_levels_platform_name_key ON (name) WHERE tenant_id IS NULL (mirror evaluation_scales_platform_name_key).
  • @@index([tenantId]) on SubjectLevel; @@index([levelId]) on CurriculumSubject.

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 of evaluation-scales/ (tenant catalog, not curriculum-owned; own subject_levels.configuration scope).
  • Backoffice SPA scope (§5b): resolved — 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, and curriculum-import.ts level/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-guard SUBJECT_LEVEL_IN_USE when a subject/rule references it.
  • src/subject-levels/subject-levels.queries.spec.tssubjectLevelVisibleWhere.
  • src/curriculum/selection-consistency.spec.ts — extend: LEVEL AT_LEAST/AT_MOST/EXACTLY evaluation; 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.tsassertValidRules LEVEL branch: levelId required for LEVEL, forbidden for ENTIRE_PLAN/BLOCK.
  • curriculum-configuration.dto.spec.tslevelId accepted/validated on subject + rule inputs.
  • E2E specs:
  • subject-levels.e2e-spec.ts — catalog CRUD + visibility + delete-guard (follow feedback_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-levels preset routes (*.controller.spec.ts / e2e): @PlatformAdminOnly gating, preset create/edit/delete, delete-guard when in use.
  • curriculum-presets (*.service.spec.ts): a preset subject/rule with a platform levelId validates; a tenant-owned levelId in a preset is rejected (preset-context validation).
  • SPA checks (backoffice/): curriculum-import.spec.ts (if present) — level name/code→id resolution + LEVEL-rule parsing in templateToPreset; types.ts mirrors 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.