Skoolsewa - School Management System Docs
Developer ResourcesAcademic year rollover

Academic Year Rollover Backend Documentation

Backend architecture, data model, services, queues, runtime rules, and operational behavior for Academic Year Rollover.

Academic Year Rollover Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringrollover.module.ts, each submodule's *-admin.module.ts, rollover-shared.module.ts, academic-year-worker.module.tsComposition of cycles, items, allocation, processing, activation, corrections, settings, insights, and workers
Controllerscycles/admin, items/admin, allocation/admin, processing/admin, activation/admin, corrections/admin, settings/admin, insights/admin controllersRoutes and permission decorators
Services*/core/*.service.ts, shared/*.service.ts, workers/*.service.tsBusiness logic, transactions, side effects
DTOs*/dto/*.tsRequest/response contracts
Schemapackages/db/src/schema/school/rollover.ts, academic-sessions.tsTables, enums, indexes, constraints
Migrations0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sqlAppend-only trigger, session lock trigger
Jobspackages/jobs/src/index.tsQueueName.ACADEMIC_YEAR, AcademicYearJob, payload shapes
Utilsapps/api/src/utils/rollover/*.util.tsSection/roll allocation algorithms, fingerprint
Sibling modulesacademic-sessions.service.ts, enrollments-core/class-enrollments.service.ts, enrollments-core/shared/enrollment-removal-guards.tsswitchCurrent, enrollment writer, removal guards

2. Backend Scope and Boundaries

Owns

  • The rollover cycle lifecycle: create, sync, review, cancel.
  • Per-pupil rollover items: status, exceptions, warnings, target class/roll, inclusion/exclusion.
  • Section allocation and roll allocation preview/apply.
  • Background processing: leasing, chunked per-item placement, retry, recovery.
  • Activation preflight and the trigger to make a target session current (delegated to academic-sessions.service.ts).
  • Finalization and source-session locking.
  • Post-processing corrections: move, realign, relink, withdraw a placement.
  • Rollover settings (strategy defaults, notification channels, capacity policy).
  • Read-only insights: event timeline, integrity checks, ID-card readiness, new-admission detection.
  • Placement and admin notifications specific to rollover outcomes.

Does Not Own

  • Promotion decisions themselves — read-only input from the promotions module. See Promotions backend.
  • The academic session entity and is_current switch mechanics — owned by academic-sessions; rollover calls into it, it does not duplicate it.
  • Enrollment writing and removal mechanics — owned by enrollments-core; rollover calls ClassEnrollmentsService.enroll rather than inserting rows itself.
  • Notification delivery transport (channels, retries, templates infrastructure) — owned by the notifications module; rollover only supplies template kinds and variables.
  • ID card generation itself — rollover only reports readiness counts.

Source of Truth

ConcernSource of TruthNotes
Cycle and item staterollover_cycles, rollover_items tablesNever cached; every read hits Postgres.
Which session is currentacademic_sessions.is_currentRollover flips it through switchCurrent, never directly.
Whether a session is lockedacademic_sessions.locked_atEnforced twice: application guard and a database trigger.
A pupil's actual placementstudent_class_enrollmentsRollover items reference it (enrollmentId) but do not duplicate its fields.
Whether processing is currently runningrollover_cycles.runState, leaseToken, leaseExpiresAtAuthoritative for the worker's lease protocol.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
RolloverModuleAggregateapps/api/src/modules/rollover/rollover.module.tsNoneNoneComposes leavesWires every rollover submodule and the worker module into the app
RolloverCyclesAdminModuleLeafcycles/rollover-cycles-admin.module.tsRolloverCyclesControllerRolloverCyclesService, RolloverCycleReviewService, RolloverCycleViewService, RolloverOverviewServiceCycle CRUD, sync/review/cancel, overviewCycle lifecycle and overview
RolloverItemsAdminModuleLeafitems/rollover-items-admin.module.tsRolloverItemsControllerRolloverItemsService, RolloverItemsQueryServiceItem mutation and queryPer-pupil item management
RolloverAllocationAdminModuleLeafallocation/rollover-allocation-admin.module.tsRolloverAllocationControllerRolloverSectionAllocationService, RolloverRollAllocationServiceAllocation preview/applySection and roll allocation
RolloverProcessingAdminModuleLeafprocessing/rollover-processing-admin.module.tsRolloverProcessingControllerRolloverProcessingServiceProcess trigger, progressEnqueue processing and report progress
RolloverActivationAdminModuleLeafactivation/rollover-activation-admin.module.tsRolloverActivationControllerRolloverActivationServicePreflight, activate, finalize, lockYear switch orchestration
RolloverCorrectionsAdminModuleLeafcorrections/rollover-corrections-admin.module.tsRolloverCorrectionsControllerRolloverCorrectionsService, RolloverRealignServiceCorrectionsPost-processing repair
RolloverSettingsAdminModuleLeafsettings/rollover-settings-admin.module.tsRolloverSettingsControllerRolloverSettingsServiceSettings CRUDStrategy and policy configuration
RolloverInsightsAdminModuleLeafinsights/rollover-insights-admin.module.tsRolloverInsightsControllerRolloverInsightsServiceRead-only reportsEvents, integrity, ID cards, new admissions
RolloverSharedModuleSharedshared/rollover-shared.module.tsNoneReadiness, validation, sync, fingerprint, placement, sections-view, cycle-access, process-request, run, placement-notification servicesExported to every leaf aboveCross-cutting rollover logic
AcademicYearWorkerModuleWorkerworkers/academic-year-worker.module.tsNoneAcademicYearQueueProcessor, RolloverRunnerService, AcademicYearSweepService, AcademicYearScheduler, RolloverAdminNoticeServiceBackground executionBullMQ processor, scheduler, sweep

4. File and Directory Map

apps/api/src/modules/rollover/
  rollover.module.ts
  cycles/
    admin/rollover-cycles.controller.ts
    core/rollover-cycles.service.ts
    core/rollover-cycle-review.service.ts
    core/rollover-cycle-view.service.ts
    core/rollover-overview.service.ts
    dto/rollover-cycle.dto.ts
  items/
    admin/rollover-items.controller.ts
    core/rollover-items.service.ts
    core/rollover-items-query.service.ts
    dto/rollover-item.dto.ts
  allocation/
    admin/rollover-allocation.controller.ts
    core/rollover-section-allocation.service.ts
    core/rollover-roll-allocation.service.ts
    dto/rollover-allocation.dto.ts
  processing/
    admin/rollover-processing.controller.ts
    core/rollover-processing.service.ts
    dto/rollover-processing.dto.ts
  activation/
    admin/rollover-activation.controller.ts
    core/rollover-activation.service.ts
    dto/rollover-activation.dto.ts
  corrections/
    admin/rollover-corrections.controller.ts
    core/rollover-corrections.service.ts
    core/rollover-realign.service.ts
    dto/rollover-correction.dto.ts
  settings/
    admin/rollover-settings.controller.ts
    core/rollover-settings.service.ts
    dto/rollover-settings.dto.ts
  insights/
    admin/rollover-insights.controller.ts
    core/rollover-insights.service.ts
    dto/rollover-insights.dto.ts
  shared/
    rollover-shared.module.ts
    rollover-codes.ts
    rollover-cycle-access.ts
    rollover-fingerprint.service.ts
    rollover-placement.service.ts
    rollover-process-request.service.ts
    rollover-readiness.service.ts
    rollover-run.service.ts
    rollover-sections-view.service.ts
    rollover-sync.service.ts
    rollover-validation.service.ts
    rollover-worker.contract.ts
    placement-notification.service.ts
    with-lock-retry.ts
    worker-actor.ts
  workers/
    academic-year-worker.module.ts
    academic-year-queue.processor.ts
    academic-year.scheduler.ts
    academic-year-sweep.service.ts
    rollover-runner.service.ts
    rollover-admin-notice.service.ts
apps/api/src/utils/rollover/
  section-allocation.util.ts
  roll-allocation.util.ts
  rollover-fingerprint.util.ts
FilePurposeKey ExportsNotes
shared/rollover-codes.tsCentral registry of every non-HTTP outcome/exception/warning/integrity codeEXCEPTION_CODES, WARNING_CODES, OUTCOME_CODES, INTEGRITY_CODES, PREFLIGHT_BLOCKING_CODES and moreSee §13 for the full list
shared/rollover-worker.contract.tsShapes passed between the runner and the placement serviceRolloverSettingsSnapshot, RolloverItemOutcome, ProcessItemContextFrozen settings snapshot at process-request time
shared/with-lock-retry.tsRetries a callback once on Postgres serialization failure/deadlockwithLockRetryRelies on the caller's callback being idempotent
shared/worker-actor.tsBuilds a synthetic AuthUser for background writesbuildWorkerActorKeeps worker-made audit entries distinguishable from a live admin
workers/rollover-runner.service.tsThe chunked processing loopRolloverRunnerService.run()Lease, heartbeat, per-item transaction
workers/academic-year-sweep.service.tsStalled-run and stuck-notification recoveryAcademicYearSweepService.run()Runs on the 5-minute scheduler tick
utils/rollover/section-allocation.util.tsPure section-distribution algorithmsallocateSections (4 strategies)No NestJS imports
utils/rollover/roll-allocation.util.tsPure roll-number-assignment algorithmsallocateRolls (3 strategies)No NestJS imports

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/rollover.ts
packages/db/src/schema/school/academic-sessions.ts (lock columns)
packages/db/src/migrations/0035_promotions_rollover.sql
packages/db/src/migrations/0036_promotions_rollover_guards.sql

5.2 Tables

rollover_settings — singleton configuration

ColumnTypeNullableDefaultNotes
idsmallintNo1Enforced singleton — a check constraint pins it to 1.
allow_conditional_placementbooleanNotrueWhether a conditional promotion outcome may still be placed.
allow_placement_without_sectionbooleanNotrueWhether an item can be placed-eligible with no target class yet chosen.
capacity_policyenum (block, allow_override)NoblockWhether hitting section capacity is a hard stop or overridable per item.
default_section_strategyenum (balanced, keep_previous, alphabetical, previous_roll)NobalancedDefault preselected in the allocation preview UI.
preserve_shiftbooleanNotrueWhether section allocation restricts a pupil to classes sharing their prior shift.
roll_strategyenum (alphabetical, previous_roll, keep_previous)NoalphabeticalDefault roll-allocation strategy.
roll_scopeenum (section, grade)NosectionWhether roll numbers are unique per class or per grade.
allow_activation_with_exceptionsbooleanNotrueWhether unresolved exceptions block activation or only warn.
notify_placementbooleanNotrueWhether guardians/pupils receive a placement notification at activation.
placement_channelstext[]No['in_app','push']CHECK constraint restricts values to email, sms, push, in_app.
admin_channelstext[]No['in_app']Same allowed set, for admin notices.
id_card_reminderbooleanNotrueWhether the ID-card insight nudges admins post-placement.
balance_genderbooleanNofalseWhether the balanced section strategy also evens out male/female counts per section (see §6.4). Off by default — gender is optional and a school must opt in.
graduate_access_dayssmallintNo180How many days a graduated pupil keeps read-only portal access after graduated_on. CHECK bounds it to 0–3650; 0 ends access on the graduation day itself. Copied onto each graduation's student_graduations.portal_access_until at the moment the pupil graduates, so a later settings change never shortens or extends access already granted.
versionbigintNo—Optimistic-lock token; every PATCH must send the current value.
updated_at / updated_by_user_idtimestamp / uuid FK usersYes—Audit of the last change.

rollover_cycles

ColumnTypeNullableNotes
id / public_iduuid PK / uuidNopublic_id is the externally addressed identifier.
codetextNoUnique, format RY-[A-Z0-9-]{1,60}.
source_session_id / target_session_idint FK academic_sessionsNoConstraint enforces they are distinct.
promotion_cycle_iduuidNoPaired with source_session_id as a composite reference into promotions.
statusenum (preparing, processed, activated, finalized, cancelled)NoSee §7.2 lifecycle.
run_stateenum (idle, running)NoDrives the lease protocol.
run_outcomeenum (completed, completed_with_exceptions, partially_completed)YesSet once processing finishes.
confirmed_fingerprint / confirmed_at / confirmed_by_user_idtext / timestamp / uuidYesSet by review; compared at process time.
settings_snapshotjsonbYesFrozen RolloverSettingsSnapshot for the run that used it.
attemptintNoIncremented each time processing is (re)requested.
recovery_countintNoIncremented by the sweep, capped at 5 before RECOVERY_EXHAUSTED.
lease_token / lease_expires_at / last_heartbeat_atuuid / timestamp / timestampYesThe worker lease.
run_started_at / run_finished_at / failure_codetimestamp / timestamp / textYesPopulated on run completion or forced failure.
activated_at / activated_by_user_idtimestamp / uuidYesSet on activation.
finalized_at / finalized_by_user_idtimestamp / uuidYesSet on finalize.
placement_notification_stateenum (none, queued, sent, failed)NoTracks the NOTIFY_PLACEMENTS job.
notification_attemptint (0–100)NoBounded retry counter for stuck notifications.
cancelled_at / cancelled_by_user_id / cancel_reasontimestamp / uuid / textYesSet on cancel.
started_by_user_iduuidNoActor who created the cycle.
versionbigintNoOptimistic lock.

Constraints: unique public_id; one live cycle per source/target/promotion-cycle triple (WHERE status != 'cancelled'); code format; distinct sessions; lease consistency (a token implies an expiry and vice versa); attempt/recovery_count bounds; running implies run_started_at is set; idle implies no lease; processed implies run_outcome is set; activated/finalized each imply their timestamp/actor pair; notification_attempt in [0,100]; cancelled implies run_state = idle.

rollover_items

ColumnTypeNullableNotes
idbigserial PKNoInternal only — items are addressed by (cyclePublicId, studentPublicId) at the API.
cycle_iduuid FK rollover_cyclesNoON DELETE CASCADE.
student_iduuid FK studentsNoON DELETE CASCADE.
source_enrollment_iduuid FK student_class_enrollmentsYesThe pupil's enrollment in the source session at sync time.
source_class_id / source_grade_id / source_section_idint FK, RESTRICTNoSnapshot of where the pupil was.
source_shiftenum (morning, day)NoUsed for preserveShift.
source_roll_numbersmallintYesUsed by keep_previous/previous_roll strategies.
decision_iduuid FK promotion decisions, NO ACTIONNoPoints at the promotion decision this item was synced from.
outcomeenum (shared with promotions)NoSnapshot of the decision outcome at last sync.
target_grade_id / target_class_idint FK, RESTRICTYesSet by allocation or manual edit.
planned_roll_numbersmallintYesRange 1–999 (see below).
inclusionenum (included, excluded)NoWhether the pupil is part of this run at all.
exclusion_sourceenum (manual, decision)YesWhether exclusion came from an admin or from the decision itself.
exclusion_reasontextYesRequired when inclusion = excluded.
capacity_overridebooleanNoDefault false. Set by RolloverOverrides_UPDATE.
override_reason / override_by_user_id / override_attext / uuid / timestampYesRequired together with capacity_override.
statusenum (pending, placed, graduated, awaiting_section, needs_review, failed, withdrawn)NoSee §7.2.
exception_codes / warning_codes / processing_warningstext[]NoReset and recomputed by validation on every mutation batch.
outcome_codetextYesSet once processed — one of the codes in §13.
enrollment_iduuid FK student_class_enrollments, NO ACTIONYesSet once placed.
graduation_iduuid FK student_graduations, NO ACTIONYesSet once graduated.
process_orderintNoDeterministic processing order — the runner's cursor.
attemptsint (0–1000)NoIncremented on an item-level failure.
processed_at / validated_at / placement_notified_attimestampYes—

Constraints: unique (cycle_id, student_id); roll range 1–999; placed implies enrollment_id set; graduated implies graduation_id set; excluded triple consistency (inclusion = excluded requires exclusion_source and exclusion_reason); override pair consistency; outcome_code implies a terminal-or-failed status; processed_at implies status is not pending; an excluded item is never processed; text length bounds on reasons; attempts bounded 0–1000.

Indexes: (cycle_id, status, process_order), (cycle_id, target_class_id), (cycle_id, target_grade_id), enrollment_id, student_id.

student_graduations

ColumnTypeNullableNotes
id / public_iduuid PK / uuidNo—
student_iduuid FK studentsNoON DELETE CASCADE.
promotion_decision_iduuid FK decisionsNoNO ACTION.
rollover_cycle_iduuid FK rollover_cyclesNoNO ACTION.
academic_session_idint FK sessionsNoRESTRICT — the session the pupil graduated from.
final_class_id / final_grade_idint FK, RESTRICTNoThe pupil's last class/grade.
graduated_ondateNo—
revoked_at / revoked_by_user_id / revoke_reasontimestamp / uuid / textYesSet by a correction that undoes a graduation.
created_at / created_by_user_idtimestamp / uuidNo—

Constraints: revoked triple consistency. Index: unique per student where revoked_at IS NULL (one live graduation record at a time); session_idx on academic_session_id.

rollover_events — append-only timeline

ColumnTypeNullableNotes
idbigserial PKNo—
cycle_iduuid FK rollover_cyclesYesON DELETE CASCADE.
session_idint FK sessionsNoRESTRICT.
item_idbigint FK rollover_itemsYesON DELETE SET NULL.
student_iduuid FK studentsYesON DELETE SET NULL.
event_typetextNoOne of 29 known types (see below).
actor_user_iduuid FK usersYesNull for a synthetic worker actor's own audit trail elsewhere; events still record the effective actor.
from_value / to_valuejsonbYesBefore/after snapshot relevant to the event.
reasontext (≤1000 chars)Yes—
created_attimestamp (clock_timestamp())NoWall-clock, not transaction start, so ordering reflects real occurrence order even inside long transactions.

Constraint: every event has an anchor — either cycle_id or session_id is set. event_type is checked against the known list. Indexes on cycle_id, student_id, session_id.

Event types (29): cycle_created, items_synced, validated, item_excluded, item_included, target_class_changed, roll_changed, capacity_override, allocation_applied, roll_allocation_applied, review_prepared, processing_started, processing_retried, processing_finished, processing_failed, processing_recovered, placement_created, graduation_recorded, placement_corrected, placement_realigned, placement_withdrawn, graduation_revoked, year_activated, cycle_finalized, session_locked, session_unlocked, cycle_cancelled, placement_notifications_queued.

Every item mutation event (rollover-items.service.ts) carries actorUserId. target_class_changed's fromValue/toValue are each { classPublicId }; roll_changed's are each { rollNumber }; capacity_override's toValue is { enabled, reason } (fromValue carries the prior { enabled }). item_excluded and item_included carry no fromValue/toValue — the transition itself is the payload. Corrections write the same target_class_changed shape.

Append-only enforcement: a BEFORE UPDATE OR DELETE trigger raises unless pg_trigger_depth() > 1 (i.e. the mutation is happening as a cascade from a parent delete, such as the cycle itself being removed) — see 0036_promotions_rollover_guards.sql:131-143.

academic_sessions — lock columns (owned by academic-sessions, consumed here)

ColumnTypeNullableNotes
locked_attimestamp with time zoneYesSet by AcademicYearLock.
locked_by_user_iduuid FK users, ON DELETE SET NULLYes—
lock_reasontextYes3–1000 chars when present.

Constraints: locked_at IS NULL ⟺ lock_reason IS NULL; lock_reason length bound; NOT (is_current AND locked_at IS NOT NULL) — the currently active session can never simultaneously be locked.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 RolloverCyclesService

MethodCalled ByReadsWritesSide EffectsErrors
create()RolloverCyclesControlleracademic_sessions, existing cycles, promotion cyclerollover_cyclesDelegates roster sync to RolloverSyncServiceROLLOVER_CYCLE_EXISTS, ROLLOVER_SAME_SESSION, ROLLOVER_TARGET_BEFORE_SOURCE, ROLLOVER_TARGET_LOCKED, ROLLOVER_TARGET_INACTIVE, ROLLOVER_PROMOTION_NOT_FINALIZED
list()Controllerrollover_cyclesNoneNone—

6.1a RolloverCycleReviewService

State transitions on an existing cycle, kept apart from RolloverCyclesService so each stays a single-purpose service.

MethodCalled ByReadsWritesSide EffectsErrors
sync()POST /rollover/cycles/:publicId/syncPromotion decisions, source-session enrollments, itemsrollover_items, rollover_eventsLocks the cycle, re-runs RolloverSyncService.sync then validateCycle, writes an items_synced eventROLLOVER_INVALID_STATE on a cancelled cycle
review()POST /rollover/cycles/:publicId/reviewItems, classes, settings, sessionsrollover_items (sync and validation codes)Locks the cycle, syncs and re-validates, then returns the fingerprint and review summary; the fingerprint itself is not storedROLLOVER_INVALID_STATE on a cancelled cycle
cancel()DELETE /rollover/cycles/:publicIdCycle, placed/graduated countsstatus = cancelledInserts cycle_cancelled eventROLLOVER_CANCEL_NOT_ALLOWED if placements already exist

6.2 RolloverCycleViewService / RolloverOverviewService

Read-only mappers. RolloverCycleViewService assembles the detail view including the readiness checklist; RolloverOverviewService assembles the dashboard: current/suggested session, active cycle summary (with readiness omitted for brevity), student/decision counts, and recent cycle history.

6.3 RolloverSyncService

MethodCalled ByReadsWritesSide EffectsErrors
syncItems(cycleId)RolloverCyclesService.create, POST /rollover/cycles/:publicId/syncPromotion decisions, source-session enrollments, existing non-terminal itemsrollover_items insert/update, rollover_events (items_synced, target_class_changed)Triggers a re-validation pass implicitly by leaving items non-terminal—

Code flow narrative: the roster is defined as every student with a current promotion decision and an active enrollment in the source session. For each roster member without an existing item, a row is inserted; if their decision outcome is excluded, the item is immediately marked inclusion = excluded, exclusion_source = decision, exclusion_reason = "The current promotion decision outcome is excluded.". For an existing non-terminal item, source-session fields (enrollment, class, grade, section, shift, roll) and the decision/outcome are always resynced; if the resynced target grade differs from what was there before, target_class_id and planned_roll_number are cleared and a target_class_changed event is recorded. Inclusion is only auto-adjusted when the exclusion was itself auto-derived (exclusion_source = decision) — a manual exclusion is never overwritten by sync. Terminal items (placed, graduated, withdrawn) are never touched.

6.4 Section Allocation — RolloverSectionAllocationService and apps/api/src/utils/rollover/section-allocation.util.ts

Capacity counting rule: for a target class C, occupied(C) = used(C) + fixed(C), where used(C) is the live enrollment count in that class (via the class occupancy service) and fixed(C) is the count of this cycle's already-placed-or-pinned items already holding a seat in C that the current preview treats as fixed (onlyUnassigned: true excludes them from being re-shuffled but still counts their seat).

Strategies:

  • balanced — sort students by name (locale-aware, numeric-aware collation). For each student, in order, compute their eligible classes (all active classes in the target grade, or same-shift-only when preserveShift narrows it and a same-shift class exists), then assign to the eligible class with the lowest occupied + assignedThisPass (ties broken by section order). A student with no eligible class with room is left unassigned. When rolloverSettings.balanceGender is on and the student's own gender is male or female, the candidate pool is instead narrowed first to the classes within 1 of the minimum projected size, and the assignment within that pool goes to whichever class currently holds the fewest pupils of that same gender (ties broken by projected size, then section order). A pupil recorded as other, prefer_not_to_say, or with no gender is never part of this narrowing — they are placed by size alone, and neither counts toward nor is balanced against anyone else's gender.
  • keep_previous — two phases. Phase 1: sort by name; for each student, try to place them back into a class that shares their source section, subject to shift and room; assign if such a class has room. Phase 2: everyone not placed in phase 1 falls through to the balanced algorithm.
  • alphabetical — partition students by their eligible-class set (so shift-restricted students form their own group), sort each group by name, sort that group's classes by section order, then distribute in contiguous blocks, one class at a time: blockSize starts at min(capacityLeft(class), ceil(remainingStudents / remainingClasses)), then is raised (water-filled) to at least remainingStudents - totalCapacityLeftInLaterClasses so a later, smaller class can never be left with more students than it has room for, and is finally clamped back to capacityLeft(class). A forward-only pass can still fall short of a perfect partition in rare edge cases the water-fill formula doesn't anticipate, so a spill pass runs afterward: whatever is left over is placed into whatever capacity remains, walking the group's classes in section order. Once a group's classes are all full, remaining students in that group are unassigned.
  • previous_roll — identical block-distribution mechanics to alphabetical, but the group is sorted by source roll number ascending (nulls last), then section order, then name.

Unassigned reasons: NO_CLASS (grade has no eligible class at all), NO_CLASS_FOR_SHIFT (shift restriction narrowed eligibility to zero classes), CAPACITY (every eligible class is full).

Worked example — 85 students into two classes of capacity 40, strategy balanced, no shift restriction:

  1. Sort all 85 students by name.
  2. For the first student: both Section A and Section B have occupied = 0; tie broken by section order → assigned to A. A.assigned = 1.
  3. The algorithm keeps assigning each subsequent student to whichever of A/B currently has the lower occupied + assignedThisPass — this alternates, filling both roughly evenly, until one of them reaches capacity 40.
  4. Once A reaches 40, every remaining candidate's only eligible class with room is B, so B receives the rest until it, too, reaches 40.
  5. Result: A = 40, B = 40, and the remaining 5 students are left unassigned with reason CAPACITY.

Worked example — 60 students into three sections of ample capacity (10 each already reserved elsewhere, effectively unconstrained here), strategy alphabetical: with three classes and 60 students and no capacity bound reached, the block size per pass is ceil(60/3) = 20, so section order 1 receives the first 20 names alphabetically, section order 2 the next 20, and section order 3 the final 20 — a clean 20/20/20 split with zero unassigned.

Applying with onlyUnassigned: false re-computes every item, not just the currently-unassigned ones, so an item the fresh computation now leaves unassigned is not left claiming its previous class: apply clears that item's targetClassId and plannedRollNumber rather than skipping it. With onlyUnassigned: true this never happens, because an already-assigned item is excluded from the movable set and is never revisited.

Endpoints (allocation.controller.ts): GET /rollover/cycles/:publicId/sections (Rollover_READ), POST .../section-allocation/preview (Rollover_UPDATE), POST .../section-allocation/apply (Rollover_UPDATE, token-checked).

6.5 Roll Allocation — RolloverRollAllocationService and apps/api/src/utils/rollover/roll-allocation.util.ts

Max roll number: 999. nextFree(taken, from) scans upward from from and returns the first roll not in taken, or null if none remain up to 999.

Scopes: section — one independent reserved-roll set per target class. grade — one reserved-roll set spanning every class in the grade (so no two sections of the same grade can share a roll number).

Reserved set: for a given scope, the rolls already taken by live, non-rollover enrollments in that scope's class(es), plus any roll already assigned to this cycle's own placed/pending items in that scope.

Strategies:

  • keep_previous — phase 1: sort items by source roll ascending (nulls last), then section order, then name; for each, if the source roll is a valid number (1–999) and not already taken in its scope, assign it that same number. Phase 2: everything deferred from phase 1 is sorted the same way and each takes nextFree in order.
  • alphabetical — sort items by name; each takes nextFree in that order.
  • previous_roll — sort items by source roll then section order; each takes nextFree in that order.

Worked example — 60 students split roughly 20/20/20 across three sections, scope section, strategy alphabetical: each section is its own independent group with its own empty reserved set. Within Section A, the 20 students are sorted by name and assigned rolls 1 through 20 in order (nextFree(∅,1)=1, then nextFree({1},1)=2, …). Section B and Section C run the identical process independently, each also producing rolls 1 through 20 — because the scope is section, not grade, three different pupils across the three sections can legitimately hold roll number "1" simultaneously.

If the settings instead used scope grade for the same 60 students, the reserved set would span all three sections at once, so the assignment would instead produce one continuous sequence, 1 through 60, with no repeats across sections.

Endpoints: POST /rollover/cycles/:publicId/roll-allocation/preview and /apply (Rollover_UPDATE), token-checked against the preview's proposalToken the same way as section allocation — a mismatch raises ROLLOVER_PROPOSAL_STALE.

6.6 RolloverValidationService

MethodCalled ByReadsWritesSide EffectsErrors
validate(cycleId)Cycle creation, sync, review, allocation apply, corrections, item edits, run finish, sweep stop, settings updateItems, target classes, live enrollments, settings, school_profile.roll_number_auto_renumberexception_codes, warning_codes and validated_at on items whose codes changedNone (pure recomputation)—

Set-based: builds the relevant sets once (occupied rolls per scope, occupied seats per class) rather than issuing one query per item, so cost does not scale linearly with roster size. validateCycle re-runs, in the same transaction as the triggering write, after: cycle creation (which reads the settings row FOR SHARE, so it waits for an in-flight settings save), section allocation apply, roll allocation apply, every correction, every item mutation (include/exclude, target-class change, roll change, capacity override), the end of a processing run, whether the runner finishes it or the recovery sweep stops it after exhausting recoveries (so a settings change made while the cycle was running is applied once it goes idle; both paths read the settings row FOR SHARE first; on the sweep path the re-validation runs in a savepoint, so a validation error is logged and the stop still commits, and a failure on one stalled cycle never stops the sweep from handling the others or from retrying publications and placement notices), and a rollover settings update — the last one locks, in id order, and revalidates every cycle item edits can still reach (status preparing, processed, activated or finalized with runState = 'idle'), not only the cycle being edited, because validateCycle reads capacity policy, conditional-placement and roll-scope settings live rather than storing them per item. It resets exception/warning codes before recomputing, then writes back only the items whose codes changed, in one UPDATE … FROM (VALUES …) per 500 rows ordered by id; validated_at therefore records when an item's codes last changed. Every caller holds the cycle FOR UPDATE, so validating writers on one cycle run one at a time instead of deadlocking on each other's item rows.

Capacity counting rule (exact): this differs by capacity_policy. Under allow_override, every item with capacity_override set is admitted unconditionally and counted first, seeding the class's admitted counter — an override item never receives SECTION_CAPACITY itself. The remaining (non-override) items for that class are then walked in process_order, admitted while used + admitted < capacity, with every item beyond that point receiving SECTION_CAPACITY. Under capacity_policy = "block", capacity_override has no effect: an overridden item is a normal candidate, walked in process_order with everyone else and subject to the same used + admitted < capacity cutoff.

Duplicate roll detection: computed per scope (section or grade, per rolloverSettings.rollScope) by comparing live non-rollover rolls plus this cycle's planned rolls; any collision marks every colliding item DUPLICATE_ROLL.

Exception codes it can set: INVALID_STUDENT_STATUS, MISSING_DECISION, DECISION_PENDING, DECISION_CHANGED, CONDITIONAL_NOT_ALLOWED, MISSING_TARGET_GRADE, TARGET_GRADE_NO_CLASS, MISSING_SECTION, TARGET_CLASS_INVALID, SECTION_CAPACITY, DUPLICATE_PLACEMENT, GRADUATE_HAS_TARGET_PLACEMENT, EXISTING_GRADUATION, DUPLICATE_ROLL, ADMISSION_AFTER_TARGET_END. Warning codes: MISSING_SECTION, MISSING_ROLL, ALREADY_PLACED.

6.7 RolloverFingerprintService

Computes a hash over exactly the state that would change the outcome of processing: every non-terminal item's id, studentId, live currentDecisionId, inclusion, targetClassId, plannedRollNumber, capacityOverride; every relevant target class's id, isActive, capacity, gradeId, and current used occupancy; the settings row's version; the roll policy (strategy, scope); and the target session's isActive/isLocked/startDate/endDate plus the promotion cycle's status/version.

Used at two points: POST /rollover/cycles/:publicId/review returns the computed fingerprint to the client without storing it; POST /rollover/cycles/:publicId/process recomputes it under lock, compares it with the fingerprint the client sends back, and on a match stores it as confirmedFingerprint — any mismatch (a decision changed, a class's capacity changed, someone else edited an item) rejects the request with ROLLOVER_PREVIEW_STALE rather than silently processing against a plan the admin never actually reviewed.

6.8 RolloverReadinessService

Fifteen checks across seven groups, each returning ready | warning | error | not_applicable | pending:

GroupChecks
academic_yearsessions_exist, target_after_source, target_active_unlocked, target_not_started, target_calendar_days
promotionspromotion_cycle_finalized, pending_decisions
classestarget_grades_have_classes, capacity_vs_demand, class_teacher_assigned
subjectsclass_subjects_configured
schedulepublished_timetable
studentsstudent_categories
id_cardsid_cards_needed
attendanceattendance

Each check's warning/error state is meant to tell the operator exactly what to fix before proceeding — e.g. target_grades_have_classes: error means create at least one active class in that grade before allocation can place anyone into it; pending_decisions: warning means some pupils still lack a finalized promotion outcome and will sync in as MISSING_DECISION exceptions.

6.9 Rollover Corrections and Realign services

RolloverCorrectionsService.correct is the controller-facing entry point for the single POST .../items/:studentPublicId/correct route. It locks the cycle (FOR SHARE), the item, and the student, loads the pupil's current promotionDecisions row, then dispatches on dto.action: "move" and "relink" are handled directly by RolloverCorrectionsService; "realign" is delegated to RolloverRealignService.realign, which dispatches again on the item's current status and decision outcome (cases a–f below), all inside the same transaction:

CaseWrite order
move — placed → different class, same grade1. Validate the target class (active, same session, same grade as the current decision). 2. Enroll into the new class via ClassEnrollmentsService.enroll. 3. Update item's targetClassId/enrollmentId. 4. Insert placement_corrected event.
relink — placed, stale enrollment link1. Find the pupil's current active enrollment in the target session whose grade matches the decision. 2. Update item's enrollmentId/targetClassId to point at it. 3. Insert placement_realigned event. No enrollment is created, removed, or changed — only the item's link.
realign (a) placed → excluded/pending1. Update item → withdrawn, outcomeCode = DECISION_WITHDRAWN, enrollmentId = null. 2. Insert placement_withdrawn event. 3. Remove the old enrollment via ClassEnrollmentsService.removeEnrollment (enrollment-removal-guards).
realign (b) placed → graduated1. Insert student_graduations row. 2. Update item → graduated, graduationId set, enrollmentId = null. 3. Insert graduation_recorded event. 4. Remove the old enrollment.
realign (c) placed → different target grade1. Update item → pending, clearing enrollmentId. 2. Remove the old enrollment. 3. Enroll into the new class via ClassEnrollmentsService.enroll. 4. Update item → placed with the new enrollmentId. 5. Insert placement_realigned event.
realign (d) graduated → placeable1. Update graduation → revoked. 2. Update item → pending, graduationId = null. 3. Insert graduation_revoked event. 4. Enroll via ClassEnrollmentsService.enroll. 5. Update item → placed.
realign (e) graduated → excluded/pending1. Update graduation → revoked. 2. Update item → withdrawn, graduationId = null, outcomeCode = DECISION_WITHDRAWN. 3. Insert graduation_revoked event.
realign (f) withdrawn → placeable/graduated1. Update item → pending, restoring decisionId/outcome/targetGradeId from the current decision. 2. Insert placement_realigned event. No enrollment or graduation row is touched — a later realign call re-derives the placed/graduated outcome from this pending state.

Every case that leaves the item with an enrollment (move, relink, and realign cases c/d) triggers a rollover.placement_changed guardian notification once the transaction commits, unless the cycle is still preparing/processed or rolloverSettings.notifyPlacement is off. RolloverCorrectionsService never issues a raw INSERT into student_class_enrollments — every placement, original or corrected, goes through ClassEnrollmentsService.enroll in enrollments-core, so the corrections path and the original processing path share the same enrollment invariants.

Un-graduating restores access, not just placement. Before cases (d) and (e) mark a graduation revoked, RolloverRealignService calls GraduateAccessService.undoForRevoke to give back whatever ending that graduation's access took — the pupil's role grants always, and sign-in only when the actor performing the realign holds Users_UPDATE (mayRestoreSignIn, resolved once before the transaction opens). An actor without Users_UPDATE can still un-graduate a pupil; the pupil's own sign-in simply stays whatever it already was.

allowOverCapacity applies to move only, and correct checks it in a fixed order so a 409-vs-403 response can never be used to probe the school's rollover settings: (1) before the transaction even opens, an actor sending allowOverCapacity: true without RolloverOverrides_UPDATE gets 403 Forbidden (ROLLOVER_OVER_CAPACITY_NOT_PERMITTED) regardless of dto.action or the current capacity policy; (2) inside the transaction, allowOverCapacity: true on any action other than move gets 400 Bad Request (ROLLOVER_CORRECTION_INVALID); (3) only then is rollover_settings.capacity_policy read — if it is not allow_override, the request gets 409 Conflict (ROLLOVER_OVERRIDE_NOT_ALLOWED). A move that passes all three enrolls over capacity, stamps the item's capacityOverride/overrideReason/overrideByUserId/overrideAt together (an ordinary move without the flag explicitly clears all four rather than leaving a stale override in place), and the placement_corrected event's toValue carries overCapacity: true|false — computed by re-counting the destination class's occupancy after the enrollment, while the class row is still locked, so it reflects the seat this move itself just claimed. overCapacity is present only when allowOverCapacity was set; an ordinary move's event carries no such field.

6.10 RolloverPlacementService

The per-item write path used by both the runner and, indirectly, corrections. Placement sequence: call ClassEnrollmentsService.enroll (never a direct insert), optionally assign the roll number, update the item's status = placed with enrollmentId, and insert a placement_created rollover event. No writes ever touch the source session, its classes, or its enrollments — only the target session is written.

6.11 RolloverActivationService

preflight() evaluates the blocking and warning code sets (see §13) without writing anything. activate() re-checks preflight, then calls academic-sessions.service.ts's switchCurrent(targetSessionId, { via: "rollover" }). Passing via: "rollover" deliberately skips the in-flight-cycle guard that a manual session switch would otherwise trip — the whole point of the call is to complete the cycle that guard would object to. switchCurrent still independently checks that the target session is not locked; that check is not bypassed. On success, activation flips is_current on both sessions inside one transaction, updates the cycle to activated, and — if notifyPlacement is enabled and unnotified placements exist — enqueues NOTIFY_PLACEMENTS. finalize() sets status = finalized and can optionally lock the source session in the same call.

6.12 RolloverCycleAccessService

findCycleByPublicId supports row locking modes: FOR UPDATE for anything that changes cycle-level state (activate, finalize, process request), FOR SHARE for item-level work that must not race a state change but doesn't itself need exclusivity. assertCycleMutable requires the cycle's status to be in an allowed set for the operation and runState = idle — nothing may mutate a cycle mid-run.

6.13 RolloverSectionsViewService

Pure read. For each target class returns occupied (from a live enrollment count query), planned (count of this cycle's items with a matching targetClassId and a placeable outcome), and remaining = capacity - occupied - planned. No writes.

6.14 PlacementNotificationService

enqueuePlacementNotifications is called from three places that all hold the same guarantee — the item rows it will notify for already have a committed placement: RolloverActivationService.activate() (on activation), RolloverRunnerService's finish step (when processing itself finishes against an already-activated/finalized cycle), and RolloverActivationService.retryNotifyPlacements() (the POST .../notify-placements route, for a failed or previously-unsent none state). sendAll (the NOTIFY_PLACEMENTS job handler) refuses to run against a cycle that is not activated or finalized — so even if a job were somehow enqueued early, the handler itself is the second gate against sending a notification for a placement that hasn't actually taken effect yet.

The channel list comes from the cycle's own settingsSnapshot.placementChannels, frozen at cycle creation rather than read live from current settings. sendAll loops in chunks, each chunk one transaction locking only rollover_items (FOR UPDATE SKIP LOCKED) and excluding soft-deleted pupils, draining the un-notified, placed item rows until none remain, then recounting eligible rows before marking the cycle's placement-notification state sent — the same lock/drain/recount shape PromotionPublicationService.publish() uses for promotion decisions.

7. Runtime Flows

7.1 Processing request → enqueue

StepCode PathBehaviorFailure Case
1RolloverProcessingController.processValidates DTO (fingerprint, confirm: true)400 if confirm is not true
2RolloverProcessRequestService.requestProcessLocks cycle, verifies fingerprint409 ROLLOVER_PREVIEW_STALE on mismatch
3SameChecks non-terminal included items exist409 ROLLOVER_NOTHING_TO_PROCESS
4SameWrites outbox row in the same transaction as the attempt bumpEnqueue relayed by the outbox dispatcher, never a direct queue.add()

7.2 Chunked processing loop

  • Lease: LEASE_SQL = clock_timestamp() + interval '2 minutes', claimed with a conditional UPDATE ... WHERE id = ? AND attempt = ? AND (lease_expires_at IS NULL OR lease_expires_at < clock_timestamp()) RETURNING *. An unclaimed row (already held by a live lease) raises RolloverLeaseHeldError, which BullMQ's own retry/backoff handles.
  • Chunk size: 50 items per pass, fetched with FOR UPDATE SKIP LOCKED ordered by process_order — SKIP LOCKED lets a concurrent admin correction lock an item the runner hasn't reached yet without blocking either side.
  • Per-item transaction: each item is processed inside its own db.transaction. An unhandled error is caught, the item is marked failed with outcomeCode = UNEXPECTED_ERROR, attempts incremented (capped at 1000), and the loop continues to the next item rather than aborting the run. A pupil who has been soft-deleted between the process request and this item being reached is marked needs_review/INVALID_STUDENT_STATUS rather than placed — this covers the graduation path in particular, since enroll() already refuses a soft-deleted pupil on the ordinary placement path.
  • Deferred effects: cache/audit-adjacent side effects for an item are buffered and flushed only after that item's transaction commits, so a rolled-back item never leaves a stray side effect.
  • Heartbeat: checked after every item, but only actually renewed once at least 30 seconds have elapsed since the last renewal — the runner updates lease_expires_at and last_heartbeat_at where lease_token still matches; if another attempt has since claimed the lease, this raises RolloverLeaseLostError, the runner stops writing immediately, and the run reports lease_lost (the sweep will recover it).
  • Per-item lock timeout: each item's placement runs in its own transaction with SET LOCAL lock_timeout = '30s'. A lock that doesn't clear in time fails only that item (failed/UNEXPECTED_ERROR) rather than hanging the whole run — a hung run would otherwise hold the ACADEMIC_YEAR queue's single worker slot (concurrency: 1) forever, and the recovery sweep shares that same queue so it could never step in to rescue it. Only an actual lease loss aborts the run itself.
  • Finish: computes run_outcome from the item-status counts (or forces partially_completed if a failure_code was set), transitions preparing → processed (other statuses left alone — a re-process of an already-processed cycle doesn't regress it), clears the lease, inserts processing_finished/processing_failed, calls RolloverAdminNoticeService, and — if notifyPlacement is enabled, the cycle is activated/finalized, and unnotified placements exist — enqueues NOTIFY_PLACEMENTS.

7.3 Recovery sweep

Stalled-run predicate: run_state = 'running' AND (lease_expires_at < clock_timestamp() OR (lease_token IS NULL AND last_heartbeat_at < clock_timestamp() - interval '3 minutes')) — the second branch covers a job that was enqueued but never claimed by any worker before being lost.

Recovery: increments recovery_count, clears the dead lease fields, stamps a fresh heartbeat, inserts a processing_recovered event, and re-enqueues via the outbox with a dedupe key of rollover-{publicId}-{attempt}-r{recoveryCount} — the attempt itself does not change, since this is a resumption of the same logical run, not a new one.

Exhaustion: at recoveryCount > 5 (MAX_RECOVERIES = 5), the sweep gives up: run_state = idle, run_outcome = partially_completed, status advances preparing → processed if still preparing, lease cleared, failure_code = RECOVERY_EXHAUSTED, and an admin notice fires with that reason.

Stuck notification recovery: applies the same 10-minute-stuck / 5-attempt-cap pattern independently to promotion publications and to placement notifications, each tracked by its own *QueuedAt/attempt columns.

Graduate-access expiry pass: the same tick also selects up to 200 live graduations (revokedAt IS NULL, portalAccessEndedAt IS NULL, portalAccessUntil < today), ordered so a row that failed a previous attempt (portalAccessSweepFailedAt) sorts after every row that hasn't been tried yet, then by portalAccessUntil and id. Each candidate is re-selected FOR UPDATE SKIP LOCKED and ended in its own transaction via GraduateAccessService.end(tx, id, "expiry", today, null, effects), which re-checks the date under its own row lock before acting — an administrator may have extended access since the scan ran. A failure on one row is caught, logged with the graduation's id, and stamped onto portal_access_sweep_failed_at via recordSweepFailure so a row that keeps failing cannot hold up every later expiry; the pass then continues with the next candidate. This pass runs after the stalled-run and stuck-notification passes in the same tick, each in its own guard, so a failure in one pass never stops the others. Until a graduation's access is successfully ended, a guardian already stops seeing that pupil (see the people module's audience-assertion rules) and the pupil's own portal writes are refused, but the pupil's own portal reads continue — they are gated by the pupil role grant, which only a successful end removes.

7.4 Session lock enforcement

The guard fires for both INSERT/UPDATE/DELETE on student_class_enrollments and classes, checking both OLD and NEW on an UPDATE so a row cannot be moved either into or out from under a locked session. The pg_trigger_depth() > 1 early return is deliberate: it lets a cascading delete (for example, deleting a student, which cascades into their enrollments) proceed even if some of those enrollments belong to a locked historical session, while still blocking any direct edit to a locked year's records.

8. Caching

Rollover has no cache layer of its own. Reads go directly to Postgres on every request; there is no CacheKeyUtil usage anywhere in this module.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.ACADEMIC_YEAR ("academic-year")AcademicYearJob.PROCESS_ROLLOVERRolloverProcessRequestService (via outbox)RolloverRunnerService.run(){v:1, requestId, cyclePublicId, attempt}BullMQ default attempts; lease-based application-level retry on topLease keyed by (cycleId, attempt); a stale attempt cannot double-run
SameAcademicYearJob.ROLLOVER_RECOVERY_SWEEPAcademicYearScheduler (cron, no outbox — cron-only jobs are the one exemption from the outbox rule)AcademicYearSweepService.run(){v:1, requestId}Runs every 5 minutes regardless of prior outcomejobId bucketed by 5-minute window via buildJobId(["academic-year-sweep", bucket]), deduplicating within the window
SameAcademicYearJob.PUBLISH_PROMOTION_DECISIONSPromotions module (via outbox)PromotionPublicationService.publish(){v:1, requestId, cyclePublicId, attempt}Sweep re-enqueues if stuck—
SameAcademicYearJob.NOTIFY_PLACEMENTSRolloverActivationService / RolloverRunnerService (via outbox)PlacementNotificationService.sendAll(){v:1, requestId, cyclePublicId, attempt}Sweep re-enqueues if stuck (max 5 attempts)Refuses to send unless cycle is activated/finalized

The processor (academic-year-queue.processor.ts) runs at concurrency 1 — a cycle's lease is held across its whole run, so there is no throughput benefit to running more than one worker process against this queue at a time; concurrency 1 also removes any need to reason about two workers racing the same lease claim.

Scheduler: academic-year.scheduler.ts fires on cron "30 */5 * * * *" (UTC, every 5 minutes at :30 seconds), enqueuing exactly ROLLOVER_RECOVERY_SWEEP with no accompanying database write — the deliberate cron-only exemption from the outbox pattern.

