Skip to content

Evaluation Criteria — Design Spec

Partially superseded 2026-06-22: criteria are now an unordered set — the ordinalPosition field and the contiguous 1..n sequence rule (EVALUATION_CRITERIA_INVALID_ORDINAL_SEQUENCE) were removed; reads sort by name. See 2026-06-22-remove-criteria-ordinal-position-design.md.

Date: 2026-05-18 Status: Draft, locked in for implementation Builds on: 2026-05-18-evaluation-scales-design.md

1. Goal

Add an optional, ordered list of criteria to each subject in the curriculum tree (both StudyPlanSubject and OptionBlockSubject). Each subject's criteria share a single grading scale, which by default mirrors the subject's effective grading scale but can be overridden to any other tenant-visible scale.

The criterion rows themselves carry only a name and ordinal position — they do not carry their own scale. This is the central modelling call (see §3).

This PR ships criteria configuration only: the model, CRUD threaded through the existing curriculum/study-plan endpoints, validation, response shape, and pure helper functions for percent-based cross-scale retrofit (for future use by the evaluations subsystem). It does not ship EvaluationEntry, teacher-facing evaluation routes, or the "cannot delete criterion with entries" enforcement — those are deferred to the criteria-evaluation iteration.

2. User-visible behaviour

  • Admin configures a subject. They can optionally define N criteria (e.g. Math → "A", "B", "C", "D").
  • By default all criteria are graded on the subject's effective scale.
  • The admin can pick a different scale for the whole criterion set of a subject (the subject row carries criteriaGradingScaleId). One choice applies to every criterion of that subject.
  • The criterion list is fully editable (add / rename / reorder / remove) until evaluation entries reference the criteria — that lock-out lands with the evaluations iteration.
  • Responses expose criteriaGradingScale (the pick) and effectiveCriteriaGradingScale (the resolved scale after falling back to subject → study plan → curriculum), mirroring the gradingScale / effectiveGradingScale projection already used.

3. Modelling call — criteria scale is per-subject, not per-criterion

The user's spec reads: "each criteria has its own name and grading scale" immediately followed by "All criteria in a subject MUST have the same grading scale". The constraint is the load-bearing half. Modelling the scale as a property of the subject (a single nullable criteriaGradingScaleId FK on each subject row), rather than as a property of each criterion (a nullable FK on each criterion row plus a runtime same-scale invariant), has three concrete benefits:

  1. No same-scale validation logic. With the FK on the subject row, "all criteria use one scale" is structural and impossible to violate. The other design needs an O(n) check on every criteria mutation, and a careful PATCH path that rejects an update touching one criterion's scale without updating the rest atomically.
  2. Criterion row stays small. Just id, parent subject FK, name, ordinalPosition. No FK churn, no effectiveScale projection per criterion — they all resolve to the same value, exposed once on the parent subject.
  3. PATCH semantics match the user's mental model. When the admin "changes the criteria scale", the natural FE flow is one dropdown on the subject card, not N dropdowns on N criterion cards.

If the user later asks for per-criterion scale (e.g. a single subject where criterion A is NUMERIC and criterion B is LETTER), the migration path is additive: drop the subject-level FK in favour of per-criterion FKs and pull the same-scale invariant back into service validation. Cheap to reverse, costly to add to the wrong layer first.

4. Schema

4.1 Two new criterion models

