Evaluation Scales — Curriculum Default + Cascading Overrides¶
Status: Design approved, awaiting implementation plan Date: 2026-05-18 Author: brainstormed with Fabio Barbieri Source user stories: - US-10 — Manage Curricula Scenarios 10–13 and 15 (curriculum/study-plan/subject scale selection) - US-42 — Manage Register Settings Scenarios 1–2 and 6–7 (scale catalog + assignment + lock-in-use); criteria-based scales (§3-4) and exam scales (§5) are out of scope.
Problem¶
The product moved the "evaluation scale configuration" out of standalone register-settings and onto the curriculum tree itself. A school needs to:
- Maintain a catalog of grading scales (NUMERIC, LETTER, DESCRIPTIVE) with three platform-provided presets always visible.
- Pick a default scale on each Curriculum. That default automatically applies to every StudyPlan inside the curriculum, and to every subject inside each study plan, unless the admin overrides at the StudyPlan or Subject level. Overrides are sparse — most subjects inherit; only exceptions get touched.
- Manage the catalog with full CRUD, including a hard rule that a scale cannot be deleted while assigned anywhere.
Today the SIS has no concept of an evaluation scale: no Prisma model, no controller, no DTO, no gradingScaleId on Curriculum/StudyPlan/Subjects. US-10 Scenarios 10–13/15 and US-42 §1-2 + §6-7 all sit at "Missing" in the coverage docs.
Goal¶
Ship a new evaluation-scales module with full CRUD, three platform presets visible to every tenant, and a four-level cascade (Curriculum → StudyPlan → StudyPlanSubject + OptionBlockSubject) that resolves the effective grading scale for any subject via live inherit: each level stores a nullable gradingScaleId; null means "inherit from the parent"; the service exposes both the raw stored value and the resolved value on every read.
This iteration ships only the scale catalog + the cascade reference threading. Criteria-based scales, rubrics, exam scales, evaluation entries, and the lock-on-evaluation-entries rule are explicitly deferred — the model leaves room for them.
Scope¶
In scope¶
- New Prisma models
EvaluationScaleandEvaluationScaleValue; new enumScaleType(NUMERIC,LETTER,DESCRIPTIVE). - New nullable FK
gradingScaleIdonCurriculum,StudyPlan,StudyPlanSubject,OptionBlockSubject, eachON DELETE Restrict. - New module
src/evaluation-scales/with controller (/evaluation-scales), service, queries, swagger, DTOs, interfaces. - Catalog read returns tenant-owned ∪ platform presets (platform presets are rows with
tenantId IS NULL, read-only for tenants). - New RBAC entity
EVALUATION_SCALESwithconfigurationscope andcreate+deleteactions (admin only initially). - Update the curriculum/study-plan/subject CRUD DTOs (admin and wizard paths) with the optional
gradingScaleIdfield; the wizard's CURRICULUM step threads the field throughCurriculumService.bulkSyncunchanged. - New error codes:
EVALUATION_SCALE_NOT_FOUND,EVALUATION_SCALE_NAME_TAKEN,EVALUATION_SCALE_PRESET_READONLY,EVALUATION_SCALE_TYPE_IMMUTABLE,EVALUATION_SCALE_IN_USE,EVALUATION_SCALE_VALUE_INVALID,EVALUATION_SCALE_VALUE_OVERLAP. - Tests: DTO specs, service specs (assertCreatable / assertEditable / cascade-visibility / preset-readonly / in-use guard), curriculum-service spec for the four-level reference threading, RBAC seed update.
- Docs: new module entry in
docs/REFERENCE.md; CURRICULUM step note indocs/08-setup-wizard.md; multi-tenant exception note indocs/02-multitenancy.md.
Out of scope¶
- Criteria-based scales (US-42 §3-4) — subscale composition + averaging primitive belong to the evaluations iteration.
- Rubrics + conversion tables (US-10 §14) — depend on evaluation entry surface.
- Exam scales (US-42 §5) — ClickUp itself defers this.
- Lock based on evaluation entries (US-42 §7) — today we only lock on FK references; the
inUsepredicate trivially extends with one OR clause whenEvaluationEntryexists. - Curriculum-preset → scale suggestion (US-10 §10) — static-data tweak; defer until the evaluations subsystem solidifies which scales matter.
- Teacher read access on the catalog — one permission row; add when the grading UI consumes scales.
- Bulk "apply scale to all subjects" endpoint — admin can do it through curriculum PATCH; revisit on UX feedback.
- Per-AY scale catalogs — tenant-flat is the chosen scoping; year-isolated history, if ever needed, will be added via an evaluation-entry-time snapshot table, not by re-architecting the catalog.
- Inline "clone preset" endpoint — FE pre-fills a
POST /evaluation-scalesbody from the preset GET; no dedicated copy endpoint.
Decisions log¶
Ordered summary of brainstorming decisions. Each links to the section that enforces it.
- Tenant-flat catalog. Scales are not AY-scoped. Curricula in any AY reference the same catalog. No copy-forward on rollover. See §2.
- Live inherit, not snapshot-on-create.
StudyPlan.gradingScaleIdandSubject.gradingScaleIdare nullable; null means "inherit from parent". Changing a parent's scale instantly changes the effective scale of every non-overriding child. Responses expose bothgradingScale(what was picked at this level) andeffectiveGradingScale(resolved). See §3. - Polymorphic single-table values.
EvaluationScaleValueis one table with all type-specific fields nullable; the parent scale'stypeis the discriminator. Service-layer validation enforces per-type field discipline. Rejected: separate tables per type (two-FK polymorphism is messy), and JSON blob (no querying, breaks the codebase's no-JSON-domain-data norm). See §2. - Score-band thresholds for LETTER/DESCRIPTIVE.
minandmaxare numeric score bands mapping raw scores into a label. Per-row:min ≤ max. Cross-row within one scale: no band overlaps; gaps allowed. See §5. - Platform-owned, read-only presets. Three preset rows with
tenantId IS NULL, one per type, always visible in every tenant's catalog. Tenants cannot edit, rename, or delete them. To customise, the admin creates a new scale (FE pre-fills the body from the preset). Rejected: seed-per-tenant (preset definitions can't evolve once seeded) and static-TS-expansion-only (catalog list wouldn't show the preset until the admin instantiates it). See §2. - Name uniqueness spans tenant ∪ platform. A tenant cannot create a scale named the same as one of its own or as a platform preset (case-insensitive). Enforced at service layer + a partial unique index for platform names. See §2.
typeis immutable. Changing scale type would invalidate all references and per-value field semantics. PATCH rejects it withEVALUATION_SCALE_TYPE_IMMUTABLE. See §5.valueseditable only when not in use. While the scale is referenced by any Curriculum/StudyPlan/Subject, the values array is locked (name remains editable). Future-proof: whenEvaluationEntrylands, theinUsepredicate adds the entry table with one OR clause. See §5.ON DELETE Restricton all four FKs. DB-level reinforcement of the "can't delete an assigned scale" service guard. Catches concurrent races. See §2.- No new wizard step for scales. Scales are tenant-flat lifetime config; platform presets are visible day-one; admins call
POST /evaluation-scalesad-hoc. The wizard's CURRICULUM step gains optionalgradingScaleIdfields at four levels, validated through the sameassertScaleVisiblehelper as admin CRUD. See §7. - Tenant-aware read filter is the named exception to the multi-tenant invariant. Reads use
WHERE tenantId = :tenantId OR tenantId IS NULL. Writes always requiretenantId = :tenantId. Documented indocs/02-multitenancy.mdas the single approved divergence. See §6.
1. Architecture summary¶
[Tenant catalog read]
│
├── GET /evaluation-scales → tenant rows ∪ platform presets
├── GET /evaluation-scales/:id → tenant row or platform preset
[Admin mutations — tenant-owned rows only]
│
├── POST /evaluation-scales → create (admin)
├── PATCH /evaluation-scales/:id → edit name (always) / values (only if not in use); reject type
└── DELETE /evaluation-scales/:id → reject if any FK references it
[Curriculum CRUD threading]
│
└── POST/PATCH on curriculum + study plan + subjects
├── DTOs accept optional gradingScaleId at each of 4 levels
└── CurriculumService.bulkSync calls assertScaleVisible(tenantId, id) per non-null FK
[Read shaping]
│
└── Curriculum, StudyPlan, StudyPlanSubject, OptionBlockSubject responses each carry:
• gradingScale: EvaluationScaleRefDto | null — what was picked
• effectiveGradingScale: EvaluationScaleRefDto | null — resolveEffectiveScale walks up
Invariants¶
- Platform presets are inviolable.
tenantId IS NULLrows reject every write path withEVALUATION_SCALE_PRESET_READONLY(403) before any other rule fires. - Tenant cannot read or write another tenant's row. Read filter is
tenantId = :tenantId OR tenantId IS NULL; write paths use the strictertenantId = :tenantId. - Type is immutable. PATCH
type→ 409EVALUATION_SCALE_TYPE_IMMUTABLE. - Values are locked while in use. If any FK references the scale, the
valuesarray cannot be edited. Same predicate as delete. - Live inherit. Storage is exactly what the admin picked; cascade resolution is a pure helper on already-loaded payloads. Stored
nullalways means "inherit from parent". Curriculum is the top — itseffectiveGradingScaleequals its owngradingScale.
2. Schema¶
Enum¶
New models¶
/// A grading scale catalog entry.
/// tenantId NULL = platform preset (read-only for tenants, visible everywhere).
/// tenantId set = tenant-owned, editable per RBAC.
model EvaluationScale {
id String @id @default(uuid()) @db.Uuid
tenantId String? @map("tenant_id") @db.Uuid
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String @db.VarChar(150)
type ScaleType
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
values EvaluationScaleValue[]
curricula Curriculum[]
studyPlans StudyPlan[]
planSubjects StudyPlanSubject[]
blockSubjects OptionBlockSubject[]
@@unique([tenantId, name])
@@index([tenantId])
@@map("evaluation_scales")
}
/// One value within a scale. Shape is polymorphic by parent scale type:
/// NUMERIC → numericEq required; min/max forbidden
/// LETTER/DESCRIPTIVE → min/max required (min ≤ max); numericEq forbidden
/// Discriminator is the parent's `type`; service-layer validation enforces it.
model EvaluationScaleValue {
id String @id @default(uuid()) @db.Uuid
scaleId String @map("scale_id") @db.Uuid
scale EvaluationScale @relation(fields: [scaleId], references: [id], onDelete: Cascade)
value String @db.VarChar(50)
label String @db.VarChar(150)
ordinalPosition Int @map("ordinal_position")
numericEq Float? @map("numeric_eq")
min Float?
max Float?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([scaleId, value])
@@unique([scaleId, ordinalPosition])
@@index([scaleId])
@@map("evaluation_scale_values")
}
Touches on existing models¶
Each of Curriculum, StudyPlan, StudyPlanSubject, OptionBlockSubject gains:
gradingScaleId String? @map("grading_scale_id") @db.Uuid
gradingScale EvaluationScale? @relation(fields: [gradingScaleId], references: [id], onDelete: Restrict)
Tenant model gains the back-relation:
Cross-cutting constraints¶
- Platform-preset name uniqueness. Postgres treats
NULLas distinct, so@@unique([tenantId, name])does not prevent two platform rows named the same. The generated migration is hand-edited to add a partial unique index: - Tenant vs. platform name collision. No DB constraint catches a tenant naming their scale identically to a platform preset. Service-layer check before insert/update:
Miss →
prisma.evaluationScale.findFirst({ where: { name: { equals: dto.name, mode: 'insensitive' }, OR: [{ tenantId: null }, { tenantId }] }, })EVALUATION_SCALE_NAME_TAKEN(409).
Migration safety¶
All four gradingScaleId columns are nullable from day one — no backfill on existing rows. New models, new enum, new partial index — all additive. No destructive operations. Per docs/12-migrations.md this is a green-path migration; the only manual SQL is the partial index above.
3. Cascade resolution + response shape¶
Thin reference DTO¶
One shared shape used wherever a scale is referenced inline:
class EvaluationScaleRefDto {
@ApiProperty({ format: 'uuid' }) id: string;
@ApiProperty() name: string;
@ApiProperty({ enum: ScaleType }) type: ScaleType;
}
Full values (the value rows) are embedded only on the dedicated scale endpoints (GET /evaluation-scales, GET /evaluation-scales/:id). Curriculum/study-plan responses stay light.
Per-level response shape¶
| Level | gradingScale |
effectiveGradingScale |
|---|---|---|
| Curriculum | what was picked (or null) | same as gradingScale |
| StudyPlan | what was picked (or null) | walks up to curriculum |
| StudyPlanSubject | what was picked (or null) | walks up to study plan → curriculum |
| OptionBlockSubject | what was picked (or null) | walks up to study plan → curriculum |
Symmetric shape across all four levels: the FE binds to effectiveGradingScale uniformly for any grading UI, and to gradingScale for the override editor (null rendered as "Inherit from parent").
Resolver¶
Pure helper in src/evaluation-scales/evaluation-scales.queries.ts, exported through the module's index.ts and called from CurriculumService while shaping responses:
export function resolveEffectiveScale(
subjectScale: EvaluationScaleRefDto | null,
studyPlanScale: EvaluationScaleRefDto | null,
curriculumScale: EvaluationScaleRefDto | null,
): EvaluationScaleRefDto | null {
return subjectScale ?? studyPlanScale ?? curriculumScale ?? null;
}
No Prisma calls. The existing curriculum include chain gains gradingScale: { select: { id: true, name: true, type: true } } at each of the four levels — one nested fetch, no N+1.
Behaviour on parent update¶
A single PATCH on Curriculum.gradingScaleId instantly changes the effective scale of every child that didn't override. No fan-out writes, no cascade copies — the next GET reflects it.
4. API surface¶
New module¶
src/evaluation-scales/
├── evaluation-scales.module.ts
├── evaluation-scales.controller.ts
├── evaluation-scales.service.ts
├── evaluation-scales.queries.ts # Includes, list filters, visibleScaleWhere, resolveEffectiveScale (pure helpers)
├── evaluation-scales.swagger.ts
├── dto/
│ ├── create-evaluation-scale.dto.ts
│ ├── update-evaluation-scale.dto.ts
│ ├── evaluation-scale-response.dto.ts
│ ├── evaluation-scale-ref.dto.ts
│ └── evaluation-scale-value.dto.ts
├── interfaces/
│ └── index.ts
└── index.ts # Public exports
Endpoints¶
| Method | Path | Action | RBAC | Notes |
|---|---|---|---|---|
GET |
/evaluation-scales |
List | @RequireScopes(EVALUATION_SCALES, 'read') |
Tenant rows ∪ platform presets. No pagination (small N). |
GET |
/evaluation-scales/:id |
Single | @RequireScopes(EVALUATION_SCALES, 'read') |
404 if not visible (tenant scope or platform). |
POST |
/evaluation-scales |
Create | @RequireAction(EVALUATION_SCALES, 'create') |
Body = CreateEvaluationScaleDto. tenantId derived from request. 201. |
PATCH |
/evaluation-scales/:id |
Edit | @RequireScopes(EVALUATION_SCALES, 'write') + FieldWriteGuard |
Body = UpdateEvaluationScaleDto. Rejects on platform preset. |
DELETE |
/evaluation-scales/:id |
Delete | @RequireAction(EVALUATION_SCALES, 'delete') |
Rejects on platform preset. Rejects if any FK reference exists. |
The shipped controller uses the single-mode
@RequireScopes(entity, mode)form (not the three-arg(entity, scope, mode)) because the entity has exactly one scope (configuration). The mode-only declaration is the canonical convention for single-scope entities.
No "preset clone" endpoint — FE pre-fills POST body from the preset GET.
Catalog response DTOs¶
class EvaluationScaleValueResponseDto {
id: string;
value: string;
label: string;
ordinalPosition: number;
numericEq: number | null; // NUMERIC only
min: number | null; // LETTER/DESCRIPTIVE only
max: number | null; // LETTER/DESCRIPTIVE only
}
class EvaluationScaleResponseDto {
id: string;
name: string;
type: ScaleType;
isPreset: boolean; // tenantId IS NULL — drives FE read-only UI
inUse: boolean; // any FK references this scale — drives delete-disabled UI
values: EvaluationScaleValueResponseDto[]; // ordered by ordinalPosition
createdAt: Date;
updatedAt: Date;
}
inUse is computed in the same query that loads the scale — one existence check per reference table.
Write DTOs¶
class EvaluationScaleValueInputDto {
@IsOptional() @IsUUID() id?: string; // present on update
@IsString() @Length(1, 50) value: string;
@IsString() @Length(1, 150) label: string;
@IsInt() @Min(1) ordinalPosition: number;
@IsOptional() @IsNumber() numericEq?: number;
@IsOptional() @IsNumber() min?: number;
@IsOptional() @IsNumber() max?: number;
}
class CreateEvaluationScaleDto {
@IsString() @Length(1, 150) name: string;
@IsEnum(ScaleType) type: ScaleType;
@IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => EvaluationScaleValueInputDto)
values: EvaluationScaleValueInputDto[];
}
class UpdateEvaluationScaleDto extends PartialType(CreateEvaluationScaleDto) {
// PATCH explicitly rejects `type` — see §5.
}
Curriculum / study-plan CRUD threading¶
Four existing DTOs each gain gradingScaleId?: string (uuid or null-to-clear):
CreateCurriculumConfigDto/UpdateCurriculumConfigDtoCreateStudyPlanInputDto/UpdateStudyPlanInputDto- Study-plan subject input (StudyPlanSubject + OptionBlockSubject)
Wire-format convention: undefined ⇒ no change; explicit null ⇒ clear FK (inherit). The service uses the existing pickDefined helper.
5. Validation, guards, error codes¶
Create-time validation¶
| Layer | Check |
|---|---|
| DTO (class-validator) | name length 1–150; type ∈ ScaleType; values[].value length 1–50; values[].label length 1–150; values[].ordinalPosition ≥ 1; numeric typing on numericEq/min/max. |
| Service business rules | (a) name unique across tenant ∪ platform presets (case-insensitive). (b) values.length ≥ 1. (c) value unique within scale (case-insensitive). (d) ordinalPosition sequential 1..n. (e) Per-type discipline: NUMERIC → numericEq required, min/max forbidden; LETTER/DESCRIPTIVE → min and max required, min ≤ max, numericEq forbidden. (f) LETTER/DESCRIPTIVE only: no [min, max] overlap across rows; gaps allowed. |
| Prisma constraint | @@unique([tenantId, name]) + partial index on platform names. |
Update-time rules¶
| Field | Editable? | Conditions |
|---|---|---|
name |
Always (non-preset) | Same uniqueness check as create. |
type |
Never | EVALUATION_SCALE_TYPE_IMMUTABLE (409). |
values |
Only when inUse === false |
Reference check inside the same transaction as the update; on a hit → EVALUATION_SCALE_IN_USE (409). Otherwise full sync via syncEntities. |
Delete-time rules¶
- Platform preset →
EVALUATION_SCALE_PRESET_READONLY(403). - Any FK reference →
EVALUATION_SCALE_IN_USE(409). Errordatacarries per-table reference counts so the FE can show "Used by 3 curricula, 2 study plans, 12 subjects". - Otherwise: hard delete.
ON DELETE CascadeonEvaluationScaleValueremoves values in the same tx.
Cascade-reference validation from curriculum CRUD¶
Whenever Curriculum.gradingScaleId, StudyPlan.gradingScaleId, StudyPlanSubject.gradingScaleId, or OptionBlockSubject.gradingScaleId is set, the curriculum service calls assertScaleVisible(tenantId, id):
prisma.evaluationScale.findFirst({
where: { id, OR: [{ tenantId }, { tenantId: null }] },
select: { id: true },
});
Miss → EVALUATION_SCALE_NOT_FOUND (raised here with HTTP 400 — invalid reference in payload). One helper, one implementation across wizard and admin CRUD.
New error codes¶
| Code | HTTP | Params shape | When |
|---|---|---|---|
EVALUATION_SCALE_NOT_FOUND |
404 / 400 | { scaleId: string } |
Direct GET/PATCH/DELETE on missing/foreign id (404); curriculum CRUD references one (400). |
EVALUATION_SCALE_NAME_TAKEN |
409 | { name: string } |
Create/rename collides with tenant row or platform preset (case-insensitive). |
EVALUATION_SCALE_PRESET_READONLY |
403 | { scaleId: string } |
Any write attempt on tenantId IS NULL. |
EVALUATION_SCALE_TYPE_IMMUTABLE |
409 | { scaleId: string } |
PATCH includes type. |
EVALUATION_SCALE_IN_USE |
409 | { scaleId, references: { curricula; studyPlans; planSubjects; blockSubjects } } |
Delete or values edit when any FK exists. |
EVALUATION_SCALE_VALUE_INVALID |
400 | { ordinalPosition: number; reason: string } |
Per-type field discipline violation. |
EVALUATION_SCALE_VALUE_OVERLAP |
400 | { a: { ordinalPosition; min; max }; b: { ordinalPosition; min; max } } |
LETTER/DESCRIPTIVE band overlap. |
All seven added to src/common/constants/error-codes.ts with typed params shapes; Swagger examples in error-examples.ts.
Field-write enforcement¶
EVALUATION_SCALES gets a single configuration scope; the mapping covers name, type, and values. FieldWriteGuard enforces it on PATCH.
6. RBAC + module placement¶
Entity / scope / actions¶
| Item | Value | Rationale |
|---|---|---|
| Entity key | EVALUATION_SCALES |
New src/common/constants/entity-keys.ts entry. |
| Scope | configuration (single) |
Matches DEPARTMENTS, ROOMS, SCHOOL. |
| Actions | create, delete |
read never an action (visibility via scope); update never an action (PATCH uses scope + FieldWriteGuard). |
Seed entries¶
prisma/seed/rbac-catalogue.ts — add the entity and its scope+actions:
prisma/seed/roles.ts — admin only at first:
| Role | EVALUATION_SCALES.configuration |
create |
delete |
|---|---|---|---|
ADMIN |
✓ | ✓ | ✓ |
| All others | — | — | — |
Teachers' read access lands in the evaluations iteration alongside the grading UI consumer; one permission row, no migration impact.
Multi-tenant exception¶
The named exception to the chapter-02 invariant (every query filters by tenantId) is encoded in one helper:
// src/evaluation-scales/evaluation-scales.queries.ts
export function visibleScaleWhere(tenantId: string): Prisma.EvaluationScaleWhereInput {
return { OR: [{ tenantId }, { tenantId: null }] };
}
Used on every read path. Write paths (create / update / delete) use the stricter tenantId = :tenantId, so platform presets are excluded before the explicit EVALUATION_SCALE_PRESET_READONLY check fires. Two-layer defense.
Cross-module boundary¶
assertScaleVisible(tenantId, scaleId) lives on EvaluationScalesService and is the only thing CurriculumService calls into the new module. Exported through evaluation-scales/index.ts. No direct Prisma reach-across.
7. Setup wizard integration¶
CURRICULUM step DTO changes¶
The wizard's CurriculumStepDataDto and its nested item DTOs each gain an optional gradingScaleId:
// curriculum-step.dto.ts nested shapes
class CurriculumItemDto { /* …existing… */ @IsOptional() @IsUUID() gradingScaleId?: string; }
class StudyPlanItemDto { /* …existing… */ @IsOptional() @IsUUID() gradingScaleId?: string; }
class StudyPlanSubjectItemDto { /* …existing… */ @IsOptional() @IsUUID() gradingScaleId?: string; }
class OptionBlockSubjectItemDto { /* …existing… */ @IsOptional() @IsUUID() gradingScaleId?: string; }
CurriculumService.bulkSync already walks the tree; it routes each gradingScaleId through the same assertScaleVisible(tenantId, id) helper used by admin CRUD. One implementation across both paths.
No new wizard step¶
Scales aren't a wizard step. Reasons: scales are tenant-flat lifetime config; platform presets are visible day-one so the curriculum form's "scale" dropdown is never empty; admins call POST /evaluation-scales ad-hoc whenever they want a custom one. No state machine concern, no isComplete() gate, no group changes.
Step state payload¶
SetupCurriculumStateDto returns the curriculum tree with the new gradingScale + effectiveGradingScale thin refs at each of the four levels — same shaper, same resolveEffectiveScale helper as admin CRUD. The catalog itself is fetched separately by the FE via GET /evaluation-scales — kept out of the wizard state payload so it doesn't bloat.
Doc updates after implementation¶
docs/REFERENCE.md§4 — newevaluation-scales/row.docs/REFERENCE.md§6 file index — new task row "Add or modify a grading scale".docs/08-setup-wizard.md§6 CURRICULUM step — newgradingScaleIdfield description + explicit "no separate SCALES step" note.docs/02-multitenancy.md— record thetenantId IS NULLplatform-preset exception as the single approved divergence.
8. Risks and open questions¶
tenantId IS NULLpunches a hole in the multi-tenant invariant. Mitigation: one named helper (visibleScaleWhere); writes use the strict tenant filter; explicitEVALUATION_SCALE_PRESET_READONLYguard; cross-tenant tests asserting tenant A cannot mutate tenant B's row, neither tenant can mutate a platform row, both can read platform rows.- Live inherit makes
effectiveGradingScalea derived field. Any future direct-Prisma consumer sees only rawgradingScaleIdcolumns and misses the cascade. Mitigation:resolveEffectiveScaleis the single helper internal consumers reuse; documented as such in the module'sindex.ts. - Score-band overlap validation is O(n²). Fine at expected sizes (≤20 values per scale); commented in the helper so a future optimisation doesn't break consistency.
ON DELETE Restrictcan produce raw Prisma errors if the service guard misses. Mitigation: the guard runs first; we catch Prisma'sP2003in delete + value-edit paths and re-throw asEVALUATION_SCALE_IN_USE, matching the DEPARTMENTS/GRADES P2002 pattern.- Preset definitions may drift from curriculum-preset suggestions if/when US-10 Scenario 10 (preset → scale suggestion) is wired. Tracked in §Scope as out-of-scope; revisit alongside the curriculum-preset work.
9. Cross-references¶
docs/REFERENCE.md— repo-wide map; this work lands as a new module and a new task-type row.docs/02-multitenancy.md— thetenantId IS NULLplatform-preset exception will be documented here.docs/04-rbac.md— entity + scope + actions seeding pattern.docs/05-crud-patterns.md— module layout, DTO scope conventions, list endpoint patterns.docs/06-error-handling.md— new error codes + params shapes pattern.docs/08-setup-wizard.md— CURRICULUM step touch.docs/12-migrations.md— must read before generating the new-models + partial-index migration.docs/coverage/per-us/US10-curricula.md— Scenarios 10–13, 15 (delivered with documented out-of-scope: rubrics §14, preset suggestion §10).docs/coverage/per-us/US42-manage-register-settings.md— Scenarios 1–2, 6–7 (delivered without criteria-based scales §3-4 and exam scales §5).
Follow-up: criteria iteration¶
The criteria iteration (2026-05-18-evaluation-criteria-design.md) builds on this PR. It adds per-subject criteria (with a single subject-level criteriaGradingScaleId shared across the criterion list) and the pure percent-based retrofit helpers (scaleRange, valueToNumeric, numericToValue, retrofitVote, proposeFinalFromCriteria) that will be consumed when the evaluation-entries subsystem lands. Criteria-scale FKs extend the inUse predicate documented in §6 — the count payload now carries planSubjectsCriteria and blockSubjectsCriteria.