AcademicYearSweepService.run() runs four independent passes on every tick — stalled-run recovery, publication requeue, placement-notice requeue, and expiring graduate access — each wrapped in its own guarded(name, fn) that catches and logs rather than throws, so one pass failing (a single bad row, a transient database error) does not stop the passes after it, and the same 5-minute tick still makes progress everywhere else.

The graduate-access pass, sweepGraduateAccess, selects up to 200 live (revoked_at IS NULL, portal_access_ended_at IS NULL) graduations whose portal_access_until has already passed, ordered by portal_access_sweep_failed_at ASC NULLS FIRST then portal_access_until then id — a row that failed on a previous tick sorts behind every row not yet attempted, so one persistently failing graduation cannot starve the rest of the batch. Each candidate is ended individually, inside its own transaction, through GraduateAccessService.end(tx, graduationId, "expiry", today, null, effects) — the same method the admin endNow route calls with cause: "admin", so the read-only cutover, the sign-in check and the role-grant revocation are identical code regardless of who or what triggered it. A candidate whose end throws is caught per-row: the sweep logs the error, calls GraduateAccessService.recordSweepFailure to write a failure-status Mongo audit entry (module Students, action GRADUATE_ACCESS_ENDED, actor "system") so a repeatedly failing expiry is visible in the activity trail and not only the server log, and separately stamps portal_access_sweep_failed_at on the row (outside the failed transaction, so the stamp survives its rollback) purely to reorder the next tick's batch — it does not block or retry within the same run. result.graduateAccessEnded/graduateAccessFailed are folded into the sweep's summary log line alongside the other three passes' counts.

