Skip to content

Subject Units — a main subject decomposed into graded units (single-slot model)

One paragraph. A curriculum subject can hold a meaningful internal partition — Units (Math = Algebra + Geometry; a PYP subject = its Units of Inquiry). A Unit is structurally a CurriculumSubject — own hours cell, own grading/criteria scales, own criteria — grouped under a main subject (container) via a new nullable self-relation parentSubjectId. The main subject is scheduled as a single class (one SubjectGroup, one timetable slot worth Σ the units' hours); units are not scheduled or rostered separately. Grades are per-unit (entering a mark on a container class asks which unit) and stamp the main subject so reports roll up. Attendance is taken on the main subject's class and can optionally carry an observed teacher tag — "which unit(s) did we cover today" — for future coverage reports.

Revision (2026-07-14, same day, pre-code). The first-approved version had a per-container classGranularity { UNIT, SUBJECT } flag (UNIT = one SG per unit, scheduled separately — for sequential PYP units) plus a per-cell planned teaching window (taughtAllYear/dates) so hours would sum per active week. Product decided the planned window "may not be worth it." We dropped UNIT granularity and the unit teaching-window entirely: every container is single-slot ("Math-style"), which models both named cases (Math and PYP UOI — nobody timetables "Unit 3" as its own slot) with one mechanism, and we swap the planned window for an observed attendance tag (teacher records what was actually covered — more accurate, simpler to author). The one deferred capability is called out in §9. Nothing was implemented before this revision.

Pattern-review corrections (2026-07-16, pre-code). A codebase audit of this spec's reuse claims fixed: (1) there is no hours-domain divisibility check to reuse and none is added (divisibility validation was removed from the hours domain); (2) the periodDuration cascade is the existing 4-level resolveEffectivePeriodDuration (cell → subject → grade → curriculum), reused as-is; (3) each new error code needs error-messages.catalog.ts (en+it, build-enforced) + param classification, not just error-examples.ts; (4) parentSubjectId: null must also filter curriculumGridInclude + allGridSubjects, not only selection-consistency; (5) the container Σ-hours / ∪-applicability read projection is net-new (no OptionBlock hours precedent); (6) the "container offered-in-grade" predicate is shared across ≥5 sites; (7) loadScaleContext must split scale-owner (unit) from criteria-owner (container/unit); (8) coveredUnit* clones the existing lessonTeacherIds/Names arrays; (9) FIELD_LABELS only for validated input fields. Scope unchanged — these tighten the implementation shape.


1. Problem distillation

  • A CurriculumSubject can be a container ("main subject") owning 2+ unit child subjects, or a leaf (today's behaviour, unchanged). Units carry their own per-(unit, grade) hours cell, grading scale, and criteria.
  • A container has no cells of its own. Its per-grade weekly hours = Σ its units' cells for that grade (AC #1); its grade-applicability derives from its children exactly like OptionBlock grade-applicability derives from its members.
  • Single-slot scheduling. The container is scheduled as one SubjectGroup worth Σ(unit hours); units are never scheduled, rostered, taught, or attended as separate classes. There is no granularity flag — every container behaves this way.
  • Grades are per-unit. Entering a mark on a container's class requires choosing the unit; the mark's subject scale resolves against the unit's (unit, grade) cell, its criteria resolve per the container's criteriaLevel (below), it anchors the container's SG, and it stamps the main subject (GradeEntry.parentSubject*) so report cards roll up. No computed container-level mark in v1.
  • Criteria live at one level per containercriteriaLevel: SUBJECT | UNIT (default SUBJECT). SUBJECT = one shared criteria set (+ criteria scale) authored on the main subject, used by every unit-grade (the common Math / IB-MYP case); UNIT = criteria authored per unit. All-or-nothing per container (no mix), so the deferred report card can template a clean subject-level or per-unit criteria display by reading one flag. Per-grade list variation stays available within the chosen level via the existing CurriculumSubjectCriterion REPLACE (NULL-grade default + per-grade override).
  • Attendance is taken on the container's class (= the reported subject — no rollup needed) and carries an optional teacher-set observed unit-coverage tag (multi-select of the container's units), snapshotted on the records for future reports. This replaces the dropped planned window.
  • Units are never independently selectable (selection-consistency skips parentSubjectId != null); the container is the choosable subject and may be mandatory or an option-block alternative.

Success criteria (observable behavior that proves this works): - PATCH /curricula/:id authors a container with nested units[]; GET returns the container with its units + derived per-grade Σ-hours and no cell of its own; a cell submitted directly on a container is rejected. - A student's curriculum selection treats only the container as choosable — units never appear as a mandatory/in-block choice, never invalidate a selection, never inflate coverage. - POST /subject-groups succeeds on a leaf or a container; is rejected on a unit. The container SG schedules against Σ(unit hours) and publishes. - A grade recorded on a container SG requires a unit id, resolves scale/criteria against the unit's (unit, grade) cell, stamps curriculumSubjectId = unit + parentSubjectId = container. A grade on a leaf SG is unchanged. GET /grades/subject-group/:id on a container returns per-unit columns. - Attendance take on a container SG accepts an optional unitIds[] (subset of the container's units); each written record carries a coveredUnitIds/coveredUnitNames snapshot; a per-subject attendance query needs no join.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Separately-scheduled / separately-taught units — a subject whose components run in distinct timetable slots with distinct teachers/rosters but report as one subject (e.g. Science = Bio Tue / Chem Thu). The dropped UNIT granularity. Deferred (§9); neither Math nor PYP needs it. - Planned per-unit teaching windows ("this unit is taught Sept–Oct"). Replaced by the observed attendance tag. The CurriculumSubjectHours window columns stay for regular subjects/generation; the units feature does not use or require them. - Computed container-level mark — units may differ in scale, so no aggregation is well-defined; the container is a reporting grouping only. - Report cards — no report-card module exists. v1 makes the data roll-up-able (grade parentSubject*, attendance coveredUnit*); it builds no report surface. - Preset authoring of units (backoffice presets + expandPreset) — author on the live grid first. - >2 levels of nesting — units cannot themselves be containers (one level deep).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
OptionBlock → alternatives (model ~1736) + curriculum-structure-sync.ts syncBlockList (~827) The child-linking-within-family-sync precedent: resolve the parent row first, then syncSubjectList its children with the resolved id (syncBlockList ~869 does exactly this). The ∪-grade-applicability semantic ("applicable in G ⟺ a member has a cell in G") matches the container's applicability rule — but it exists today only as an eval-time .some() in selection-consistency.ts:159, not a read-time projection. Nested DTO (OptionBlockInputDto.subjects ~415) is the model for nested units[]. Cascade-delete + choice-invalidation carry. An option block is a choice; a container is all-taken and not choosable. Units live under a subject row, so the identity tuple is parentSubjectId, not optionBlockId. No hours precedent: a block carries no aggregate hours (schema.prisma:1731-1735), so the container's per-grade Σ-hours + the effectiveWeeklyHours read projection are net-new (§7), not an OptionBlock mirror. OptionBlockInputDto.subjects reuses the full CurriculumSubjectInputDto recursively; units use a deliberate subset DTO (§7).
CombinedClass iter-3 soft columns The soft-snapshot contributor-attribution pattern: a frozen row carries extra soft columns for the "other" teaching context so per-subject reports see through it. Directly reused twice — GradeEntry.parentSubject* (unit → main subject) and AttendanceRecord.coveredUnit* (observed units on the session). NOT combined-classes: no dual-anchor lesson, no union roster, no disjoint-roster rule — a container is one ordinary SubjectGroup.
grades v1 + grades.scale-context.ts GradeEntry soft-FK + snapshot columns; the (subject, grade) scale/criteria cascade resolveEffectiveCellScale; finalOverride ?? avg(criteria). A container-SG grade resolves the context against the chosen unit id instead of the SG's subject. The write path today derives curriculumSubjectId from the SG (grades.service.ts:151); a container SG must accept + validate a chosen unit id and resolve the scale against it. The gradebook read groups by student only — a container needs per-unit columns.
attendance per-student snapshot (its v1 spec was deleted 2026-07-25) + attendance.queries.ts Per-student frozen snapshot rows with soft columns; bulk take per lesson; recordable cells key off ScheduledLesson.subjectGroupId. The observed unit tag is a session-level value fanned onto each record. Need a new session-level input (unitIds) on the take + snapshot array columns; small additive change.
resolve-target-plan.ts + grouped-courses coverage (~644) { should, planSubjectIds } coverage denominator; the boards' catalog-driven subject rows. Must ensure units are invisible to placement/coverage (a container is one expected course; units are never rows) — the inverse of the option-block expansion.

On-axis / off-axis (ch16 §4–§5). Mostly on-axis — a new nullable self-relation + nested child on an existing grid model, riding the existing family-sync; plus soft-snapshot columns on grades/attendance (coveredUnit* clones the existing lessonTeacherIds/Names array snapshot on AttendanceRecord, schema.prisma:581-582). No new module, entity, scope, action, or cross-cutting concern. One off-axis invention to call out (ch16 §5): the container's read-time effectiveWeeklyHours (Σ children) + ∪-applicability projection has no existing precedent — a block carries no aggregate hours and the codebase does no read-time hours projection; this is new code, not a borrowed pattern. It also forces a shared "container offered-in-grade G ⟺ ≥1 child unit has a cell in G" predicate reused across SG-create, both coverage boards, countOffered, and HOUR_BUDGET (§3). Dropping UNIT granularity removed the only other off-axis seam (per-container two-mode branching) the first version carried.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes unchanged — units/containers are CurriculumSubject rows under a Curriculum; new soft columns inherit each row's tenancy standard
Academic-year scope yes unchanged — grid AY-scoped; SGs/attendance/grades already AY-filtered standard
RBAC entity key existing grid CURRICULA, SGs SUBJECT_GROUPS, grades GRADE_ENTRIES, attendance ATTENDANCE, timetables TIMETABLES. No new entity. on-axis
Scopes existing curricula.configuration, subject_groups.composition, grade_entries.record, attendance.register, timetables.configuration. No new scope. reuse
Actions none grade record, attendance take, SG create/delete, timetable create/publish unchanged read/update implicit
Service base custom (existing services) deltas in CurriculumService/curriculum-structure-sync.ts/curriculum.validation.ts (author + validate + container Σ-divisibility), selection-consistency.ts (skip units), resolve-target-plan.ts + subject-groups.queries.ts (units invisible to coverage), SubjectGroupsService (reject SG on a unit), GradesService + grades.scale-context.ts (unit pick + scale + gradebook columns), AttendanceService + attendance.queries.ts (observed unit tag). No new module. no new domain
queries.ts shape extend selects SG anchor select gains parentSubjectId + _count.childSubjects; grades SG select gains the SG subject's parentSubjectId + childSubjects { id, name, code }; grid include nests childSubjects + their cells/criteria; attendance take loads the container's units for validation selects carry the relation where needed
Error codes new (small set) CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS (a container submitted with hours); CURRICULUM_CRITERIA_LEVEL_MISMATCH (criteria authored at the wrong level for the container's criteriaLevel); SUBJECT_GROUP_ANCHOR_IS_UNIT (SG create on a unit — or a reason on the existing SUBJECT_GROUP_REQUIRES_CURRICULUM_SUBJECT); GRADE_UNIT_REQUIRED (grade on a container SG with no unit); GRADE_UNIT_NOT_IN_SUBJECT (chosen unit not a child of the SG's container); ATTENDANCE_UNIT_NOT_IN_SUBJECT (tagged unit not a child of the SG's container). Reuse GRADE_SUBJECT_NOT_GRADABLE (no criteria/scale resolves at the chosen level). No Σ-divisibility gate — divisibility validation has been removed from the hours domain and is not reintroduced (container hours are just Σ children). Each new code needs a full i18n round: ErrorCode enum + (if params) ERROR_TEXT_PARAMS/ERROR_INTERNAL_PARAMS classification in error-codes.ts, ERROR_MESSAGES{en_US,it_IT} in error-messages.catalog.ts (build-enforced by error-messages.drift.spec.ts), and error-examples.ts. One-level-deep + no-cycles are structurally enforced by the nested DTO (no code). new domain guards
DTO conventions scope sub-DTOs CurriculumSubjectInputDto gains units?: CurriculumUnitInputDto[] (new subset DTO — no nesting, no cells-on-container); response gains isContainer + nested units[] + per-grade effectiveWeeklyHours. RecordGradeDto gains curriculumSubjectId? (the unit; required iff the SG's subject is a container). Gradebook gains a units[] dimension for container SGs. TakeAttendanceDto gains optional unitIds?: string[]; AttendanceCellDto echoes coveredUnitIds/coveredUnitNames. Mirror grid input fields into GridSubjectInput (bulk-sync.interfaces.ts). contract parity
File-backed sub-resources n/a not a file surface
Custom fields no grid rows / not custom-field-bearing
Profile completeness n/a not a person entity

The two consumers that must know a subject's shape (leaf / container / unit)

The shape is read off two columns — a subject is a container iff it has ≥1 child (_count.childSubjects > 0); is a unit iff parentSubjectId != null; else a leaf.

  1. SG create (subject-groups.service.ts create ~1018, after findCurriculumSubjectAnchor ~1027): curriculumSubjectAnchorSelect (subject-groups.queries.ts:422) must first add parentSubjectId (+ a child-cells load) so the guard can read it. Then reject an SG whose anchor is a unit (SUBJECT_GROUP_ANCHOR_IS_UNIT); allow leaf or container. The existing offered-in-grade check (subject-groups.service.ts:1044, subject.hours.some(...)) fails for a cell-less container — it must use the shared container-offered predicate (∪ children cells for the grade). A container SG's scheduling minutes = Σ(unit cells); its duration resolves through the existing 4-level resolveEffectivePeriodDuration (period-math.ts:49: cell → subject → grade → curriculum) — the cell is simply absent, so it lands on the container's own periodDuration (subject level) → grade → curriculum with no resolver change. Effective track/block is the container's own (a container can be mandatory or in a block/track exactly like a leaf).
  2. Grade write (grades.service.ts record ~87): leaf SG → today's behaviour (parentSubjectId = null, curriculumSubjectId = sg.curriculumSubjectId at :151). Container SG → require dto.curriculumSubjectId (a child unit; GRADE_UNIT_REQUIRED), validate it's a child (GRADE_UNIT_NOT_IN_SUBJECT), stamp curriculumSubjectId = unit, parentSubjectId = container (+ parentSubjectName/Code), and resolve the grading context by criteriaLevel. This needs a reworked loadScaleContext (grades.scale-context.ts:61), not a drop-in: today it takes a single subject id and uses it for both the scale cascade and the criteria relation. The variant must split the id into a scale-subject (the unit) and a criteria-subject (container for SUBJECT, unit for UNIT). Two consequences: (a) resolveEffectiveCellScale (evaluation-scales.queries.ts:58) is a fixed 4-arg chain (cell → subject → grade → curriculum) that never consults the container, so a unit-scale "container fallback hop" is not expressible without a new argument — add one or drop the hop; (b) the per-grade criteria REPLACE stays the existing pickCriteriaForGrade (grades.scale-context.ts:18) applied to whichever owner's criteria array the level selects — reuse it, don't reimplement. No criteria/scale resolves → GRADE_SUBJECT_NOT_GRADABLE.

Attendance take stamps the optional observed coveredUnit* tag (§4/§5); coverage/selection treat units as invisible/non-choosable (§5).


4. Data model plan

Schema deltas

  • CurriculumSubject (~1647): add parentSubjectId String? @map("parent_subject_id") @db.Uuid + self-relation parentSubject … onDelete: Cascade and back-relation childSubjects CurriculumSubject[]; @@index([parentSubjectId]); add criteriaLevel CriteriaLevel? @map("criteria_level") (container-only — null on leaves/units; a container defaults to SUBJECT). New enum enum CriteriaLevel { SUBJECT UNIT @@map("criteria_level") }. No classGranularity enum (single-slot is the only scheduling model). Update the doc comment (leaf / container / unit shapes + criteriaLevel). Containers may carry CurriculumSubjectCriterion rows (+ criteriaGradingScaleId) — the SUBJECT-level shared criteria; today's spec put criteria only on units.
  • GradeEntry (~2175): add soft (no-FK) nullable parentSubjectId @db.Uuid, parentSubjectName @db.VarChar(150) (matches the local subjectName sizing), parentSubjectCode (untyped → text, mirroring subjectCode at schema.prisma:2196) beside the subject snapshot columns.
  • AttendanceRecord (~538): add nullable snapshot arrays coveredUnitIds String[] @db.Uuid (@map("covered_unit_ids")) + coveredUnitNames String[] (@map("covered_unit_names"), untyped → text[]), default [] — an exact clone of the existing lessonTeacherIds/lessonTeacherNames array-snapshot pair on the same model (schema.prisma:581-582), a known column shape, not novel. No parentSubject* on attendance — a container SG's records already carry the container as their subject (single-slot ⇒ nothing to roll up).
  • Raw-SQL CHECK curriculum_subjects_unit_shape_chk (same-row; Prisma can't express): CHECK (parent_subject_id IS NULL OR (option_block_id IS NULL AND track_id IS NULL)) — a unit inherits the parent's containment and is never in a block/track directly. "Container has no cells"
  • "container has ≥1 unit" stay service-side (cross-table); one-level-deep is structural via the nested CurriculumUnitInputDto.

Derived quantities (never stored)

  • Container per-grade weekly hours = Σ children CurriculumSubjectHours.weeklyHours; container grade-applicability = ∪ children taught grades. Projected as effectiveWeeklyHours on the container response. This read-time projection is net-new — a block carries no aggregate hours (schema.prisma:1731-1735) and the response mapper (curriculum.service.ts:195) does no such derivation today; the ∪-applicability matches only the eval-time .some() in selection-consistency.ts:159 (§2, §7).
  • Container's effective periodDuration for scheduling reuses the existing 4-level resolveEffectivePeriodDuration (period-math.ts:49: cell → subject → grade → curriculum) — the cell is simply absent on a container, so it resolves container.periodDuration (subject level) → CurriculumGradeCurriculum with no resolver change.
  • No Σ-divisibility check. Divisibility validation was removed from the hours domain and is not reintroduced; a container's hours are the Σ of children with no fit gate.

Migration shape

  • Additive / non-destructive. One migration: ALTER curriculum_subjects (add parent_subject_id + self-FK ON DELETE CASCADE + index + the unit-shape CHECK); ALTER grade_entries (3 nullable columns); ALTER attendance_records (2 nullable array columns). No backfill (existing subjects stay leaves).
  • Hazards (ch12): CHECK validates existing rows (all parent_subject_id NULL → passes); nullable + array column adds are safe; no new tenant-bearing table ⇒ no rls-coverage.ts / tenanted-models.ts / db-constraints delta. label-coverage.drift.spec.ts scan-a collects only class-validator-decorated props, so FIELD_LABELS is needed only for the validated input fields — units, criteriaLevel (on CurriculumSubjectInputDto), unitIds (on TakeAttendanceDto), curriculumSubjectId (on RecordGradeDto, if unlabelled). Do NOT add labels for parentSubjectId, coveredUnitIds, coveredUnitNames — server-stamped / response-echo (not validated input), so scan-a would flag them as dead labels. VALUE_LABELS for the CriteriaLevel members (SUBJECT, UNIT), both languages.

Indexes and uniqueness

  • @@index([parentSubjectId]). Unit name uniqueness rides the family-sync container scope (name unique within {curriculumId, trackId, optionBlockId, parentSubjectId} — the sync existing query gains parentSubjectId). No new unique constraint.

5. API surface

No new endpoints. Deltas on existing surfaces (FE-breaking — flag + coordinate with FE; no back-compat shims, but the greenfield "breaks are free" stance is retired now the product is in prod):

Surface Delta
PATCH /curricula/:id (grid family-sync) CurriculumSubjectInputDto accepts nested units?: CurriculumUnitInputDto[] + criteriaLevel? (container-only, default SUBJECT). A container omits hours (rejected if present — CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS); it may carry criteria/criteriaGradingScaleId when criteriaLevel = SUBJECT. Criteria at the wrong level (units in SUBJECT mode / container in UNIT mode) → CURRICULUM_CRITERIA_LEVEL_MISMATCH. Units ride the existing sync via a parent-scoped syncSubjectList (mirror syncBlockList).
GET /curricula/:id (+ list) CurriculumSubjectResponseDto gains isContainer, units[] (each a full subject projection with its cells/scales/criteria), and per-grade effectiveWeeklyHours (Σ children) on containers. The grid include must exclude units from the top-level subject listcurriculumGridInclude (curriculum.queries.ts:57) top-level where gains parentSubjectId: null, and units are read only via the nested childSubjects relation (also guard the pre-flight walker allGridSubjects, curriculum-structure-sync.ts:163).
POST /subject-groups Reject an SG anchored on a unit (SUBJECT_GROUP_ANCHOR_IS_UNIT); allow leaf or container. A container SG schedules against Σ(unit hours) with the container's resolved period duration.
GET /subject-groups/grouped-courses + /table + /missing-courses A container is one course; units are never rows. Single filter point: findBoardSubjectsForCurricula (subject-groups.queries.ts:728) where gains parentSubjectId: null (feeds both catalog boards). A cell-less container is only counted/shown if the offered gates (subject-groups.service.ts:544, :899, countOffered :688) use the shared container-offered predicate (∪ children cells) instead of the subject's own hours — else a cell-less container silently disappears. /table rows are SGs, so units never appear there once SG-create rejects them.
POST /grades (RecordGradeDto) gains curriculumSubjectId? (the chosen unit) — required + child-validated iff the SG's subject is a container (GRADE_UNIT_REQUIRED/GRADE_UNIT_NOT_IN_SUBJECT); ignored for a leaf SG. Response GradeEntry carries parentSubject*. PATCH /grades/:id keeps the entry's unit fixed.
GET /grades/subject-group/:id (gradebook) A container SG response gains a units[] dimension, marks split by curriculumSubjectId. Criteria columns follow criteriaLevel: SUBJECT → one shared criteria/scale context across all units; UNIT → a per-unit context. Leaf SGs keep today's flat shape.
Attendance take (TakeAttendanceDto) gains optional unitIds?: string[] (a subset of the container's units; validated as children — ATTENDANCE_UNIT_NOT_IN_SUBJECT; ignored for a leaf SG). The take fans the tag onto every written record (present and absent) as coveredUnitIds/coveredUnitNames snapshots.
Attendance grid / cell / reports AttendanceCellDto echoes coveredUnitIds/coveredUnitNames. Future coverage reports query attendance_records directly (array has/hasSome) — no join.
Student assignment block (GET /students/:id) a container appears once as the taught subject; units are not separate SG rows (they never get SGs).

Swagger considerations

  • units[] nesting + "container has no cells" on the grid DTO prose; RecordGradeDto.curriculumSubjectId = "the unit, required for a multi-unit subject group"; TakeAttendanceDto.unitIds = "optional: which unit(s) were covered this session (multi-select)". New error codes get error-examples.ts rows.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none — new grid fields ride curricula.configuration; the grade curriculumSubjectId + attendance unitIds/coveredUnit* ride the already-granted grade_entries.record / attendance.register groups (verify the new response fields sit inside the granted scope)
Role grants (roles.ts) none
*_SCOPES runtime constant none

Entirely on existing grants. No record-level policy change (a container SG is an ordinary SubjectGroup under SubjectGroupsPolicy).


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
"cell presence ⟺ subject taught in grade" a container is taught in a grade with no cell of its own (Σ children) a main subject's hours are definitionally Σ its units container hours/applicability derived at read — a net-new read projection (a block has no aggregate hours; the only ∪-applicability precedent is the eval-time .some() in selection-consistency.ts:159), plus a shared "container offered-in-grade" predicate at ≥5 sites
GradeEntry.curriculumSubjectId is derived from the SG on a container SG it comes from the request body (the chosen unit), with parentSubjectId holding the container one class (Math) holds marks for several subjects (its units) grade write branches on the SG's subject shape; report rollup uses parentSubjectId ?? curriculumSubjectId
attendance rows record only planned/structural context rows carry an observed teacher-set unit-coverage snapshot (coveredUnit*) replaces the dropped planned window; feeds coverage reports with no join; observed beats planned scalar-array snapshot columns fanned across the roster (consistent with attendance's per-student snapshot doctrine)
an SG can be created on any offered CurriculumSubject an SG is refused on a unit a unit is a grading/reporting facet, never a class one guard reading parentSubjectId on the anchor
loadScaleContext takes one subject id for both scale + criteria on a container SG the scale resolves against the unit while criteria may resolve against the container criteriaLevel: SUBJECT puts criteria on the container split the id into scale-subject + criteria-subject; resolveEffectiveCellScale's fixed 4-arg chain can't take a container hop without a new arg
OptionBlockInputDto.subjects reuses the full recursive CurriculumSubjectInputDto units use a subset CurriculumUnitInputDto (no units field) + same-row CHECK structurally enforce one-level-deep; a 3-level partition has no use and explodes the rollup a second DTO kept in lockstep with the subject DTO

8. Pushback log

US / chat says Conflicts with Proposed instead Status
original US: units have "their own hours … a unit can take place all year or part of it" + "handle scheduling" (implies separately-scheduled, part-year units) the manual timetable is a single weekly grid; planned windows are complex to author and drift from reality single-slot container scheduling (no per-unit slots), and observed attendance unit-tags instead of planned windows — settled chat 2026-07-14 Resolved
first-approved design: per-container classGranularity {UNIT, SUBJECT} product 2026-07-14: the part-year/UNIT machinery "may not be worth it" drop UNIT granularity — SUBJECT-only (single slot) covers Math and PYP UOI Resolved (this revision)
"add Units to a subject" read literally as a new entity would duplicate the CurriculumSubject machinery off-axis a Unit is a CurriculumSubject child via parentSubjectId Resolved
"…total = sum of units" (implies a stored container hour) double-counting vs the per-unit cells container hours derived (Σ children); a container cell is rejected Resolved
"Grades handling to be defined (choose the unit / per-unit SG)" ambiguous with single-slot there is one answer: entering a mark on the container asks which unit (there are no per-unit SGs to anchor to) Resolved (chat 2026-07-14)

9. Deferrals

  • Separately-scheduled / separately-taught units (the dropped UNIT granularity) — a subject whose components run in distinct timetable slots with distinct teachers/rosters but report as one subject (Science = Bio + Chem). Bring back a per-unit-SG mode if a school needs it; interim workaround is two leaf subjects grouped at report time. Follow-up: revisit on demand — memory project_subject_units.
  • Planned per-unit teaching windows — replaced by the observed attendance tag; the CurriculumSubjectHours window columns remain for regular subjects/generation.
  • Coverage reports over coveredUnit* — v1 only captures the tag; the reports that consume it land with the report-card work.
  • Report-card criteria display config ("if / which criteria to show per subject") — a template-layer filter that sits on top of criteriaLevel + the resolved criteria; nothing is baked into the curriculum now. The report card will read criteriaLevel to know the shape.
  • coveredUnit* in the audit trail — attendance take() already audits each cell via AuditService.recordMany (attendance.service.ts:398) with a value-only CellValues bag; v1 keeps that bag value-only and does not add coveredUnit*, so cell history won't reflect covered units. Revisit with the coverage-report / report-card work. (Called out, not silent.)
  • Unit choice in the grade audit trail — the grade audit data (grades.service.ts:191) is value-only today and already omits curriculumSubjectId, so a container-SG grade history won't record which unit a mark was against. v1 keeps the value-only payload (no existing pattern to extend); flag for the report-card iteration if per-unit audit is needed.
  • Computed container-level mark, report cards, preset authoring of units, >2 levels — fenced by v1.

10. Open questions

All resolved (chat 2026-07-14):

  • Adopt SUBJECT-only (single-slot), drop UNIT granularity + the planned teaching window.
  • Per-unit hours kept → container weekly hours = Σ units (AC #1); report shows the split.
  • Attendance unit-tag stored as a snapshot array on each AttendanceRecord (coveredUnitIds/ coveredUnitNames) — no join for future reports — rather than a normalized per-session row.
  • The tag is optional per take.
  • Criteria home: explicit per-container criteriaLevel: SUBJECT | UNIT, default SUBJECT (shared) — not a silent cascade, not a per-grade choice. All-or-nothing per container so the report card templates against one readable flag; per-grade list variation stays via the existing REPLACE.

11. Verification plan

  • Unit specs:
  • curriculum-structure-sync.spec.ts — author a container with nested units (create/rename/reorder/ delete a unit); unit id-or-key identity within the parent scope; container-cell rejected; delete-cascade of units on container delete.
  • curriculum.validation.spec.ts — container-has-no-cells, container-has-≥1-unit, unit-shape (no block/track on a unit), criteriaLevel default SUBJECT + criteria at the wrong level rejected (CURRICULUM_CRITERIA_LEVEL_MISMATCH); one-level-deep is structural (DTO). No divisibility spec (gate removed from the hours domain).
  • selection-consistency.spec.ts — units never enter mandatory/in-block structure; a container participates as a normal choosable subject; a unit-only grid edit never demotes a confirmed selection.
  • resolve-target-plan / grouped-courses coverage — a container is one expected course; units are invisible (no phantom rows, no double count).
  • subject-groups.service.spec.ts — SG create rejected on a unit, allowed on leaf/container; a container SG resolves Σ-hours + container duration.
  • grades.service.spec.ts + grades.scale-context.spec.ts — container SG requires the unit + child-validates; subject-mark scale from the unit cell; criteria resolved per criteriaLevel (SUBJECT → container's; UNIT → unit's); parentSubject* stamped; leaf path byte-identical; gradebook units[] with shared-vs-per-unit criteria columns.
  • attendance.service.spec.ts / queries — optional unitIds validated as children; fanned to all records (present + absent) as coveredUnit*; leaf SG take unchanged; DAILY + PERIOD.
  • timetable duration/budget specs — a container SG budgets Σ(unit) minutes (HOUR_BUDGET must not short-circuit on the container's null own-cell, diagnostics.ts:996); container duration resolves without a cell via the existing 4-level cascade (no resolver change).
  • E2E:
  • Grid round-trip: author Math (Algebra 3h + Geometry 2h) + a UOI subject with N units; GET returns nested units + derived Σ-hours; container-cell rejected.
  • SG create allowed on the container, rejected on a unit; place + publish the container slot.
  • Selection complete without choosing units; a unit edit doesn't invalidate.
  • Grade on the Math SG requires a unit + resolves Algebra's scale + stamps the main subject; gradebook returns per-unit columns.
  • Attendance take on the Math SG with unitIds: [algebra] → records carry coveredUnit*.
  • Manual verification: seed a tenant with one container (Math) + one UOI subject; walk grid editor → SG create → timetable place + publish → grade entry (pick unit) → attendance take (tag a unit), confirming the rollups + the tag snapshot.

Documentation deltas (part of the work)

  • docs/14-homerooms-subject-groups.md — "Subject units" section (leaf/container/unit, single-slot, SG-on-unit rejection, units invisible to coverage).
  • docs/21-grades.md — container-SG unit pick + parentSubject* + per-unit gradebook.
  • docs/19-attendance.md — observed coveredUnit* unit-coverage tag.
  • docs/REFERENCE.md — module rows + glossary ("Unit", "container subject") + file-index rows.
  • Memory project_subject_units updated after landing.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-14
  • Chat reference: brainstormed + signed off 2026-07-14 ("signed"), then revised the same day ("This seems good to me") to SUBJECT-only (single-slot) — dropping the classGranularity UNIT mode and the planned per-unit teaching window, swapping in an observed attendance unit-coverage tag. Core decisions: no computed parent mark; units not selectable; leaf-xor-container (one level); grades per-unit picking the unit on a container SG; per-unit hours kept (container = Σ); tag snapshot on each attendance record, optional. Criteria (chat 2026-07-14, "yes go on"): explicit per-container criteriaLevel: SUBJECT | UNIT, default SUBJECT (shared), all-or-nothing — not a cascade, not per-grade — so the report card templates against one flag.

Until this section is filled, no implementation code is written.