ERP timetable & exams for higher ed: scheduling by semester, not class¶
ERP timetable and exam operations currently address a cohort only through
the K-12 Class → Section pair. Degree colleges, engineering colleges and
universities never create those rows — the college onboarding path builds
Program → Branch → AcademicLevel and records placement in student_levels —
so for a higher-ed institution both modules dead-end on an empty picker. This
doc covers teaching the two scheduling modules to address a higher-ed cohort
directly, so an admin schedules against Branch + Semester and never sees the
words "Class" or "Section".
Status: phases 1-3 in review — kwiloai_webapp#1433. Gate passed 2026-07-21.
Related: pu-college-academic-setup.md (the hierarchical model), attendance.md (the other module that reads cohorts), erp-fee-payments-per-institution-razorpay.md (the ERP module conventions).
Why this exists¶
- Who asked: Kantharaju, 2026-07-21, while seeding a full Sapthagiri University tenant on staging. Found internally, not reported by a customer — but the underlying requirement is validated by a real institution that has asked for semester-based timetable and exams.
- User pain: "It is not possible to add a timetable without using class and section, and that is for K-12." A university admin opens Timetable or Exams, and the first field is a Class dropdown that is empty and that nothing in the product can populate. There is no error and no guidance — the flow simply ends. The same admin has already built 2 programs, 4 branches and 28 semesters, none of which the scheduler can see.
- Cost of not doing it: it blocks university deals. A university
evaluating the ERP finds 2 of its 9 modules unusable, and the cost lands the
moment they evaluate, not later. The only workaround — fabricating
Class/Sectionrows per cohort — demos capability we do not have and leaves two representations of "semester" that never sync, so promoting a student silently corrupts either the exam schedule or the curriculum. - Validated or guess: validated by a real institution for the requirement. The specific defect was found internally, so we have not yet watched a real university admin hit the empty dropdown.
- How we will know it worked: a university admin builds a complete semester
timetable and exam schedule choosing Program → Branch → Semester, never
encountering "Class" or "Section", and the tenant has zero rows in
classes. Sean Ellis counterfactual: remove it a week after shipping and the admin says "I can't schedule my semester exams anymore" — specific, not vague.
Personas¶
Dr. Vasundhara, Controller of Examinations at a 4,000-student university. Owns the semester-end exam calendar for 7 cohorts across 2 programmes. She thinks in "B.Tech CSE Semester 3", never in "Class 3, Section A". Today she opens Exam Operations, finds an empty Class dropdown, and calls support.
Prakash, timetable in-charge. Publishes the weekly grid before each semester starts. His constraint is faculty who teach the same subject to several branches — one Maths lecturer serving CSE, ECE and ME Semester 1 simultaneously. He needs the system to refuse a double-booking, which it already does correctly once it can see the cohort at all.
Arjun, B.Tech CSE Semester 1 student. Wants a hall ticket with room and seat. He is downstream of everything above: no cohort means no seat allocation, which means no hall ticket.
ICP exclusions¶
- K-12 schools keep their existing flows and vocabulary. Under option D a section gains an academic-level parent, but no K-12 screen, endpoint or behaviour changes.
- PU colleges are deliberately out of scope for the first phase. PU uses the hierarchical model but its own vocabulary (combinations, Class 11/12), and folding it in would force a vocabulary decision we have not made. See pu-college-academic-setup.md.
- Corporate and training-provider org units are not scheduled through these modules today and stay out.
Outcomes¶
- A higher-ed admin schedules a full week of teaching against Branch + Semester without any Class/Section row existing in the tenant.
- A higher-ed admin schedules an exam session, allocates rooms and issues hall tickets from the same cohort selection.
- Faculty see a correct weekly schedule, and the existing clash rules (section slot taken, teacher clash, room clash) keep working unchanged.
- One representation of "semester" per tenant. Promoting a student updates one place.
Non-goals¶
- No unification of the two academic models beyond the cohort. Option D
re-parents
SectionontoAcademicLevelso a cohort has one shape; it does not retireClass, migrate K-12 enrolment reads offstudents.section_id, or touch promotion. That broader unification stays withk12-enrollment-history-unification. - No marks or grading changes. Exam ops stays a logistics module —
scheduling, seating, hall tickets. Grading remains learning-owned via
physical_exams, exactly as today. - No migration of the demo workaround. The fabricated Class rows in the Sapthagiri University staging tenant get deleted, not migrated.
- No timetable auto-generation. Filling the grid intelligently is a separate problem; this is about being able to fill it at all.
- Attendance is not fixed in phase 1, even though it has the same defect
(
face_attendance_records.section_idis NOT NULL). Phase 1 extracts the shared cohort resolver that phase 2 will use to fix it.
Metrics¶
- North star: number of higher-ed org units with at least one published
timetable and one exam session, with zero rows in
classes. - Guardrail: zero regressions in K-12 scheduling — existing school tenants
keep the same clash behaviour and the same picker, measured by the existing
timetable and exam-ops test suites staying green plus no rise in 4xx/5xx on
/erp/timetable/entriesand/erp/exam-ops/exams.
Risks & mitigations¶
| Risk | Mitigation |
|---|---|
| Two cohort shapes double the branching in scheduling code, inviting bugs | Resolve the cohort to one internal identity at the service boundary; keep clash logic cohort-shape-agnostic |
| The wire contract is shared with a frontend built in parallel | section_id cannot simply be renamed — additive field plus a migration window, not a breaking change |
Seat allocation reads students.section_id, NULL for all higher-ed students |
Allocation must source the cohort roster from student_levels when the org unit is higher ed |
| We have not watched a real university admin hit this | Before phase 2, put the phase-1 build in front of the validating institution |
| Scope creep into full model unification | Non-goal 1 is explicit; attendance deferred to a later phase |
Success signals¶
- A university admin completes timetable setup without contacting support, and without the words "Class" or "Section" appearing anywhere in their flow.
- A staging university tenant carries a working timetable, exam session and hall
tickets with
SELECT count(*) FROM classes WHERE org_unit_id = …returning 0. - Sean Ellis counterfactual: removed a week after shipping, Dr. Vasundhara complains with specifics — "I can't schedule my semester exams anymore" — rather than not noticing.
Mental model¶
A cohort is "the group of students who sit together for this thing". Kwilo has two shapes for it and the schedulers only speak one:
| K-12 | Higher ed | |
|---|---|---|
| Cohort | Class → Section |
Program → Branch → AcademicLevel |
| Student placement | students.section_id |
student_levels rows |
| Built by | K-12 onboarding | College onboarding wizard |
| Timetable / exam-ops can address it | yes | no |
The fix is to make the schedulers bilingual: accept either shape, resolve it to one roster internally, and keep every clash rule downstream unchanged.
Design decisions¶
Both academic models already share AcademicLevel. Verified on staging
2026-07-21: Sapthagiri School (K-12) has 5 academic_levels mirroring its
classes, created automatically by ensure_k12_catalog(), alongside its
Class/Section rows. Higher ed has them natively as semesters. The models
have already converged one level up — only the section/batch below it is still
K-12-only, and for higher ed it exists merely as a StudentLevel.section
string.
Attendance is a partial precedent, not a solution.
erp.face_attendance_records already carries a nullable academic_level_id
(plus subject_id) for per-subject college attendance — but section_id on
that table is nullable=False. So attendance also cannot run in a tenant with
zero sections. Three modules are affected, not two. Any fix should be shared
rather than repeated three times.
Options considered
- A — Nullable
section_id+academic_level_id, exactly one set. Additive and closest to the shape attendance already uses.ExamCreate/TimetableEntryCreateaccept either key; a single shared resolver turns whichever was supplied into a roster (students.section_idfor K-12,student_levelsfor higher ed). Cost: uniqueness constraints currently keyed onsection_id(uq_erp_entry_section_slot,uq_erp_exam_session_section_subject) need a coalesced or paired form. - B — A first-class cohort abstraction both models resolve into. Cleanest long-term and would serve attendance, fees and promotion too, but it is a much larger change and touches shipped wire contracts.
-
C — Auto-materialise Sections for higher-ed cohorts during college onboarding, i.e. automate the demo workaround. Cheapest by far, and rejected: it enshrines two non-syncing representations of "semester", which directly contradicts the outcome this feature exists to deliver.
-
D — Re-parent
SectiontoAcademicLevel. A section/batch stops being a child ofClassand becomes a child of an academic level, which both models already have: higher ed builds them as semesters, and K-12 classes are already mirrored intoAcademicLevelbyensure_k12_catalog()(shipped with the K-12 enrolment-history unification). Scheduling keeps keying onsection_idand does not change at all.
Recommended: D. The blast radii are not close:
| Option A | Option D | |
|---|---|---|
section_id references in ERP that need cohort branching |
317, across 10 files | 0 — the key is unchanged |
Section → Class org-scoping joins to widen |
0 | 11 |
| Wire contract | new field, migration window | unchanged |
| Permanent branching in 3 modules | yes | no |
| Batch-level scheduling for higher ed (CSE Sem 3 A vs B) | no — AcademicLevel is semester-granular |
yes |
| Relationship to the K-12 unification already in flight | temporary scaffolding, later removed | converges with it |
Option A looks cheaper because it is additive, but it spreads a permanent
two-shape conditional through every cohort read in timetable, exam-ops and
attendance — and it still cannot schedule a batch within a semester, because
AcademicLevel stops at the semester. Option D moves one foreign key and
widens 11 scoping joins, after which every cohort in the product is the same
shape and the schedulers get simpler rather than more complex.
The cost of D is honest and bounded: sections.class_id becomes nullable with a
new nullable academic_level_id and a "exactly one parent" constraint, and the
11 places that reach the org unit via Section → Class must accept either
parent. StudentLevel.section is a free-text String(20) today; those strings
become real Section rows during the same migration.
Wire-contract constraint. section_id is consumed by a frontend built in
parallel against the current shape, so it cannot be renamed or made
required-either-or in a breaking way. Additive field, both accepted during a
migration window.
User flows¶
To be filled during design.
Rollout plan¶
Phase 1 — the parent (done, feat/section-academic-level-parent).
sections.academic_level_id added nullable, class_id relaxed to nullable,
CHECK requires at least one parent, existing K-12 sections backfilled onto their
mirrored level. section_in_org_unit() scopes on either parent and the two ERP
validators gating exam and timetable creation use it. Backward compatible: the
previous backend image runs unchanged against the new schema, so the migration
can land before the code that needs it. 1761 backend tests green; migration
verified up, down and up again against a real Postgres, and the backfill proved
against a constructed mirror.
Phase 2 — the writes (done). POST/GET /org-units/levels/{id}/sections
create and list a batch under a semester; GET /org-units/sections (the picker
source) scopes on either parent and returns cohort_name; seat allocation's
roster reads student_levels for higher-ed sections, which is what made hall
tickets come out empty. Deferred to a later phase: college onboarding creating
batches automatically, the analytics/homework Section → Class joins (correct
today — those surfaces are K-12-only), and face_attendance_records.section_id
(NOT NULL, needs its own migration).
Phase 3 — the surface (done). The shared SectionPicker behind the
timetable and exam dialogs keeps its cascade shape but chooses its source by
institution type: Semester → Batch for higher ed, Class → Section unchanged for
K-12. Both resolve to a section id, so no consumer changed. useSemesters /
useLevelSections join the shared academic-structure hooks.
Phase 4 — retire the workaround (partially done). Sapthagiri University's
28 exam papers, 8 cohort batches and 240 timetable entries now hang off
semester-parented sections, and seat allocation resolved every roster through
student_levels — "28 allocated, 0 with no students" — which is phase 2 proven
end to end on real data.
The 7 fabricated Class rows could not be deleted. DELETE of any
public.sections row returns 500 with permission denied for schema erp,
reproduced on a brand-new section with zero exams, zero timetable entries and
zero attendance rows — so it is the FK constraints from erp.exams,
erp.timetable_entries and erp.face_attendance_records themselves, not the
data. This is pre-existing and unrelated to this work (00072 only added a
public.academic_levels FK), and it is user-facing: a school admin deleting a
section hits the same 500. The fix is a database GRANT for the role the backend
connects as, not an application change. Until then the north-star metric
(zero rows in classes) stays unmet for an infrastructure reason.
Changelog¶
- 2026-07-21 — Gate passed; doc scaffolded. Gap found while seeding the
Sapthagiri University staging tenant: exam-ops and timetable both require
section_id, sections scope throughclasses, and seat allocation selects onstudents.section_id. The university had 28 academic levels, 0 classes, 0 sections, and every student'ssection_idwas NULL. Worked around for the demo with 7 fabricated Class/Section cohorts — to be deleted, not migrated.