10. Realtime and Events

No realtime/Socket.IO events. Rollover's "event" vocabulary in this module refers exclusively to the rollover_events audit timeline table (§5.2), not to the platform's realtime gateway.

11. Security, Auth, and Abuse Controls

  • Guards: every route sits behind the global JwtAuthGuard plus RoleGuard, which checks the handler's @Permissions() code against the caller's granted permissions.
  • Permissions: Rollover (cycle/item/allocation browsing and everyday editing), RolloverProcessing (start/re-run processing), RolloverOverrides (place beyond capacity), RolloverCorrections (move/realign/relink a placement or graduation), RolloverActivation (make the target year current, finalize), AcademicYearLock (lock/unlock a historical session), RolloverSettings (change strategy/policy defaults). Each generates the standard CREATE/READ/UPDATE/DELETE/RESTORE codes.
  • Superadmin-only by default: RolloverProcessing, RolloverOverrides, RolloverCorrections, RolloverActivation, AcademicYearLock, and RolloverSettings are all in SUPERADMIN_ONLY_MODULES — only Rollover itself is not. The rationale recorded in the permission catalog: each of these either publishes every child's outcome or placement to their family, moves the whole school into a new year, rewrites history that has already been published, or relaxes one of the gates around those actions through settings — and granting a permission has no automatic revocation path, so the safer default is to require a deliberate, reversible decision by a school to widen it, rather than defaulting wide and hoping nobody misuses it.
  • Worker identity: background writes use a synthetic AuthUser built by worker-actor.ts (sessionId: "worker:rollover", no roles), so every audit entry produced during processing is visibly attributable to the background run rather than impersonating a live admin session.
  • Fail-closed on locked sessions: enforced at two independent layers — the application guard in academic-sessions.service.ts/enrollment-removal-guards.ts, and the database trigger in 0036_promotions_rollover_guards.sql that fires regardless of which code path reached the table. A bug that bypassed the application check would still be stopped by the trigger.
  • Enrollment removal guard: assertEnrollmentRemovable in enrollments-core/shared/enrollment-removal-guards.ts refuses to delete an enrollment if its session is locked, or if the enrollment is linked to a rollover item (ENROLLMENT_LINKED_TO_ROLLOVER) — a rollover-created placement can only be undone through a rollover correction, never a raw delete.
  • Append-only audit: the rollover_events trigger makes the timeline tamper-evident even against a superadmin session — no application code path can rewrite it, only a cascading delete of the parent cycle can remove its rows.

