Skip to content

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

  1. 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.
  2. 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.
  3. 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/Section rows 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.
  4. 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.
  5. 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

  1. No unification of the two academic models beyond the cohort. Option D re-parents Section onto AcademicLevel so a cohort has one shape; it does not retire Class, migrate K-12 enrolment reads off students.section_id, or touch promotion. That broader unification stays with k12-enrollment-history-unification.
  2. 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.
  3. No migration of the demo workaround. The fabricated Class rows in the Sapthagiri University staging tenant get deleted, not migrated.
  4. No timetable auto-generation. Filling the grid intelligently is a separate problem; this is about being able to fill it at all.
  5. Attendance is not fixed in phase 1, even though it has the same defect (face_attendance_records.section_id is 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/entries and /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 / TimetableEntryCreate accept either key; a single shared resolver turns whichever was supplied into a roster (students.section_id for K-12, student_levels for higher ed). Cost: uniqueness constraints currently keyed on section_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 Section to AcademicLevel. A section/batch stops being a child of Class and 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 into AcademicLevel by ensure_k12_catalog() (shipped with the K-12 enrolment-history unification). Scheduling keeps keying on section_id and 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 through classes, and seat allocation selects on students.section_id. The university had 28 academic levels, 0 classes, 0 sections, and every student's section_id was NULL. Worked around for the demo with 7 fabricated Class/Section cohorts — to be deleted, not migrated.