/// Criterion belonging to a study-plan subject. All criteria of a given
/// StudyPlanSubject share the subject's `criteriaGradingScaleId` (or the
/// resolved subject scale if that is null). The criterion row carries only
/// its identity and order.
///
/// Cascade revisit: when EvaluationEntry lands, switch onDelete to Restrict
/// once criterion-level votes exist, OR snapshot the criterion identity onto
/// the entry. Mirrors the EvaluationScale.tenantId comment.
model StudyPlanSubjectCriterion {
  id                 String           @id @default(uuid()) @db.Uuid
  studyPlanSubjectId String           @map("study_plan_subject_id") @db.Uuid
  studyPlanSubject   StudyPlanSubject @relation(fields: [studyPlanSubjectId], references: [id], onDelete: Cascade)
  name               String           @db.VarChar(150)
  ordinalPosition    Int              @map("ordinal_position")
  createdAt          DateTime         @default(now()) @map("created_at")
  updatedAt          DateTime         @updatedAt @map("updated_at")

  @@unique([studyPlanSubjectId, name])
  @@index([studyPlanSubjectId])
  @@map("study_plan_subject_criteria")
}

/// Criterion belonging to an option-block subject. Same shape and rules as
/// StudyPlanSubjectCriterion — separate model because the parent rows are
/// already separate in this schema. See StudyPlanSubjectCriterion docstring
/// for the cascade-revisit note.
model OptionBlockSubjectCriterion {
  id                   String             @id @default(uuid()) @db.Uuid
  optionBlockSubjectId String             @map("option_block_subject_id") @db.Uuid
  optionBlockSubject   OptionBlockSubject @relation(fields: [optionBlockSubjectId], references: [id], onDelete: Cascade)
  name                 String             @db.VarChar(150)
  ordinalPosition      Int                @map("ordinal_position")
  createdAt            DateTime           @default(now()) @map("created_at")
  updatedAt            DateTime           @updatedAt @map("updated_at")

  @@unique([optionBlockSubjectId, name])
  @@index([optionBlockSubjectId])
  @@map("option_block_subject_criteria")
}

4.2 New FK on the two subject models

Add criteriaGradingScaleId to both StudyPlanSubject and OptionBlockSubject:

criteriaGradingScaleId  String?          @map("criteria_grading_scale_id") @db.Uuid
criteriaGradingScale    EvaluationScale? @relation("StudyPlanSubjectCriteriaScale", fields: [criteriaGradingScaleId], references: [id], onDelete: Restrict)
// + criteria back-relation
criteria                StudyPlanSubjectCriterion[]

onDelete: Restrict — same policy as the existing gradingScaleId. A scale referenced as a criteria-scale anywhere blocks delete.

The gradingScale relation on EvaluationScale is already named-anonymously; the new criteria-scale relation needs a distinct relation name on each model because the same scale could be referenced twice (once as primary, once as criteria scale) from the same model. Use explicit @relation("StudyPlanSubjectCriteriaScale") and @relation("OptionBlockSubjectCriteriaScale") plus matching back-relations on EvaluationScale.

4.3 EvaluationScale back-relations

Two new back-relations added to EvaluationScale for the criteria-scale references:

asStudyPlanSubjectCriteriaScale  StudyPlanSubject[]    @relation("StudyPlanSubjectCriteriaScale")
asOptionBlockSubjectCriteriaScale OptionBlockSubject[] @relation("OptionBlockSubjectCriteriaScale")

These also extend the inUse predicate used by EvaluationScalesService — see §6.

4.4 No new entity/scope in the RBAC catalogue

Criteria are administered under the existing curriculum.configuration scope. The criterion field set is folded into the existing scope field-mapping for study-plan-configuration and the option-block-subject portion of the same. No new entries in entity-keys.ts or rbac-catalogue.ts.

5. DTOs

5.1 Input — criteria array on each subject input DTO

StudyPlanSubjectDto and OptionBlockSubjectInputDto each gain:

@IsOptional()
@ValidateIf((_obj, value) => value !== null)
@IsUUID()
@ApiPropertyOptional({
  format: 'uuid',
  nullable: true,
  description: 'Scale used by all criteria of this subject. null = inherit subject scale.',
})
criteriaGradingScaleId?: string | null;

@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubjectCriterionInputDto)
@ArrayMaxSize(20)
@ApiPropertyOptional({ type: [SubjectCriterionInputDto] })
criteria?: SubjectCriterionInputDto[];