13. Error Handling

Domain error codes (apps/api/src/common/types/error-codes.ts)

Error CodeCondition
ROLLOVER_CYCLE_NOT_FOUNDpublicId does not resolve to a cycle
ROLLOVER_SAME_SESSIONSource and target session are identical
ROLLOVER_TARGET_BEFORE_SOURCETarget session does not start after the source
ROLLOVER_TARGET_LOCKEDTarget session has locked_at set
ROLLOVER_TARGET_INACTIVETarget session is not active
ROLLOVER_CYCLE_EXISTSA live cycle already targets this source/target/promotion-cycle triple
ROLLOVER_INVALID_STATERequested action is not valid for the cycle's current status/run state
ROLLOVER_PROMOTION_MISMATCHPromotion cycle does not match the source session
ROLLOVER_PROMOTION_NOT_FINALIZEDSource promotion cycle isn't finalized yet
ROLLOVER_PREVIEW_STALEFingerprint at process time doesn't match the confirmed one (409)
ROLLOVER_PROPOSAL_STALEAllocation apply token doesn't match the current cycle state (409)
ROLLOVER_CONFIRMATION_REQUIREDconfirm: true missing on the process request (409)
ROLLOVER_NOTHING_TO_PROCESSNo non-terminal included items remain (409)
ROLLOVER_ITEM_NOT_FOUNDItem lookup by student/cycle fails
ROLLOVER_ITEM_LOCKEDItem is currently locked by a running process (SKIP LOCKED conflict surfaced)
ROLLOVER_ITEM_LINK_STALEItem's linked enrollment/graduation no longer matches expectations
ROLLOVER_CLASS_INVALIDTarget class does not belong to the target grade/session
ROLLOVER_ROLL_POLICY_AUTOMATICRoll-allocation preview/apply refused because school_profile.roll_number_auto_renumber is true — a manual roll-allocation proposal has nothing to apply against
ROLLOVER_ROLL_INVALIDRoll number outside 1–999 or already taken in scope
ROLLOVER_OVERRIDE_NOT_ALLOWEDCapacity override attempted without the RolloverOverrides permission or while policy is block
ROLLOVER_ACTIVATION_BLOCKEDA preflight blocking code is still present
ROLLOVER_WARNINGS_UNACKNOWLEDGEDActivation requested with allowActivationWithExceptions = false and warnings unresolved
ROLLOVER_SOURCE_NOT_CURRENTSource session is not (or is no longer) the current session where that is required
ROLLOVER_CANCEL_NOT_ALLOWEDCycle already has placements/graduations, so it can no longer be cancelled outright
ROLLOVER_CORRECTION_GRADE_MISMATCHCorrection target grade conflicts with the item's decision outcome
ROLLOVER_CORRECTION_INVALIDCorrection requested against an item state it doesn't apply to
ROLLOVER_SETTINGS_STALESettings PATCH sent with an outdated version
ROLLOVER_NOTIFICATION_INVALID_STATENotification action requested against a cycle not in activated/finalized
ACADEMIC_SESSION_CURRENT_MANAGED_BY_ROLLOVERAttempt to switch the current session manually while a rollover cycle is in flight (the guard via: "rollover" deliberately bypasses)
ENROLLMENT_LINKED_TO_ROLLOVERAttempt to directly delete an enrollment a rollover item owns
ACADEMIC_SESSION_LOCKEDAny write against a locked session's enrollments/classes — mapped to 409 CONFLICT by all-exceptions.filter.ts, with message "That academic session is locked. Its placements and classes can no longer change."
ACADEMIC_SESSION_IN_USEDELETE /academic-sessions/:publicId refused because a promotion cycle or a rollover cycle/item still references the session through a restrict foreign key — retire the session (isActive = false) instead

