Skip to content

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:

  1. Maintain a catalog of grading scales (NUMERIC, LETTER, DESCRIPTIVE) with three platform-provided presets always visible.
  2. 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.
  3. 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 EvaluationScale and EvaluationScaleValue; new enum ScaleType (NUMERIC, LETTER, DESCRIPTIVE).
  • New nullable FK gradingScaleId on Curriculum, StudyPlan, StudyPlanSubject, OptionBlockSubject, each ON 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_SCALES with configuration scope and create + delete actions (admin only initially).
  • Update the curriculum/study-plan/subject CRUD DTOs (admin and wizard paths) with the optional gradingScaleId field; the wizard's CURRICULUM step threads the field through CurriculumService.bulkSync unchanged.
  • 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 in docs/08-setup-wizard.md; multi-tenant exception note in docs/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 inUse predicate trivially extends with one OR clause when EvaluationEntry exists.
  • 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-scales body from the preset GET; no dedicated copy endpoint.

Decisions log

Ordered summary of brainstorming decisions. Each links to the section that enforces it.

  1. Tenant-flat catalog. Scales are not AY-scoped. Curricula in any AY reference the same catalog. No copy-forward on rollover. See §2.
  2. Live inherit, not snapshot-on-create. StudyPlan.gradingScaleId and Subject.gradingScaleId are nullable; null means "inherit from parent". Changing a parent's scale instantly changes the effective scale of every non-overriding child. Responses expose both gradingScale (what was picked at this level) and effectiveGradingScale (resolved). See §3.
  3. Polymorphic single-table values. EvaluationScaleValue is one table with all type-specific fields nullable; the parent scale's type is 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.
  4. Score-band thresholds for LETTER/DESCRIPTIVE. min and max are 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.
  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.
  6. 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.
  7. type is immutable. Changing scale type would invalidate all references and per-value field semantics. PATCH rejects it with EVALUATION_SCALE_TYPE_IMMUTABLE. See §5.
  8. values editable 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: when EvaluationEntry lands, the inUse predicate adds the entry table with one OR clause. See §5.
  9. ON DELETE Restrict on all four FKs. DB-level reinforcement of the "can't delete an assigned scale" service guard. Catches concurrent races. See §2.
  10. No new wizard step for scales. Scales are tenant-flat lifetime config; platform presets are visible day-one; admins call POST /evaluation-scales ad-hoc. The wizard's CURRICULUM step gains optional gradingScaleId fields at four levels, validated through the same assertScaleVisible helper as admin CRUD. See §7.
  11. Tenant-aware read filter is the named exception to the multi-tenant invariant. Reads use WHERE tenantId = :tenantId OR tenantId IS NULL. Writes always require tenantId = :tenantId. Documented in docs/02-multitenancy.md as 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 NULL rows reject every write path with EVALUATION_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 stricter tenantId = :tenantId.
  • Type is immutable. PATCH type → 409 EVALUATION_SCALE_TYPE_IMMUTABLE.
  • Values are locked while in use. If any FK references the scale, the values array 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 null always means "inherit from parent". Curriculum is the top — its effectiveGradingScale equals its own gradingScale.

2. Schema

Enum

enum ScaleType {
  NUMERIC
  LETTER
  DESCRIPTIVE

  @@map("scale_type")
}

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:

evaluationScales EvaluationScale[]

Cross-cutting constraints

  1. Platform-preset name uniqueness. Postgres treats NULL as 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:
    CREATE UNIQUE INDEX evaluation_scales_platform_name_key
      ON evaluation_scales (name) WHERE tenant_id IS NULL;
    
  2. 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:
    prisma.evaluationScale.findFirst({
      where: { name: { equals: dto.name, mode: 'insensitive' },
               OR: [{ tenantId: null }, { tenantId }] },
    })
    
    Miss → 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 / UpdateCurriculumConfigDto
  • CreateStudyPlanInputDto / 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; typeScaleType; 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

  1. Platform preset → EVALUATION_SCALE_PRESET_READONLY (403).
  2. Any FK reference → EVALUATION_SCALE_IN_USE (409). Error data carries per-table reference counts so the FE can show "Used by 3 curricula, 2 study plans, 12 subjects".
  3. Otherwise: hard delete. ON DELETE Cascade on EvaluationScaleValue removes 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:

{ entity: 'EVALUATION_SCALES', scopes: ['configuration'], actions: ['create', 'delete'] }

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 — new evaluation-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 — new gradingScaleId field description + explicit "no separate SCALES step" note.
  • docs/02-multitenancy.md — record the tenantId IS NULL platform-preset exception as the single approved divergence.

8. Risks and open questions

  • tenantId IS NULL punches a hole in the multi-tenant invariant. Mitigation: one named helper (visibleScaleWhere); writes use the strict tenant filter; explicit EVALUATION_SCALE_PRESET_READONLY guard; 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 effectiveGradingScale a derived field. Any future direct-Prisma consumer sees only raw gradingScaleId columns and misses the cascade. Mitigation: resolveEffectiveScale is the single helper internal consumers reuse; documented as such in the module's index.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 Restrict can produce raw Prisma errors if the service guard misses. Mitigation: the guard runs first; we catch Prisma's P2003 in delete + value-edit paths and re-throw as EVALUATION_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


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.