SubjectCriterionInputDto:

export class SubjectCriterionInputDto {
  @IsOptional() @IsUUID() @ApiPropertyOptional({ format: 'uuid' })
  id?: string;

  @IsString() @MinLength(1) @MaxLength(150) @ApiProperty()
  name: string;

  @IsInt() @Min(1) @ApiProperty()
  ordinalPosition: number;
}

id? is the diff hint for syncEntities — present means "update existing", absent means "create new". Same convention as scale values.

The 20-row cap matches the soft cap reasoning from scale values (the band-overlap check is O(n²); 20 is generous for the real-world use case and keeps the cost trivial).

5.2 Response — resolved-criteria-scale projection

StudyPlanSubjectResponseDto and OptionBlockSubjectResponseDto each gain:

@ApiProperty({ type: EvaluationScaleRefDto, nullable: true })
criteriaGradingScale: EvaluationScaleRefDto | null;

@ApiProperty({ type: EvaluationScaleRefDto, nullable: true })
effectiveCriteriaGradingScale: EvaluationScaleRefDto | null;

@ApiProperty({ type: [SubjectCriterionResponseDto] })
criteria: SubjectCriterionResponseDto[];

SubjectCriterionResponseDto:

export class SubjectCriterionResponseDto {
  @ApiProperty({ format: 'uuid' }) id: string;
  @ApiProperty() name: string;
  @ApiProperty() ordinalPosition: number;
}

The criterion response intentionally does not include a scale ref — all criteria of a subject use the parent's effectiveCriteriaGradingScale. The frontend reads it once from the subject row.

5.3 Setup wizard DTOs

SubjectInputDto, SubjectResponseDto, BlockSubjectInputDto, BlockSubjectResponseDto in src/setup/dto/steps/curriculum-step.dto.ts get the same field additions: criteriaGradingScaleId?, criteria?, plus the two resolved-scale projections + criteria[] on responses.

6. Service changes

6.1 CurriculumService — extend existing flows

The new fields thread through the same private helpers that already thread gradingScaleId:

  • assertGradingScalesVisible(tenantId, ids) — extend the candidate-id collection step in bulkSync, createCurriculum, updateCurriculum, createStudyPlanForCurriculum, updateStudyPlanById to also include every non-null criteriaGradingScaleId from the payload.
  • createStudyPlan / updateStudyPlan (module-level helpers) — when writing a subject row, pass criteriaGradingScaleId through pickDefined; when writing a criterion sub-collection use syncEntities<SubjectCriterionInputDto, {id,name,ordinalPosition}>(…) keyed by id?.
  • toStudyPlanConfig / toStudyPlanResponse — compute effectiveCriteriaGradingScale for each subject:
const effectiveCriteriaScale =
  subject.criteriaGradingScale ??
  resolveEffectiveScale(
    subject.gradingScale,
    studyPlanScale,
    curriculumScale,
  );

i.e. fall back through the existing 3-level scale cascade if the subject didn't set one explicitly. The fall-back chain is not "use subject row's gradingScaleId" — it's resolveEffectiveScale(subject.gradingScale, …), which already does the right thing.

6.2 EvaluationScalesService — extend inUse predicate

computeInUseMap and referenceCounts already count 4 FK sources (curricula, studyPlans, planSubjects, blockSubjects). Add two more counts for the criteria-scale FKs:

{
  curricula: ,
  studyPlans: ,
  planSubjects: ,
  blockSubjects: ,
  planSubjectsCriteria: prisma.studyPlanSubject.groupBy({
    by: ['criteriaGradingScaleId'],
    where: { criteriaGradingScaleId: { in: ids }, studyPlan: { tenantId } },
    _count: { _all: true },
  }),
  blockSubjectsCriteria: prisma.optionBlockSubject.groupBy({
    by: ['criteriaGradingScaleId'],
    where: { criteriaGradingScaleId: { in: ids }, optionBlock: { studyPlan: { tenantId } } },
    _count: { _all: true },
  }),
}