Non-HTTP outcome/state codes (shared/rollover-codes.ts)

These never appear as an HTTP status — they populate exceptionCodes, warningCodes, outcomeCode, failureCode, or a preflight/integrity report, for an operator to act on.

Code groupValuesMeaning / operator action
Exception codes (15)INVALID_STUDENT_STATUS, MISSING_DECISION, DECISION_PENDING, DECISION_CHANGED, CONDITIONAL_NOT_ALLOWED, MISSING_TARGET_GRADE, TARGET_GRADE_NO_CLASS, MISSING_SECTION, TARGET_CLASS_INVALID, SECTION_CAPACITY, DUPLICATE_PLACEMENT, GRADUATE_HAS_TARGET_PLACEMENT, EXISTING_GRADUATION, DUPLICATE_ROLL, ADMISSION_AFTER_TARGET_ENDBlock processing of that item until resolved — e.g. MISSING_DECISION means finalize the promotion decision first; TARGET_GRADE_NO_CLASS means create a class in that grade; SECTION_CAPACITY means allocate elsewhere or apply a capacity override.
Warning codes (3)MISSING_SECTION, MISSING_ROLL, ALREADY_PLACEDNon-blocking — the item can still process, but the operator should notice (e.g. it will land in awaiting_section). MISSING_ROLL is suppressed entirely when school_profile.roll_number_auto_renumber is true — an unassigned roll under an automatic policy is expected, not a gap for an operator to fill in.
Processing warnings (2)ALREADY_PLACED, ROLL_REASSIGNEDRecorded during processing itself, distinct from pre-processing validation warnings.
Outcome codes (19)The 15 exception codes above, plus TARGET_SESSION_LOCKED, ENROLLMENT_REFUSED, UNEXPECTED_ERROR, DECISION_WITHDRAWNStamped on outcomeCode once an item finishes processing — explains exactly why an item did not simply place.
Run failure codes (4)WORKER_FAILED, RECOVERY_EXHAUSTED, ITEMS_UNPROCESSED, ACTOR_MISSINGStamped on the cycle's failureCode — RECOVERY_EXHAUSTED means manual re-processing is required; ACTOR_MISSING means the worker actor (a deactivated user) could no longer be resolved.
Integrity codes (8)DUPLICATE_ACTIVE_PLACEMENT, ITEM_LINK_STALE, DECISION_CHANGED_AFTER_PLACEMENT, ROLL_CONFLICT, SOURCE_SNAPSHOT_DRIFT, GRADUATE_WITH_PLACEMENT, ORPHAN_ITEM, ACTIVE_STUDENT_WITHOUT_ITEMSurfaced only by the read-only integrity report (API §8.31) — each names a cross-check between rollover's own bookkeeping and the live enrollment/graduation tables that has drifted, for manual investigation. The enrollment-linkage checks (ITEM_LINK_STALE, DECISION_CHANGED_AFTER_PLACEMENT) are scoped to the enrollment ids this cycle's own placed items reference, not every enrollment in the target session, and every check ignores soft-deleted pupils.
Preflight blocking codes (7)RUN_IN_PROGRESS, NOT_PROCESSED, PARTIALLY_COMPLETED, EXCEPTIONS_NOT_ALLOWED, TARGET_INACTIVE, TARGET_LOCKED, SOURCE_NOT_CURRENTAny one present means activation is refused until resolved.
Preflight warning codes (3)EXCEPTIONS_PRESENT, AWAITING_SECTION, TARGET_NOT_STARTEDActivation may still proceed (subject to allowActivationWithExceptions) but the operator is shown these first.
Allocation unassigned reasons (3)NO_CLASS, NO_CLASS_FOR_SHIFT, CAPACITYExplains why a specific pupil was left out of an allocation proposal.
Terminal item statuses (3)placed, graduated, withdrawnNever revisited by sync or validation.
Non-terminal item statuses (4)pending, awaiting_section, needs_review, failedStill eligible for sync, validation, and (re-)processing.

