K-12 Enrollment History: unify onto AcademicLevel + StudentLevel¶
Status: draft · Owner: kantharaju · Source: founder-initiated, validated in school conversations · Roles: unit_manager, org_admin (primary); instructor, learner, guardian (read surfaces)
K-12 schools are the last institution type on the legacy Class model, and their year-end promotion overwrites student.class_id / section_id in place. Every promotion permanently destroys the record of which class, section, and roll number a student had that year. This feature moves K-12 onto the unified AcademicLevel (catalog) + StudentLevel (enrollment-with-history) model that colleges already use and PU colleges adopted in 2026-07 — so a student's academic membership history survives promotion, and year-wise ERP reporting (fees, attendance, exams) attributes to the right cohort.
Why this exists¶
Who asked. Founder-initiated (2026-07-19), spotted while walking the ERP university flows: the K-12 promotion endpoint (/promotion/execute) does student.class_id = target — an in-place overwrite with no history row. Flagged as self-initiated, but the underlying need is validated (below).
The pain. A school admin runs year-end promotion in April. The moment it completes, nobody can answer "what section and roll number did this student have in 9th standard?" — the system's only copy of that fact was just overwritten. Raw records survive (attendance rows carry their own dates and section ids, fee payments are immutable), but the membership is gone: report-card generation, transfer certificates, and any "by class-year" report must reconstruct membership by inference from scattered dates, or can't at all. Reports that group by the student's current class_id silently misattribute last year's data to this year's class.
Cost of not doing it. Two compounding costs. (1) Irreversible loss on a clock: every K-12 school on the platform loses a year of membership history at every April promotion — it cannot be backfilled after the fact, so each cycle we delay destroys another year of data for every school. (2) Blocks K-12 ERP: fee structures target academic levels, exam ops and attendance reporting are year/cohort-scoped — none of it can be correct for K-12 without enrollment history. The ERP program's K-12 story is capped until this lands.
Validated or guess. Validated — school conversations confirmed admins need year-over-year student records, and the Indian K-12 operating context makes it structural: transfer certificates, cumulative record cards, and board registration all require multi-year history. Schools currently keep this in paper registers/Excel precisely because software loses it.
How we'll know it worked. After promotion, an admin opens any student and sees prior years (class, section, roll number, status) — and year-filtered attendance/fee/marks reports attribute to the correct historical cohort. Sean Ellis counterfactual: remove the history view a week after shipping and admins preparing TCs and report cards would complain immediately and specifically — those documents cannot be produced without it.
Personas¶
- Mrs. Shobha, admin of a 900-student CBSE school. Runs promotion every April. Today she exports class rosters to Excel first "just in case" because she has learned the system forgets. Needs: promote a class in bulk, then still answer parents' and boards' questions about past years.
- Mr. Ravi, class teacher of 9-B. Needs last year's 8th-standard attendance percentage during parent meetings — attributed to 8-B as it was, not to the student's current class.
ICP exclusions¶
- Higher-ed institutions (degree/engineering/university) — already on the unified model; nothing changes for them.
- PU colleges — already migrated (see
pu-college-academic-setup.md); no sections in their model. - Corporate/training org types — no classes, out of scope.
Mental model (target state)¶
K-12 School (org_unit_type = k12_school) class-based
Program "School" (program_type=school, grades 1-10)
└── Branch "General" (single implicit branch)
└── AcademicLevel: Class 1 … Class 10 ← catalog slot (replaces Class)
└── Section A / B / C ← re-parented from Class
Student ──< StudentLevel (level, academic_year, section, roll_number, status)
active → completed at promotion; new row per year ← the history
AcademicLevel= the slot in the catalog.StudentLevel= one student's membership in a slot for one year. Both are needed;Classandstudent.class_id/section_idare what retire.- Promotion becomes: mark current
StudentLevelrowcompleted, insert next year's rowactive. Never mutate identity or prior rows. student.class_idremains as a denormalized "current placement" pointer during migration, maintained by the promotion flow, until all readers move over (then dropped).
Outcomes¶
- An admin can answer any "which class/section/roll in year X?" question from the student page, forever.
- Year-end promotion is trusted: no pre-promotion Excel exports out of fear.
- K-12 ERP fee structures, exam ops, and attendance reports become year/cohort-correct, unlocking the ERP program for schools.
Non-goals¶
- No pre-cutover history reconstruction. History accrues from the backfill day forward; we do not attempt to infer past years from attendance dates.
- No report-card/TC generator in this feature. This lands the data foundation those documents need; the documents themselves are separate features.
- No change to higher-ed or PU flows. Their model is the destination, not a construction site.
- No section redesign. Sections re-parent under
AcademicLevelwith identical semantics (attendance, timetable, class-teacher FKs keep working); rethinking sections is out of scope.
Metrics¶
- North star: % of K-12 promotions after launch that produce complete
StudentLevelhistory rows (target: 100% — it's structural, not adoptive). - Guardrail: zero regressions in K-12 daily surfaces that read current placement (roster, attendance marking, timetable, guardian views) during the dual-read period — measured by error rates and support tickets on those routes.
Rollout plan (phased)¶
- Backfill + dual-write. Create
Program(school)/Branch/AcademicLevelcatalog per K-12 school mirroring itsClassrows; create anactiveStudentLevelfor every current student fromclass_id/section_id. Promotion writes both models (history row + legacy pointer). - Move reads. Rosters, attendance section resolution, timetable, guardian views, ERP fee targeting switch from
student.class_idjoins to active-StudentLevellookups, behind per-surface small PRs. - New promotion UX. Promotion UI shows "completing 2025-26 → starting 2026-27" with per-student section/roll assignment; history tab appears on the student page.
- Retire. Legacy
Class/ClassSubjectreads removed,student.class_id/section_iddropped,Classmodel deleted. Board field and subject mappings live onAcademicLevelby then.
Risks & mitigations¶
- Sections are load-bearing (attendance, timetable, class-teacher FK into
sections). Mitigation: re-parent the existingsectionsrows toAcademicLevel(keep the same PKs) instead of recreating, so FKs never break. - Many read sites key off
student.class_id. Mitigation: dual-write keeps the pointer correct until each reader migrates; a CI grep-guard inventories remaining readers per phase. - Board +
ClassSubjectmapping lives onClass. Mitigation:AcademicLevelalready carriesboardvianormalized_class_board(); subject mapping migrates to the level-scoped assignment used by PU/college. - Promotion is a bulk, once-a-year, high-stakes operation. Mitigation: promotion runs in one transaction per class with a dry-run preview; phase 1 ships behind the ERP-style feature flag so pilots go first.
Where it lives (today's code, for the implementer)¶
- Legacy:
apps/backend/src/models/academic.py(Class,Section,ClassSubject),src/api/v1/schools.py(/promotion/students,/promotion/execute— the in-place overwrite),student.class_id/section_id. - Target: same file —
AcademicLevel(docstring already declares it "the unified replacement for the Class model"),StudentLevel(per-year enrollment withstatus active/completed/withdrawn). - Precedent: PU college migration (
pu-college-academic-setup.md) proved class-based institutions on the unified model with zero backend change for structure creation.
Open questions¶
- Academic-year entity: derive from org settings or first-class
AcademicYearrows? (AnAcademicYearmodel exists insrc/models/academic.py— evaluate in phase 1.) - Mid-year transfers and section changes: new
StudentLevelrow vs. mutating the active row's section (leaning: mutate section within the year, new row only on level/year change). - Whether guardian-visible history ships in phase 3 or later.
Changelog¶
- 2026-07-19 — Draft created via Product Mindset Gate (founder-initiated, validated in school conversations; PU precedent noted).