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-relationparentSubjectId. 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
periodDurationcascade is the existing 4-levelresolveEffectivePeriodDuration(cell → subject → grade → curriculum), reused as-is; (3) each new error code needserror-messages.catalog.ts(en+it, build-enforced) + param classification, not justerror-examples.ts; (4)parentSubjectId: nullmust also filtercurriculumGridInclude+allGridSubjects, not onlyselection-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)loadScaleContextmust split scale-owner (unit) from criteria-owner (container/unit); (8)coveredUnit*clones the existinglessonTeacherIds/Namesarrays; (9)FIELD_LABELSonly for validated input fields. Scope unchanged — these tighten the implementation shape.
1. Problem distillation¶
- A
CurriculumSubjectcan 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
OptionBlockgrade-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'scriteriaLevel(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 container —
criteriaLevel: SUBJECT | UNIT(defaultSUBJECT).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 existingCurriculumSubjectCriterionREPLACE (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.
- SG create (
subject-groups.service.tscreate ~1018, afterfindCurriculumSubjectAnchor~1027):curriculumSubjectAnchorSelect(subject-groups.queries.ts:422) must first addparentSubjectId(+ 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-levelresolveEffectivePeriodDuration(period-math.ts:49: cell → subject → grade → curriculum) — the cell is simply absent, so it lands on the container's ownperiodDuration(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). - Grade write (
grades.service.tsrecord ~87): leaf SG → today's behaviour (parentSubjectId = null,curriculumSubjectId = sg.curriculumSubjectIdat:151). Container SG → requiredto.curriculumSubjectId(a child unit;GRADE_UNIT_REQUIRED), validate it's a child (GRADE_UNIT_NOT_IN_SUBJECT), stampcurriculumSubjectId = unit,parentSubjectId = container(+parentSubjectName/Code), and resolve the grading context bycriteriaLevel. This needs a reworkedloadScaleContext(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 thecriteriarelation. The variant must split the id into a scale-subject (the unit) and a criteria-subject (container forSUBJECT, unit forUNIT). 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 existingpickCriteriaForGrade(grades.scale-context.ts:18) applied to whichever owner'scriteriaarray 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): addparentSubjectId String? @map("parent_subject_id") @db.Uuid+ self-relationparentSubject … onDelete: Cascadeand back-relationchildSubjects CurriculumSubject[];@@index([parentSubjectId]); addcriteriaLevel CriteriaLevel? @map("criteria_level")(container-only — null on leaves/units; a container defaults toSUBJECT). New enumenum CriteriaLevel { SUBJECT UNIT @@map("criteria_level") }. NoclassGranularityenum (single-slot is the only scheduling model). Update the doc comment (leaf / container / unit shapes +criteriaLevel). Containers may carryCurriculumSubjectCriterionrows (+criteriaGradingScaleId) — theSUBJECT-level shared criteria; today's spec put criteria only on units.GradeEntry(~2175): add soft (no-FK) nullableparentSubjectId @db.Uuid,parentSubjectName @db.VarChar(150)(matches the localsubjectNamesizing),parentSubjectCode(untyped →text, mirroringsubjectCodeatschema.prisma:2196) beside the subject snapshot columns.AttendanceRecord(~538): add nullable snapshot arrayscoveredUnitIds String[] @db.Uuid(@map("covered_unit_ids")) +coveredUnitNames String[](@map("covered_unit_names"), untyped →text[]), default[]— an exact clone of the existinglessonTeacherIds/lessonTeacherNamesarray-snapshot pair on the same model (schema.prisma:581-582), a known column shape, not novel. NoparentSubject*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 aseffectiveWeeklyHourson 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()inselection-consistency.ts:159(§2, §7). - Container's effective
periodDurationfor scheduling reuses the existing 4-levelresolveEffectivePeriodDuration(period-math.ts:49: cell → subject → grade → curriculum) — the cell is simply absent on a container, so it resolvescontainer.periodDuration(subject level) →CurriculumGrade→Curriculumwith 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(addparent_subject_id+ self-FKON 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 ⇒ norls-coverage.ts/tenanted-models.ts/ db-constraints delta.label-coverage.drift.spec.tsscan-a collects only class-validator-decorated props, soFIELD_LABELSis needed only for the validated input fields —units,criteriaLevel(onCurriculumSubjectInputDto),unitIds(onTakeAttendanceDto),curriculumSubjectId(onRecordGradeDto, if unlabelled). Do NOT add labels forparentSubjectId,coveredUnitIds,coveredUnitNames— server-stamped / response-echo (not validated input), so scan-a would flag them as dead labels.VALUE_LABELSfor theCriteriaLevelmembers (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 syncexistingquery gainsparentSubjectId). 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 list — curriculumGridInclude (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 geterror-examples.tsrows.
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
CurriculumSubjectHourswindow 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 readcriteriaLevelto know the shape. coveredUnit*in the audit trail — attendancetake()already audits each cell viaAuditService.recordMany(attendance.service.ts:398) with a value-onlyCellValuesbag; v1 keeps that bag value-only and does not addcoveredUnit*, 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 omitscurriculumSubjectId, 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, defaultSUBJECT(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),criteriaLeveldefaultSUBJECT+ 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 percriteriaLevel(SUBJECT→ container's;UNIT→ unit's);parentSubject*stamped; leaf path byte-identical; gradebookunits[]with shared-vs-per-unit criteria columns.attendance.service.spec.ts/ queries — optionalunitIdsvalidated as children; fanned to all records (present + absent) ascoveredUnit*; 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;
GETreturns 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 carrycoveredUnit*. - 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— observedcoveredUnit*unit-coverage tag.docs/REFERENCE.md— module rows + glossary ("Unit", "container subject") + file-index rows.- Memory
project_subject_unitsupdated 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
classGranularityUNIT 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-containercriteriaLevel: SUBJECT | UNIT, defaultSUBJECT(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.