14. Observability

SignalLocationPurpose
LogLogger in RolloverRunnerService, AcademicYearSweepService, RolloverAdminNoticeServiceEvery lease claim, heartbeat failure, recovery, and finish is logged — this is the only visibility into a run once it leaves the request/response cycle.
Admin noticerollover_admin_notice.service.ts → notification rowsprocessingFinished (with placed/graduated/exception/failed counts), processingFailed, processingStopped (with the exact failureCode reason) — delivered on rolloverSettings.adminChannels, default ["in_app"].
Auditrollover_eventsThe full, append-only, per-cycle timeline — every state transition, allocation apply, correction, and lock/unlock is recorded there with actor and before/after values.
Integrity reportGET /rollover/cycles/:publicId/integrityOn-demand cross-check between rollover bookkeeping and live enrollment/graduation state.

15. Testing and Validation

Test TypeFilesCoverage
Unit/integrationrollover/**/__tests__, rollover/shared/__tests__, rollover/workers/__tests__Service methods, allocation algorithms, lease/recovery behavior

Validation commands: pnpm --filter @skoolsewa/api test, pnpm --filter @skoolsewa/api test:structure (route/guard fingerprint), pnpm rules (error codes, job ids). Tests run against the real database per this repo's testing rules — no mocked schema, no mocked queue.

