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 whencriteriaLevel = 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 migration20260716092919_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 onCurriculumSubject— no 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)CurriculumSubjectHourscells withweeklyHours,gradingScaleId,periodDuration, teaching window — and may carrylevelId/roomIdslike any leaf. Its "offered in grade G" test is the plain cell-presence rule again. - Units carry only criteria. A unit keeps
name/id/codeand itscriteria(+criteriaGradingScaleIdonly whencriteriaLevel = 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). criteriaLevelsemantics 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 followcriteriaLevel(container forSUBJECT, unit forUNIT). effectiveHoursis dropped from the response —container.hoursis the real data now.- Attendance unchanged —
unitIdsstill tags observed units ontocoveredUnit*.
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/
periodDuration → 400 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 (noeffectiveHours). - SG create — reject unit anchor (unchanged); the offered-in-grade test uses the main subject's
own cell (
anchorOffersGradedrops the ∪ branch); scheduling minutes/duration come from the main subject's own cell via the existing 4-levelresolveEffectivePeriodDuration. - Grade write — container SG: require unit, stamp
curriculumSubjectId = unit+parentSubject* = container,scaleSubjectId = container(the cell owner),criteriaSubjectId = container|unitpercriteriaLevel.loadScaleContextsignature is unchanged; the cell-less-criteria-subject branch now applies to aUNITcriteria 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 from20260716092919_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) andparentSubjectIdare unchanged. - Optional (NOT taken in v1): tightening the raw CHECK
curriculum_subjects_unit_shape_chkto also forbid a unit'slevel_id/grading_scale_id/period_durationwould 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 removedCONTAINER_HAS_CELLSguard 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_unitsmigration 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.sqlis generated, so there is nothing to audit. No new tenant-bearing table ⇒ norls-coverage.ts/tenanted-models.tsdelta.
Indexes and uniqueness¶
- Unchanged.
@@index([parentSubjectId])and thecurriculum_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 |
Unchanged — unitIds? 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_CELLSgets anerror-examples.tsrow;CURRICULUM_SUBJECT_CONTAINER_HAS_CELLSexample row is removed.effectiveHoursremoved from the response DTO/JSDoc.RecordGradeDto.curriculumSubjectId/TakeAttendanceDto.unitIdsprose 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 now — container.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
effectiveHoursnow (no deployed consumer). - Units keep
code; shed hours/level/rooms/scale/periodDuration. - Add
CURRICULUM_UNIT_HAS_CELLS(hard 400) covering hours + scheduling attrs; removeCURRICULUM_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;criteriaLeveldefault 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.serviceread mapper spec (or the curriculum e2e read) — main subjecthours= own cells,units[].hours = [], noeffectiveHourskey 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 percriteriaLevel(SUBJECT → container, UNIT → unit);parentSubject*stamped; leaf path byte-identical; gradebookunits[]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'sfindSubjectGradeCell(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 (unitIds→coveredUnit*).- E2E:
- Grid round-trip: author Math with its own cells (e.g. 5h in a grade) + units Algebra/Geometry
carrying only criteria;
GETreturns Math's own hours, unitshours: [], noeffectiveHours; 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 carrycoveredUnit*(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; removeeffectiveHours/CURRICULUM_SUBJECT_CONTAINER_HAS_CELLSmentions.docs/fe-guides/2026-07-16-subject-units-BREAKING.md— supersede with a new2026-07-20-subject-units-hours-inversion-BREAKING.mdFE guide (hours moved to the main subject; units criteria-only;effectiveHoursremoved;CURRICULUM_UNIT_HAS_CELLS).- Memory
project_subject_unitsupdated 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.