EVALUATION_SCALE_IN_USE error params get two new keys (planSubjectsCriteria, blockSubjectsCriteria) — additive change to ErrorParamsMap.

6.3 Pure retrofit helpers (added but not yet called)

Added to src/evaluation-scales/evaluation-scales.queries.ts:

/**
 * Numeric range of a scale, used as the denominator/numerator in percent-space conversion.
 * NUMERIC: [min(numericEq), max(numericEq)].
 * LETTER/DESCRIPTIVE: [min(min), max(max)].
 */
export function scaleRange(scale: { type: ScaleType; values: ScaleValueShape[] }): { min: number; max: number }

/** Numeric representative of a vote, on the source scale's number line. */
export function valueToNumeric(value: string, scale: ): number

/** Inverse — find the value on the target scale whose numeric representative is closest. */
export function numericToValue(numeric: number, scale: ): string

/** Convert a vote from one scale to another via percent of range. */
export function retrofitVote(
  voteValue: string,
  source: ,
  target: ,
): string

/**
 * Average per-criterion votes on the criteria scale, convert to subject scale.
 * If criteriaScale.id === subjectScale.id the conversion is a no-op average.
 * Returns the proposed final vote as a value string on the subject scale.
 */
export function proposeFinalFromCriteria(
  criterionVotes: string[],
  criteriaScale: ,
  subjectScale: ,
): string