17. Zero-Omission Backend Checklist

  • Every controller, service, DTO, schema table, job, and worker in the module is represented above.
  • Every table has a field table with nullability, defaults, and constraints.
  • Every lifecycle (cycle, item) has a transition table and state diagram (see Feature §7).
  • Every runtime flow (process request, chunk loop, sweep, session lock) has a sequence diagram and branch notes.
  • Every business invariant enforced by schema constraint or service logic is called out inline.
  • Every code in rollover-codes.ts, the error-code registry, and the exception filter mapping is documented.
  • Every architectural tradeoff (fingerprint gate, async chunking, superadmin default, sweep design) is documented with rationale.

18. Backend Completion Checklist

  • Module boundaries are documented.
  • Every controller, service, DTO, schema file, job, and event is covered.
  • Every database table has a field table and relationship diagram.
  • Every runtime flow has a diagram and branch notes.
  • API and feature/flows docs are linked.
  • No claim is made without a source file checked this session.

See Also

On this page

Academic Year Rollover Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tablesrollover_settings — singleton configurationrollover_cyclesrollover_itemsstudent_graduationsrollover_events — append-only timelineacademic_sessions — lock columns (owned by academic-sessions, consumed here)5.3 Relationship Diagram6. Services and Responsibilities6.1 RolloverCyclesService6.1a RolloverCycleReviewService6.2 RolloverCycleViewService / RolloverOverviewService6.3 RolloverSyncService6.4 Section Allocation — RolloverSectionAllocationService and apps/api/src/utils/rollover/section-allocation.util.ts6.5 Roll Allocation — RolloverRollAllocationService and apps/api/src/utils/rollover/roll-allocation.util.ts6.6 RolloverValidationService6.7 RolloverFingerprintService6.8 RolloverReadinessService6.9 Rollover Corrections and Realign services6.10 RolloverPlacementService6.11 RolloverActivationService6.12 RolloverCycleAccessService6.13 RolloverSectionsViewService6.14 PlacementNotificationService7. Runtime Flows7.1 Processing request → enqueue7.2 Chunked processing loop7.3 Recovery sweep7.4 Session lock enforcement8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error HandlingDomain error codes (apps/api/src/common/types/error-codes.ts)Non-HTTP outcome/state codes (shared/rollover-codes.ts)14. Observability15. Testing and Validation17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also