Skip to content

date: 2026-07-20 slug: subject-units-iteration-1 status: Approved # Draft | Approved | Superseded clickup_us: n/a — product decision + FE BE-spec pasted by Fabio in chat 2026-07-20 ("Subject units: invertire il modello ore") epic: Curriculum grid — subject decomposition (Units of a main subject) related_specs: - docs/superpowers/specs/2026-07-14-subject-units-design.md (the iteration this inverts — units-have-hours, container = Σ) - docs/superpowers/specs/2026-06-03-curriculum-grid-remodel-design.md (grid substrate — per-(subject,grade) hours cells) - docs/superpowers/specs/2026-06-26-grades-criteria-based-v1-design.md (GradeEntry soft-FK + criteria-derived marks) supersedes: none (iterates 2026-07-14-subject-units-design.md — that spec stays as the landed baseline)


Subject Units — invert the hours model (hours on the main subject, criteria on the units)

One paragraph. The landed subject-units model (spec 2026-07-14) put the weekly hours on the units and made the main subject (container) derive Σ — a cell-less container whose hours, applicability, scheduling minutes and scale were all synthesised from its children at read/eval time. Product has reversed this: the schedulable unit is always the main subject. The main subject owns its own per-(grade) cells exactly like an ordinary leaf (hours, grading scale, level, rooms, period duration, teaching window); the units carry only their grading criteria (and a per-unit criteria scale when criteriaLevel = UNIT). Grades stay per-unit (entering a mark on the main subject's class still asks which unit), but the subject-mark scale and the hours/monte-ore now resolve from the main subject, not the unit. Attendance is unchanged. Net effect: the container stops being a special derived thing and becomes a normal leaf-with-cells; the unit becomes a thin criteria-only child. Almost all of the 2026-07-14 iteration's off-axis machinery is deleted, not added to.

Why an inversion, not a fresh feature. Everything the 2026-07-14 spec added to support a cell-less container — the read-time effectiveHours Σ projection, the shared "container-offered ⟺ ∪ children cells" predicate at ~5 sites, the timetable Σ-synthesis, the grades scale-from-unit hop — exists precisely because hours lived on the units. Move the hours back onto the main subject and that machinery has nothing left to do. The unit shrinks to name/id/code + criteria. This iteration is mostly removal.

Rollout note. Subject-units is committed on dev (c356e77) with migration 20260716092919_subject_units, but never shipped: no FE authors containers, the seed authors none, and (confirmed with Fabio 2026-07-20) no environment holds real container/unit data. So there is no data migration and — because every column the inverted model needs already exists on CurriculumSubjectno new schema migration. The change is validation + service + response shape. FE is BREAKING-but-uncoordinated-consumers: rollout is BE-then-FE, no deployed FE reads the old shape.


1. Problem distillation

  • Hours move from units → main subject. A container (isContainer, i.e. has ≥1 unit child) is now an ordinary subject: it owns its per-(grade) CurriculumSubjectHours cells with weeklyHours, gradingScaleId, periodDuration, teaching window — and may carry levelId / roomIds like any leaf. Its "offered in grade G" test is the plain cell-presence rule again.
  • Units carry only criteria. A unit keeps name / id / code and its criteria (+ criteriaGradingScaleId only when criteriaLevel = UNIT). It has no hours cell, no subject-mark grading scale, no level, no rooms, no period duration. A unit that submits any of those is rejected (CURRICULUM_UNIT_HAS_CELLS).
  • criteriaLevel semantics unchanged. SUBJECT = one shared criteria set (+ criteria scale) on the main subject, used by every unit-grade; UNIT = criteria authored per unit. Wrong-level criteria still → CURRICULUM_CRITERIA_LEVEL_MISMATCH.
  • Grades stay per-unit, but resolve the scale from the main subject. A mark on a container SG still names a unit and stamps curriculumSubjectId = unit, parentSubject* = container. What changes: the subject-mark scale cascades against the main subject's (subject, grade) cell (the unit has no cell anymore); the criteria list + criteria scale still follow criteriaLevel (container for SUBJECT, unit for UNIT).
  • effectiveHours is dropped from the response — container.hours is the real data now.
  • Attendance unchangedunitIds still tags observed units onto coveredUnit*.

Success criteria (observable behavior that proves this works): - PATCH /curricula/:id authors a main subject with its own hours cells + nested units[] that carry only criteria; GET returns the main subject with real hours (no effectiveHours) and units with hours: []. A unit submitting hours/levelId/roomIds/gradingScaleId/ periodDuration400 CURRICULUM_UNIT_HAS_CELLS. A cell on the main subject is accepted (the old CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS is gone). - "Offered in grade G" for a main subject = its own cell has positive hours in G (not ∪ units). - POST /subject-groups still allowed on leaf or main subject, rejected on a unit; the container SG schedules against the main subject's own cell (own period duration / window) — no Σ synthesis. - A grade on a container SG requires a unit id, resolves the subject-mark scale against the main subject's (subject, grade) cell, criteria per criteriaLevel, stamps curriculumSubjectId = unit + parentSubject* = container. Leaf SG grade unchanged. - Timetable HOUR_BUDGET / duration for a container SG read the main subject's own cell directly (the container is no longer a null-own-cell special case).

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - Re-introducing per-unit hours / separately-scheduled units — the dropped UNIT granularity stays dropped; the main subject is the single schedulable unit. - Computed container-level mark, report cards, preset authoring of units, >2 nesting — still fenced (unchanged from 2026-07-14). - Coverage reports over coveredUnit* — still deferred to the report-card work. - A new schema migration / data backfill — explicitly out (no data exists; columns already present).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
2026-07-14-subject-units-design.md (the baseline this inverts) The whole scaffold: parentSubjectId self-relation, criteriaLevel, per-unit grades, coveredUnit* attendance, units-invisible-to-selection/coverage. All structural decisions carry; only the hours-owner flips. Its central premise — cell-less container, hours on units — is exactly what we reverse. Every "container = Σ / ∪" projection it introduced (effectiveHours, container-offered predicate, timetable Σ, scale-from-unit) is removed, not reused.
curriculum.validation.ts validateSubjectList (~234) The leaf-vs-container branch + criteriaLevel placement check + hasCriteria helper stay. The branch inverts: today it rejects container cells and validates unit cells; after, it validates container cells (as a leaf) and rejects unit cells/attrs.
curriculum.service.ts buildGridBody mappers (~129) mapLeaf is reused verbatim for the main subject row (it already projects real cells/scale/level/rooms). mapSubject/mapUnit shells stay. computeEffectiveHours (Σ) is deleted; mapUnit collapses from "full leaf projection" to "criteria-only" (hours: [], no scale/level/rooms). The container no longer needs effectiveHours.
grades.scale-context.ts loadScaleContext (split scale-subject vs criteria-subject) The split signature stays — it already separates the scale owner from the criteria owner. The roles swap: today scaleSubjectId = unit (has cell), criteriaSubjectId = container|unit. After: scaleSubjectId = container (has cell), criteriaSubjectId = container|unit. The "cell-less criteria subject" branch now fires for a UNIT criteria owner instead of a container — same code path, mirror inputs.
timetables.queries.ts findSubjectGradeCells (~1505) + subject-groups.service.ts anchorOffersGrade (~1057) The plain own-cell lookup (resolveWeeklyHours, cell-presence test) — which already exists for leaves. The container-Σ synthesis block (containerHours map, ~1540-1567) and the anchorOffersGrade ∪-over-children rule are deleted — a main subject now has its own cell, so the leaf path covers it.

On-axis / off-axis (ch16 §4–§5). Firmly on-axis and net-negative in complexity. No new module, entity, scope, action, cross-cutting concern, or schema column. The single off-axis invention the 2026-07-14 iteration called out (the read-time effectiveHours Σ projection + the shared container-offered predicate at ~5 sites) is removed by this iteration — the main subject rides the existing leaf cell machinery end-to-end. The only remaining "shape-aware" code is the grades per-unit branch (resolveGradingSubject) and the attendance unit tag, both untouched in structure (grades just flips which id is the scale owner).


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes unchanged — units/containers are CurriculumSubject rows under a Curriculum; no new columns standard
Academic-year scope yes unchanged — grid/SG/grades/attendance already AY-scoped standard
RBAC entity key existing CURRICULA, SUBJECT_GROUPS, GRADE_ENTRIES, ATTENDANCE, 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 only: curriculum.validation.ts (flip cell-owner check + new unit-attrs guard), curriculum.service.ts buildGridBody (main subject maps own cells; unit → criteria-only; delete computeEffectiveHours/effectiveHours), curriculum-structure-sync.ts (units shed hours/scale/level/rooms; container keeps them), grades.service.ts resolveGradingSubject (scale owner = container), subject-groups.service.ts (anchorOffersGrade → plain own-cell; resolveWeeklyHours reads container own cell), timetables.queries.ts findSubjectGradeCell(s) (delete Σ synthesis), student-assignment resolveWeeklyHours (own cell). No new module. no new domain
queries.ts shape trim selects The grid curriculumGridInclude still nests childSubjects + excludes units top-level (parentSubjectId: null), but the nested childSubjects select can drop hours/rooms/level/scale (units carry none) and keep criteria + criteriaGradingScale. SG anchor select keeps parentSubjectId (unit-reject) + the container's own hours (offered test); the timetable container-unit-hours OR-branch is removed. selects shrink to the fields each shape actually has
Error codes +1 / −1 Add CURRICULUM_UNIT_HAS_CELLS (400 — a unit submitted hours or a scheduling attr: levelId/roomIds/gradingScaleId/periodDuration); remove CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS (a main subject now legitimately has cells). Keep CURRICULUM_CRITERIA_LEVEL_MISMATCH, SUBJECT_GROUP_ANCHOR_IS_UNIT, GRADE_UNIT_REQUIRED, GRADE_UNIT_NOT_IN_SUBJECT, ATTENDANCE_UNIT_NOT_IN_SUBJECT. New code needs the full i18n round: ErrorCode enum + ERROR_TEXT_PARAMS/ERROR_INTERNAL_PARAMS classification + ERROR_MESSAGES{en_US,it_IT} (build-enforced by error-messages.drift.spec.ts) + error-examples.ts. Removing a code: delete its enum member + catalog + examples rows + any spec referencing it. mirror of the removed guard
DTO conventions scope sub-DTOs CurriculumSubjectInputDto keeps units?/criteriaLevel?; the unit subset DTO (CurriculumUnitInputDto) is tightened to name + id? + code? + criteria? + criteriaGradingScaleId? only (no hours/levelId/roomIds/gradingScaleId/periodDuration). Main-subject response CurriculumSubjectResponseDto: drop effectiveHours; hours on a container is its own cells; units[] project hours: [] + criteria only. RecordGradeDto, TakeAttendanceDto, gradebook DTOs unchanged in shape. Mirror the DTO trim into GridSubjectInput (bulk-sync.interfaces.ts). contract follows the inverted ownership
File-backed sub-resources n/a not a file surface
Custom fields no grid rows are not custom-field-bearing
Profile completeness n/a not a person entity

The shape-aware consumers, after the inversion

  • Grid author/read — the main subject is a leaf-with-cells + units[] (criteria-only children). Validation validates the main subject's cells and rejects unit cells/attrs; the read projects the main subject's own cells (no effectiveHours).
  • SG create — reject unit anchor (unchanged); the offered-in-grade test uses the main subject's own cell (anchorOffersGrade drops the ∪ branch); scheduling minutes/duration come from the main subject's own cell via the existing 4-level resolveEffectivePeriodDuration.
  • Grade write — container SG: require unit, stamp curriculumSubjectId = unit + parentSubject* = container, scaleSubjectId = container (the cell owner), criteriaSubjectId = container|unit per criteriaLevel. loadScaleContext signature is unchanged; the cell-less-criteria-subject branch now applies to a UNIT criteria owner.
  • Timetable diagnostics/budget — a container SG reads the main subject's own cell like any leaf; delete the Σ synthesis in findSubjectGradeCell(s).

4. Data model plan

Schema deltas

  • None. Every field the inverted model needs already exists on CurriculumSubject / CurriculumSubjectHours (hours cells, gradingScaleId, levelId, rooms, periodDuration, criteria, criteriaGradingScaleId) and on the grade/attendance snapshot columns from 20260716092919_subject_units. The leaf / container / unit distinction stays a service-enforced shape over shared columns, not a column-level distinction — exactly as "container has no cells" was service-enforced before. criteriaLevel (container-only) and parentSubjectId are unchanged.
  • Optional (NOT taken in v1): tightening the raw CHECK curriculum_subjects_unit_shape_chk to also forbid a unit's level_id / grading_scale_id / period_duration would give a DB belt, but (a) hours live in a separate table and can't be CHECK'd same-row, so the guard would be partial, and (b) the removed CONTAINER_HAS_CELLS guard was service-only for the symmetric case. Keep it service-only for consistency; revisit only if we later want a hard DB floor.

Migration shape

  • No migration. Additive/destructive/renaming: none — no DDL. The committed 20260716092919_subject_units migration stays as-is (its columns are all still used).
  • Data backfill: none — no environment holds container/unit rows (confirmed 2026-07-20); the seed authors none; no shipped FE creates any. FE §5 (zero unit hours / populate container hours) is moot.
  • Hazards from chapter 12 checklist: n/a — no migration.sql is generated, so there is nothing to audit. No new tenant-bearing table ⇒ no rls-coverage.ts / tenanted-models.ts delta.

Indexes and uniqueness

  • Unchanged. @@index([parentSubjectId]) and the curriculum_subjects_container_name_key (COALESCE-parent) unique index are shape-agnostic and remain correct.

5. API surface

No new endpoints. Deltas on existing surfaces (FE-breaking — coordinated BE→FE rollout; no back-compat shims; no deployed consumer of the old shape):

Surface Delta
PATCH /curricula/:id (grid family-sync) A main subject (isContainer) sends its own hours cells (+ gradingScaleId/levelId/roomIds/periodDuration/window) like a leaf — the old CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS rejection is removed. A unit sends only name/id?/code?/criteria (+ criteriaGradingScaleId iff criteriaLevel = UNIT); submitting hours/levelId/roomIds/gradingScaleId/periodDuration on a unit → 400 CURRICULUM_UNIT_HAS_CELLS. criteriaLevel unchanged; wrong-level criteria → CURRICULUM_CRITERIA_LEVEL_MISMATCH.
GET /curricula/:id (+ list) Main subject hours = its own cells (not []). Unit hours = []; units carry criteria. effectiveHours removed from the response entirely. isContainer/criteriaLevel/units[] presence + nesting unchanged (units only inside units[], never top-level). List carries the same fields as detail (already true).
POST /subject-groups, eligible-for-subject-group, boards "Offered in grade G" for a main subject = its own cell in G (not ∪ units). Anchor on a unit still → 409 SUBJECT_GROUP_ANCHOR_IS_UNIT. No request-shape change.
POST /grades (RecordGradeDto) Unchanged shape (curriculumSubjectId? = the unit, required on a container SG). Behaviour: the subject-mark scale resolves from the main subject's (subject, grade) cell; criteria per criteriaLevel. Response GradeEntry still carries parentSubject*.
GET /grades/subject-group/:id (gradebook) Container SG still returns per-unit units[]; criteria columns follow criteriaLevel (SUBJECT → shared container criteria/scale across units; UNIT → per-unit). The subject-mark scale is now the container's (shared). Leaf SG unchanged.
Attendance take / grid / cell UnchangedunitIds? still tags coveredUnit*.
Timetable place / diagnostics / publish A container SG budgets its own cell's minutes with its own period duration; HOUR_BUDGET reads the main subject's own cell (no Σ synthesis, no null-own-cell short-circuit).
Student assignment block A main subject appears once with its own weekly hours (own cell); units never appear as SG rows.

Swagger considerations

  • Grid DTO prose flips: "a main subject owns its own hours cells and may hold units[]; a unit carries only criteria." CURRICULUM_UNIT_HAS_CELLS gets an error-examples.ts row; CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS example row is removed. effectiveHours removed from the response DTO/JSDoc. RecordGradeDto.curriculumSubjectId / TakeAttendanceDto.unitIds prose unchanged.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none — main-subject cells ride curricula.configuration (already granted, as leaf cells always did); grade/attendance fields unchanged
Role grants (roles.ts) none
*_SCOPES runtime constant none

Entirely on existing grants. No record-level policy change.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
(removed) "container is taught in a grade with no cell of its own (Σ children)" reverted — a main subject is taught ⟺ it has its own cell, identical to a leaf product: the schedulable unit is the main subject the effectiveHours read projection + the shared container-offered predicate + the timetable Σ are deleted; complexity goes down
GradeEntry.curriculumSubjectId derived from the SG on a container SG it still comes from the request body (the chosen unit), parentSubjectId = container — unchanged from 2026-07-14 one class (Math) holds marks for several units grade write branches on SG subject shape; report rollup = parentSubjectId ?? curriculumSubjectId
loadScaleContext scale owner vs criteria owner scale owner flips from the unit to the main subject (the cell owner); criteria owner still container/unit per criteriaLevel after inversion the unit has no cell — only the main subject can anchor the subject-mark scale the "cell-less criteria subject" branch now fires for a UNIT criteria owner instead of a container; same code, mirror inputs
a CurriculumSubject shape carries cells a unit carries no cells and no scheduling attrs (hours/level/rooms/scale/periodDuration) a unit is purely a grading facet of the main subject one guard (CURRICULUM_UNIT_HAS_CELLS) reading the unit's submitted fields; the symmetric guard for containers is removed
unit response projection = full leaf unit projects hours: [] + criteria only units own no hours/scale/level/rooms mapUnit collapses to a criteria-only shape; computeEffectiveHours deleted

8. Pushback log

FE spec / chat says Conflicts with Proposed instead Status
FE §5: migrate existing containers — zero unit hours, populate container.hours (Σ or blank) no container/unit data exists anywhere (unshipped feature, seed authors none) no data migration — §5 is moot; confirmed with Fabio 2026-07-20 Resolved
FE §3: keep effectiveHours valued = container.hours one cycle for FE retro-compat no deployed FE consumes the old shape (coordinated BE→FE rollout) drop effectiveHours nowcontainer.hours is the real field Resolved (Fabio 2026-07-20)
FE §2/§3: a unit sends "only name, id?, criteria (+criteriaGradingScaleId)" a unit is still a CurriculumSubject row; code is free-text identity a report card may want and costs nothing units keep code in addition to name/id/criteria; shed hours/level/rooms/scale/periodDuration Resolved (Fabio 2026-07-20 — "FE file is a guide")
FE §4: CURRICULUM_UNIT_HAS_CELLS "optional (recommended)" silent-ignore hides FE bugs; the symmetric container guard was a hard 400 add it as a hard 400, covering unit hours and scheduling attrs Resolved (Fabio 2026-07-20)

9. Deferrals

  • Separately-scheduled / separately-taught units (the dropped UNIT granularity) — still deferred; follow-up: memory project_subject_units.
  • DB belt for unit shape (CHECK forbidding unit level/scale/periodDuration) — not taken; hours can't be CHECK'd same-row so it'd be partial, and the symmetric container guard was service-only. Revisit if a hard DB floor is wanted.
  • Coverage reports over coveredUnit*, coveredUnit* / unit-choice in the audit trail, computed container-level mark, report cards, preset authoring of units, >2 nesting — all unchanged from 2026-07-14 (still fenced).

10. Open questions

All resolved (chat 2026-07-20):

  • No environment has container/unit data → no data migration, §5 moot.
  • Drop effectiveHours now (no deployed consumer).
  • Units keep code; shed hours/level/rooms/scale/periodDuration.
  • Add CURRICULUM_UNIT_HAS_CELLS (hard 400) covering hours + scheduling attrs; remove CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS.

11. Verification plan

  • Unit specs:
  • curriculum.validation.spec.ts — main subject with cells is accepted (drop the old container-has-cells rejection assertions); a unit submitting hours/level/rooms/scale/periodDuration → CURRICULUM_UNIT_HAS_CELLS; criteriaLevel default SUBJECT + wrong-level criteria still → CURRICULUM_CRITERIA_LEVEL_MISMATCH.
  • curriculum-structure-sync.spec.ts — authoring a main subject with own cells + criteria-only units (create/rename/reorder/delete a unit); units carry no hours/scale/level/rooms after sync; delete-cascade of units on main-subject delete (unchanged).
  • curriculum.service read mapper spec (or the curriculum e2e read) — main subject hours = own cells, units[].hours = [], no effectiveHours key in the response.
  • grades.service.spec.ts + grades.scale-context.spec.ts — container SG resolves the subject-mark scale from the main subject's cell; criteria per criteriaLevel (SUBJECT → container, UNIT → unit); parentSubject* stamped; leaf path byte-identical; gradebook units[] columns.
  • subject-groups.service.spec.ts — SG offered test for a main subject uses its own cell (drop the ∪-children assertions); unit-anchor still rejected; container SG weekly hours = own cell.
  • timetables.queries.spec.ts — a container SG's findSubjectGradeCell(s) returns the main subject's own cell (delete the Σ-synthesis assertions); HOUR_BUDGET reads it directly.
  • selection-consistency.spec.ts — unchanged (units still invisible; container choosable with its own cells).
  • attendance.service.spec.ts — unchanged (unitIdscoveredUnit*).
  • E2E:
  • Grid round-trip: author Math with its own cells (e.g. 5h in a grade) + units Algebra/Geometry carrying only criteria; GET returns Math's own hours, units hours: [], no effectiveHours; a unit sending hours → 400.
  • SG create allowed on Math (main subject), rejected on a unit; place + publish Math's slot from its own cell.
  • Grade on the Math SG requires a unit, resolves Math's scale, stamps parentSubject* = Math; gradebook returns per-unit columns.
  • Attendance take on the Math SG with unitIds:[algebra] → records carry coveredUnit* (regression; unchanged).
  • Manual verification: seed a tenant, author Math-with-units in the grid editor, walk SG create → timetable place + publish → grade entry (pick unit) → attendance tag, confirming hours come from the main subject and the mark stamps the main subject.

Documentation deltas (part of the work)

  • docs/14-homerooms-subject-groups.md §1.4 — flip "container = Σ units" to "main subject owns its cells; units carry only criteria".
  • docs/21-grades.md §3.1 — subject-mark scale resolves from the main subject; criteria per level.
  • docs/19-attendance.md — no change (verify wording).
  • docs/REFERENCE.md — curriculum module row + glossary ("Unit", "main subject") updated to the inverted model; remove effectiveHours / CURRICULUM_SUBJECT_CONTAINER_HAS_CELLS mentions.
  • docs/fe-guides/2026-07-16-subject-units-BREAKING.md — supersede with a new 2026-07-20-subject-units-hours-inversion-BREAKING.md FE guide (hours moved to the main subject; units criteria-only; effectiveHours removed; CURRICULUM_UNIT_HAS_CELLS).
  • Memory project_subject_units updated to the inverted model after landing.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-20
  • Chat reference: brainstormed 2026-07-20 from the FE BE-spec ("Subject units: invertire il modello ore"); four decisions locked via AskUserQuestion (no data migration, drop effectiveHours, units keep code, CURRICULUM_UNIT_HAS_CELLS as a hard 400); signed off in chat 2026-07-20.

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.