Algorithm (per the user's worked example 7 + 6 + 5 + 4 / 4 = 5.5 on 0–7 → 78.6% → 6.29 on 0–8):

  1. Convert each criterion vote string → percent of source-scale range using valueToNumeric and scaleRange.
  2. Average the percents (arithmetic mean).
  3. Multiply the averaged percent back into target-scale range to get a numeric.
  4. numericToValue on the target scale to land on a legal vote string.

Edge cases:

  • NUMERIC value → numeric: use numericEq. NUMERIC values in this codebase always carry it (validated).
  • LETTER/DESCRIPTIVE value → numeric: midpoint of [min, max]. The midpoint is the unbiased representative for a banded scale.
  • Numeric → NUMERIC value: nearest numericEq; ties round half-up.
  • Numeric → LETTER/DESCRIPTIVE value: the band that contains it. If no band contains it (gap), pick the band with the closest edge.
  • Empty criterion vote list: the helper accepts criterionVotes.length === 0 and returns null — caller decides what to do (likely fall back to "no vote yet"). In the criteria-only PR this code path is unreachable since we don't persist entries.

These helpers are exported from the evaluation-scales module's barrel (index.ts) alongside the existing resolveEffectiveScale.

6.4 No CRUD controller for criteria

Criteria are nested under the existing study-plan and option-block-subject configuration endpoints. There is no /criteria route. Same rationale as evaluation scales not having a separate setup wizard step: criteria belong to the curriculum tree and are administered as part of it.

7. Validation rules

  • criteriaGradingScaleId (per subject):
  • If not null, must be visible to the tenant (assertScaleVisible).
  • criteria array (per subject):
  • 0..20 rows.
  • name: non-empty, ≤150 chars, unique within the subject (case-insensitive). Mirrors scale-value name uniqueness.
  • ordinalPosition: contiguous 1..n after sort by ordinalPosition. Mirrors scale-value ordinal validation.
  • On PATCH: criterion rows with an id not currently in the subject's criteria set raise EVALUATION_CRITERION_NOT_FOUND (new error code, see §8).

8. New error codes

  • EVALUATION_CRITERION_NOT_FOUND — params: { criterionId: string }. HTTP 400 (referential, not 404 — mirrors EVALUATION_SCALE_NOT_FOUND).
  • EVALUATION_CRITERIA_DUPLICATE_NAME — params: { name: string }. HTTP 400.
  • EVALUATION_CRITERIA_INVALID_ORDINAL_SEQUENCE — params: { expected: number[], received: number[] }. HTTP 400.

EVALUATION_SCALE_IN_USE params shape extended additively with planSubjectsCriteria and blockSubjectsCriteria counts (does not break existing consumers).

9. Test surface

Add or extend:

  • src/evaluation-scales/evaluation-scales.queries.spec.ts — full coverage for scaleRange, valueToNumeric, numericToValue, retrofitVote, proposeFinalFromCriteria. Includes the user's worked example ([7,6,5,4] on 0–7, retrofitted to 0–8, expected ≈ 6.29 → rounded NUMERIC value).
  • src/curriculum/curriculum.service.spec.ts — new cases for criteria CRUD on both subject types: create with criteria, update adds/removes/reorders/renames criteria, set criteriaGradingScaleId to a foreign scale, clear with null, attempt to set to an unknown scale.
  • src/evaluation-scales/evaluation-scales.service.spec.ts — extend the EVALUATION_SCALE_IN_USE test to cover the new criteria-scale FKs.
  • src/curriculum/dto/scopes/study-plan-configuration.dto.spec.ts — validation cases for criteria array (cap, dup name, bad ordinal sequence) + criteriaGradingScaleId shape.
  • src/setup/dto/steps/curriculum-step.dto.spec.ts — same fields exposed in wizard DTOs.

10. Docs

  • docs/REFERENCE.md — add a "Add or modify criteria on a subject" row in the task index pointing at this spec + docs/01-architecture.md.
  • docs/superpowers/specs/2026-05-18-evaluation-scales-design.md — append a "Follow-up: criteria" line linking here.

Follow-up: per-grade criteria — see docs/superpowers/specs/2026-06-05-evaluation-criteria-iteration-2-design.md

11. Out of scope (deferred to the evaluations iteration)

  • EvaluationEntry model + the routes that persist final/per-criterion votes.
  • "Cannot delete criterion with entries" / "cannot change criteria-scale once entries exist" enforcement (mirror of the cascade-revisit note already on EvaluationScale.tenantId).
  • Teacher-facing controller surface for entering, listing, and proposing-avg evaluations.
  • UI surface decisions.

12. Decision log

  • Subject-level criteria-scale FK (not per-criterion). See §3.
  • Two separate criterion models (StudyPlanSubjectCriterion / OptionBlockSubjectCriterion) over a polymorphic single table. Matches the existing pattern (the two subject parent rows are already separate models with separate gradingScaleId columns) and gives Prisma clean FK constraints.
  • % in user's formula = percent-of-scale, not arithmetic modulo. Updated from the pre-compact memory.
  • Cap criteria at 20 per subject. Matches the scale-value cap; trivially raised later.
  • No new RBAC entity/scope. Folded under curriculum.configuration.
  • Pure helpers in evaluation-scales, not curriculum. Retrofit math is about scales; lives next to the scale value math even though its first caller will be the (deferred) evaluation entry service.

13. Risks

  • Frontend mental model. "Each criterion has its own scale" was the user's literal phrasing; the implementation collapses to subject-level. The response shape exposes effectiveCriteriaGradingScale on the subject and an empty/scale-less criterion shape. If the FE was already wireframed against per-criterion scales, the design call here needs to be communicated explicitly.
  • Banded retrofit precision. LETTER ↔ NUMERIC retrofit uses band midpoint, which is lossy in both directions. For schools mixing banded and numeric criteria scales the rounding may surprise teachers. The helper is testable in isolation; we lock the algorithm here and revisit during the evaluations iteration if reports show drift from teacher intuition.
  • Empty criteria with non-null criteriaGradingScaleId. An admin who picks a criteria scale but creates zero criteria leaves a "dangling" scale pick. Allowed (no validation forbids it) — semantically harmless until entries land. Worth a one-line comment in the service where the scale is read.