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
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | rollover.module.ts, each submodule's *-admin.module.ts, rollover-shared.module.ts, academic-year-worker.module.ts | Composition of cycles, items, allocation, processing, activation, corrections, settings, insights, and workers |
| Controllers | cycles/admin, items/admin, allocation/admin, processing/admin, activation/admin, corrections/admin, settings/admin, insights/admin controllers | Routes and permission decorators |
| Services | */core/*.service.ts, shared/*.service.ts, workers/*.service.ts | Business logic, transactions, side effects |
| DTOs | */dto/*.ts | Request/response contracts |
| Schema | packages/db/src/schema/school/rollover.ts, academic-sessions.ts | Tables, enums, indexes, constraints |
| Migrations | 0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sql | Append-only trigger, session lock trigger |
| Jobs | packages/jobs/src/index.ts | QueueName.ACADEMIC_YEAR, AcademicYearJob, payload shapes |
| Utils | apps/api/src/utils/rollover/*.util.ts | Section/roll allocation algorithms, fingerprint |
| Sibling modules | academic-sessions.service.ts, enrollments-core/class-enrollments.service.ts, enrollments-core/shared/enrollment-removal-guards.ts | switchCurrent, 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
promotionsmodule. See Promotions backend. - The academic session entity and
is_currentswitch mechanics — owned byacademic-sessions; rollover calls into it, it does not duplicate it. - Enrollment writing and removal mechanics — owned by
enrollments-core; rollover callsClassEnrollmentsService.enrollrather 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
| Concern | Source of Truth | Notes |
|---|---|---|
| Cycle and item state | rollover_cycles, rollover_items tables | Never cached; every read hits Postgres. |
| Which session is current | academic_sessions.is_current | Rollover flips it through switchCurrent, never directly. |
| Whether a session is locked | academic_sessions.locked_at | Enforced twice: application guard and a database trigger. |
| A pupil's actual placement | student_class_enrollments | Rollover items reference it (enrollmentId) but do not duplicate its fields. |
| Whether processing is currently running | rollover_cycles.runState, leaseToken, leaseExpiresAt | Authoritative for the worker's lease protocol. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
RolloverModule | Aggregate | apps/api/src/modules/rollover/rollover.module.ts | None | None | Composes leaves | Wires every rollover submodule and the worker module into the app |
RolloverCyclesAdminModule | Leaf | cycles/rollover-cycles-admin.module.ts | RolloverCyclesController | RolloverCyclesService, RolloverCycleReviewService, RolloverCycleViewService, RolloverOverviewService | Cycle CRUD, sync/review/cancel, overview | Cycle lifecycle and overview |
RolloverItemsAdminModule | Leaf | items/rollover-items-admin.module.ts | RolloverItemsController | RolloverItemsService, RolloverItemsQueryService | Item mutation and query | Per-pupil item management |
RolloverAllocationAdminModule | Leaf | allocation/rollover-allocation-admin.module.ts | RolloverAllocationController | RolloverSectionAllocationService, RolloverRollAllocationService | Allocation preview/apply | Section and roll allocation |
RolloverProcessingAdminModule | Leaf | processing/rollover-processing-admin.module.ts | RolloverProcessingController | RolloverProcessingService | Process trigger, progress | Enqueue processing and report progress |
RolloverActivationAdminModule | Leaf | activation/rollover-activation-admin.module.ts | RolloverActivationController | RolloverActivationService | Preflight, activate, finalize, lock | Year switch orchestration |
RolloverCorrectionsAdminModule | Leaf | corrections/rollover-corrections-admin.module.ts | RolloverCorrectionsController | RolloverCorrectionsService, RolloverRealignService | Corrections | Post-processing repair |
RolloverSettingsAdminModule | Leaf | settings/rollover-settings-admin.module.ts | RolloverSettingsController | RolloverSettingsService | Settings CRUD | Strategy and policy configuration |
RolloverInsightsAdminModule | Leaf | insights/rollover-insights-admin.module.ts | RolloverInsightsController | RolloverInsightsService | Read-only reports | Events, integrity, ID cards, new admissions |
RolloverSharedModule | Shared | shared/rollover-shared.module.ts | None | Readiness, validation, sync, fingerprint, placement, sections-view, cycle-access, process-request, run, placement-notification services | Exported to every leaf above | Cross-cutting rollover logic |
AcademicYearWorkerModule | Worker | workers/academic-year-worker.module.ts | None | AcademicYearQueueProcessor, RolloverRunnerService, AcademicYearSweepService, AcademicYearScheduler, RolloverAdminNoticeService | Background execution | BullMQ 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| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/rollover-codes.ts | Central registry of every non-HTTP outcome/exception/warning/integrity code | EXCEPTION_CODES, WARNING_CODES, OUTCOME_CODES, INTEGRITY_CODES, PREFLIGHT_BLOCKING_CODES and more | See §13 for the full list |
shared/rollover-worker.contract.ts | Shapes passed between the runner and the placement service | RolloverSettingsSnapshot, RolloverItemOutcome, ProcessItemContext | Frozen settings snapshot at process-request time |
shared/with-lock-retry.ts | Retries a callback once on Postgres serialization failure/deadlock | withLockRetry | Relies on the caller's callback being idempotent |
shared/worker-actor.ts | Builds a synthetic AuthUser for background writes | buildWorkerActor | Keeps worker-made audit entries distinguishable from a live admin |
workers/rollover-runner.service.ts | The chunked processing loop | RolloverRunnerService.run() | Lease, heartbeat, per-item transaction |
workers/academic-year-sweep.service.ts | Stalled-run and stuck-notification recovery | AcademicYearSweepService.run() | Runs on the 5-minute scheduler tick |
utils/rollover/section-allocation.util.ts | Pure section-distribution algorithms | allocateSections (4 strategies) | No NestJS imports |
utils/rollover/roll-allocation.util.ts | Pure roll-number-assignment algorithms | allocateRolls (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.sql5.2 Tables
rollover_settings — singleton configuration
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id | smallint | No | 1 | Enforced singleton — a check constraint pins it to 1. |
allow_conditional_placement | boolean | No | true | Whether a conditional promotion outcome may still be placed. |
allow_placement_without_section | boolean | No | true | Whether an item can be placed-eligible with no target class yet chosen. |
capacity_policy | enum (block, allow_override) | No | block | Whether hitting section capacity is a hard stop or overridable per item. |
default_section_strategy | enum (balanced, keep_previous, alphabetical, previous_roll) | No | balanced | Default preselected in the allocation preview UI. |
preserve_shift | boolean | No | true | Whether section allocation restricts a pupil to classes sharing their prior shift. |
roll_strategy | enum (alphabetical, previous_roll, keep_previous) | No | alphabetical | Default roll-allocation strategy. |
roll_scope | enum (section, grade) | No | section | Whether roll numbers are unique per class or per grade. |
allow_activation_with_exceptions | boolean | No | true | Whether unresolved exceptions block activation or only warn. |
notify_placement | boolean | No | true | Whether guardians/pupils receive a placement notification at activation. |
placement_channels | text[] | No | ['in_app','push'] | CHECK constraint restricts values to email, sms, push, in_app. |
admin_channels | text[] | No | ['in_app'] | Same allowed set, for admin notices. |
id_card_reminder | boolean | No | true | Whether the ID-card insight nudges admins post-placement. |
balance_gender | boolean | No | false | Whether 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_days | smallint | No | 180 | How 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. |
version | bigint | No | — | Optimistic-lock token; every PATCH must send the current value. |
updated_at / updated_by_user_id | timestamp / uuid FK users | Yes | — | Audit of the last change. |
rollover_cycles
| Column | Type | Nullable | Notes |
|---|---|---|---|
id / public_id | uuid PK / uuid | No | public_id is the externally addressed identifier. |
code | text | No | Unique, format RY-[A-Z0-9-]{1,60}. |
source_session_id / target_session_id | int FK academic_sessions | No | Constraint enforces they are distinct. |
promotion_cycle_id | uuid | No | Paired with source_session_id as a composite reference into promotions. |
status | enum (preparing, processed, activated, finalized, cancelled) | No | See §7.2 lifecycle. |
run_state | enum (idle, running) | No | Drives the lease protocol. |
run_outcome | enum (completed, completed_with_exceptions, partially_completed) | Yes | Set once processing finishes. |
confirmed_fingerprint / confirmed_at / confirmed_by_user_id | text / timestamp / uuid | Yes | Set by review; compared at process time. |
settings_snapshot | jsonb | Yes | Frozen RolloverSettingsSnapshot for the run that used it. |
attempt | int | No | Incremented each time processing is (re)requested. |
recovery_count | int | No | Incremented by the sweep, capped at 5 before RECOVERY_EXHAUSTED. |
lease_token / lease_expires_at / last_heartbeat_at | uuid / timestamp / timestamp | Yes | The worker lease. |
run_started_at / run_finished_at / failure_code | timestamp / timestamp / text | Yes | Populated on run completion or forced failure. |
activated_at / activated_by_user_id | timestamp / uuid | Yes | Set on activation. |
finalized_at / finalized_by_user_id | timestamp / uuid | Yes | Set on finalize. |
placement_notification_state | enum (none, queued, sent, failed) | No | Tracks the NOTIFY_PLACEMENTS job. |
notification_attempt | int (0–100) | No | Bounded retry counter for stuck notifications. |
cancelled_at / cancelled_by_user_id / cancel_reason | timestamp / uuid / text | Yes | Set on cancel. |
started_by_user_id | uuid | No | Actor who created the cycle. |
version | bigint | No | Optimistic 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
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | bigserial PK | No | Internal only — items are addressed by (cyclePublicId, studentPublicId) at the API. |
cycle_id | uuid FK rollover_cycles | No | ON DELETE CASCADE. |
student_id | uuid FK students | No | ON DELETE CASCADE. |
source_enrollment_id | uuid FK student_class_enrollments | Yes | The pupil's enrollment in the source session at sync time. |
source_class_id / source_grade_id / source_section_id | int FK, RESTRICT | No | Snapshot of where the pupil was. |
source_shift | enum (morning, day) | No | Used for preserveShift. |
source_roll_number | smallint | Yes | Used by keep_previous/previous_roll strategies. |
decision_id | uuid FK promotion decisions, NO ACTION | No | Points at the promotion decision this item was synced from. |
outcome | enum (shared with promotions) | No | Snapshot of the decision outcome at last sync. |
target_grade_id / target_class_id | int FK, RESTRICT | Yes | Set by allocation or manual edit. |
planned_roll_number | smallint | Yes | Range 1–999 (see below). |
inclusion | enum (included, excluded) | No | Whether the pupil is part of this run at all. |
exclusion_source | enum (manual, decision) | Yes | Whether exclusion came from an admin or from the decision itself. |
exclusion_reason | text | Yes | Required when inclusion = excluded. |
capacity_override | boolean | No | Default false. Set by RolloverOverrides_UPDATE. |
override_reason / override_by_user_id / override_at | text / uuid / timestamp | Yes | Required together with capacity_override. |
status | enum (pending, placed, graduated, awaiting_section, needs_review, failed, withdrawn) | No | See §7.2. |
exception_codes / warning_codes / processing_warnings | text[] | No | Reset and recomputed by validation on every mutation batch. |
outcome_code | text | Yes | Set once processed — one of the codes in §13. |
enrollment_id | uuid FK student_class_enrollments, NO ACTION | Yes | Set once placed. |
graduation_id | uuid FK student_graduations, NO ACTION | Yes | Set once graduated. |
process_order | int | No | Deterministic processing order — the runner's cursor. |
attempts | int (0–1000) | No | Incremented on an item-level failure. |
processed_at / validated_at / placement_notified_at | timestamp | Yes | — |
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
| Column | Type | Nullable | Notes |
|---|---|---|---|
id / public_id | uuid PK / uuid | No | — |
student_id | uuid FK students | No | ON DELETE CASCADE. |
promotion_decision_id | uuid FK decisions | No | NO ACTION. |
rollover_cycle_id | uuid FK rollover_cycles | No | NO ACTION. |
academic_session_id | int FK sessions | No | RESTRICT — the session the pupil graduated from. |
final_class_id / final_grade_id | int FK, RESTRICT | No | The pupil's last class/grade. |
graduated_on | date | No | — |
revoked_at / revoked_by_user_id / revoke_reason | timestamp / uuid / text | Yes | Set by a correction that undoes a graduation. |
created_at / created_by_user_id | timestamp / uuid | No | — |
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
| Column | Type | Nullable | Notes |
|---|---|---|---|
id | bigserial PK | No | — |
cycle_id | uuid FK rollover_cycles | Yes | ON DELETE CASCADE. |
session_id | int FK sessions | No | RESTRICT. |
item_id | bigint FK rollover_items | Yes | ON DELETE SET NULL. |
student_id | uuid FK students | Yes | ON DELETE SET NULL. |
event_type | text | No | One of 29 known types (see below). |
actor_user_id | uuid FK users | Yes | Null for a synthetic worker actor's own audit trail elsewhere; events still record the effective actor. |
from_value / to_value | jsonb | Yes | Before/after snapshot relevant to the event. |
reason | text (≤1000 chars) | Yes | — |
created_at | timestamp (clock_timestamp()) | No | Wall-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)
| Column | Type | Nullable | Notes |
|---|---|---|---|
locked_at | timestamp with time zone | Yes | Set by AcademicYearLock. |
locked_by_user_id | uuid FK users, ON DELETE SET NULL | Yes | — |
lock_reason | text | Yes | 3–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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create() | RolloverCyclesController | academic_sessions, existing cycles, promotion cycle | rollover_cycles | Delegates roster sync to RolloverSyncService | ROLLOVER_CYCLE_EXISTS, ROLLOVER_SAME_SESSION, ROLLOVER_TARGET_BEFORE_SOURCE, ROLLOVER_TARGET_LOCKED, ROLLOVER_TARGET_INACTIVE, ROLLOVER_PROMOTION_NOT_FINALIZED |
list() | Controller | rollover_cycles | None | None | — |
6.1a RolloverCycleReviewService
State transitions on an existing cycle, kept apart from RolloverCyclesService so each stays a single-purpose service.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
sync() | POST /rollover/cycles/:publicId/sync | Promotion decisions, source-session enrollments, items | rollover_items, rollover_events | Locks the cycle, re-runs RolloverSyncService.sync then validateCycle, writes an items_synced event | ROLLOVER_INVALID_STATE on a cancelled cycle |
review() | POST /rollover/cycles/:publicId/review | Items, classes, settings, sessions | rollover_items (sync and validation codes) | Locks the cycle, syncs and re-validates, then returns the fingerprint and review summary; the fingerprint itself is not stored | ROLLOVER_INVALID_STATE on a cancelled cycle |
cancel() | DELETE /rollover/cycles/:publicId | Cycle, placed/graduated counts | status = cancelled | Inserts cycle_cancelled event | ROLLOVER_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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
syncItems(cycleId) | RolloverCyclesService.create, POST /rollover/cycles/:publicId/sync | Promotion decisions, source-session enrollments, existing non-terminal items | rollover_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 whenpreserveShiftnarrows it and a same-shift class exists), then assign to the eligible class with the lowestoccupied + assignedThisPass(ties broken by section order). A student with no eligible class with room is left unassigned. WhenrolloverSettings.balanceGenderis on and the student's owngenderismaleorfemale, 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 asother,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 thebalancedalgorithm.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:blockSizestarts atmin(capacityLeft(class), ceil(remainingStudents / remainingClasses)), then is raised (water-filled) to at leastremainingStudents - totalCapacityLeftInLaterClassesso a later, smaller class can never be left with more students than it has room for, and is finally clamped back tocapacityLeft(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 toalphabetical, 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:
- Sort all 85 students by name.
- For the first student: both Section A and Section B have
occupied = 0; tie broken by section order → assigned to A.A.assigned = 1. - 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. - 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.
- 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 takesnextFreein order.alphabetical— sort items by name; each takesnextFreein that order.previous_roll— sort items by source roll then section order; each takesnextFreein 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
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
validate(cycleId) | Cycle creation, sync, review, allocation apply, corrections, item edits, run finish, sweep stop, settings update | Items, target classes, live enrollments, settings, school_profile.roll_number_auto_renumber | exception_codes, warning_codes and validated_at on items whose codes changed | None (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:
| Group | Checks |
|---|---|
academic_year | sessions_exist, target_after_source, target_active_unlocked, target_not_started, target_calendar_days |
promotions | promotion_cycle_finalized, pending_decisions |
classes | target_grades_have_classes, capacity_vs_demand, class_teacher_assigned |
subjects | class_subjects_configured |
schedule | published_timetable |
students | student_categories |
id_cards | id_cards_needed |
attendance | attendance |
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:
| Case | Write order |
|---|---|
move — placed → different class, same grade | 1. 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 link | 1. 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/pending | 1. 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 → graduated | 1. 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 grade | 1. 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 → placeable | 1. 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/pending | 1. Update graduation → revoked. 2. Update item → withdrawn, graduationId = null, outcomeCode = DECISION_WITHDRAWN. 3. Insert graduation_revoked event. |
realign (f) withdrawn → placeable/graduated | 1. 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
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | RolloverProcessingController.process | Validates DTO (fingerprint, confirm: true) | 400 if confirm is not true |
| 2 | RolloverProcessRequestService.requestProcess | Locks cycle, verifies fingerprint | 409 ROLLOVER_PREVIEW_STALE on mismatch |
| 3 | Same | Checks non-terminal included items exist | 409 ROLLOVER_NOTHING_TO_PROCESS |
| 4 | Same | Writes outbox row in the same transaction as the attempt bump | Enqueue 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 conditionalUPDATE ... 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) raisesRolloverLeaseHeldError, which BullMQ's own retry/backoff handles. - Chunk size: 50 items per pass, fetched with
FOR UPDATE SKIP LOCKEDordered byprocess_order—SKIP LOCKEDlets 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 markedfailedwithoutcomeCode = UNEXPECTED_ERROR,attemptsincremented (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 markedneeds_review/INVALID_STUDENT_STATUSrather than placed — this covers the graduation path in particular, sinceenroll()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_atandlast_heartbeat_atwherelease_tokenstill matches; if another attempt has since claimed the lease, this raisesRolloverLeaseLostError, the runner stops writing immediately, and the run reportslease_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 theACADEMIC_YEARqueue'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_outcomefrom the item-status counts (or forcespartially_completedif afailure_codewas set), transitionspreparing → processed(other statuses left alone — a re-process of an already-processedcycle doesn't regress it), clears the lease, insertsprocessing_finished/processing_failed, callsRolloverAdminNoticeService, and — ifnotifyPlacementis enabled, the cycle isactivated/finalized, and unnotified placements exist — enqueuesNOTIFY_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
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.ACADEMIC_YEAR ("academic-year") | AcademicYearJob.PROCESS_ROLLOVER | RolloverProcessRequestService (via outbox) | RolloverRunnerService.run() | {v:1, requestId, cyclePublicId, attempt} | BullMQ default attempts; lease-based application-level retry on top | Lease keyed by (cycleId, attempt); a stale attempt cannot double-run |
| Same | AcademicYearJob.ROLLOVER_RECOVERY_SWEEP | AcademicYearScheduler (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 outcome | jobId bucketed by 5-minute window via buildJobId(["academic-year-sweep", bucket]), deduplicating within the window |
| Same | AcademicYearJob.PUBLISH_PROMOTION_DECISIONS | Promotions module (via outbox) | PromotionPublicationService.publish() | {v:1, requestId, cyclePublicId, attempt} | Sweep re-enqueues if stuck | — |
| Same | AcademicYearJob.NOTIFY_PLACEMENTS | RolloverActivationService / 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
JwtAuthGuardplusRoleGuard, 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 standardCREATE/READ/UPDATE/DELETE/RESTOREcodes. - Superadmin-only by default:
RolloverProcessing,RolloverOverrides,RolloverCorrections,RolloverActivation,AcademicYearLock, andRolloverSettingsare all inSUPERADMIN_ONLY_MODULES— onlyRolloveritself 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
AuthUserbuilt byworker-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 in0036_promotions_rollover_guards.sqlthat 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:
assertEnrollmentRemovableinenrollments-core/shared/enrollment-removal-guards.tsrefuses 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_eventstrigger 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 Code | Condition |
|---|---|
ROLLOVER_CYCLE_NOT_FOUND | publicId does not resolve to a cycle |
ROLLOVER_SAME_SESSION | Source and target session are identical |
ROLLOVER_TARGET_BEFORE_SOURCE | Target session does not start after the source |
ROLLOVER_TARGET_LOCKED | Target session has locked_at set |
ROLLOVER_TARGET_INACTIVE | Target session is not active |
ROLLOVER_CYCLE_EXISTS | A live cycle already targets this source/target/promotion-cycle triple |
ROLLOVER_INVALID_STATE | Requested action is not valid for the cycle's current status/run state |
ROLLOVER_PROMOTION_MISMATCH | Promotion cycle does not match the source session |
ROLLOVER_PROMOTION_NOT_FINALIZED | Source promotion cycle isn't finalized yet |
ROLLOVER_PREVIEW_STALE | Fingerprint at process time doesn't match the confirmed one (409) |
ROLLOVER_PROPOSAL_STALE | Allocation apply token doesn't match the current cycle state (409) |
ROLLOVER_CONFIRMATION_REQUIRED | confirm: true missing on the process request (409) |
ROLLOVER_NOTHING_TO_PROCESS | No non-terminal included items remain (409) |
ROLLOVER_ITEM_NOT_FOUND | Item lookup by student/cycle fails |
ROLLOVER_ITEM_LOCKED | Item is currently locked by a running process (SKIP LOCKED conflict surfaced) |
ROLLOVER_ITEM_LINK_STALE | Item's linked enrollment/graduation no longer matches expectations |
ROLLOVER_CLASS_INVALID | Target class does not belong to the target grade/session |
ROLLOVER_ROLL_POLICY_AUTOMATIC | Roll-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_INVALID | Roll number outside 1–999 or already taken in scope |
ROLLOVER_OVERRIDE_NOT_ALLOWED | Capacity override attempted without the RolloverOverrides permission or while policy is block |
ROLLOVER_ACTIVATION_BLOCKED | A preflight blocking code is still present |
ROLLOVER_WARNINGS_UNACKNOWLEDGED | Activation requested with allowActivationWithExceptions = false and warnings unresolved |
ROLLOVER_SOURCE_NOT_CURRENT | Source session is not (or is no longer) the current session where that is required |
ROLLOVER_CANCEL_NOT_ALLOWED | Cycle already has placements/graduations, so it can no longer be cancelled outright |
ROLLOVER_CORRECTION_GRADE_MISMATCH | Correction target grade conflicts with the item's decision outcome |
ROLLOVER_CORRECTION_INVALID | Correction requested against an item state it doesn't apply to |
ROLLOVER_SETTINGS_STALE | Settings PATCH sent with an outdated version |
ROLLOVER_NOTIFICATION_INVALID_STATE | Notification action requested against a cycle not in activated/finalized |
ACADEMIC_SESSION_CURRENT_MANAGED_BY_ROLLOVER | Attempt to switch the current session manually while a rollover cycle is in flight (the guard via: "rollover" deliberately bypasses) |
ENROLLMENT_LINKED_TO_ROLLOVER | Attempt to directly delete an enrollment a rollover item owns |
ACADEMIC_SESSION_LOCKED | Any 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_USE | DELETE /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 group | Values | Meaning / 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_END | Block 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_PLACED | Non-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_REASSIGNED | Recorded 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_WITHDRAWN | Stamped 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_MISSING | Stamped 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_ITEM | Surfaced 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_CURRENT | Any one present means activation is refused until resolved. |
| Preflight warning codes (3) | EXCEPTIONS_PRESENT, AWAITING_SECTION, TARGET_NOT_STARTED | Activation may still proceed (subject to allowActivationWithExceptions) but the operator is shown these first. |
| Allocation unassigned reasons (3) | NO_CLASS, NO_CLASS_FOR_SHIFT, CAPACITY | Explains why a specific pupil was left out of an allocation proposal. |
| Terminal item statuses (3) | placed, graduated, withdrawn | Never revisited by sync or validation. |
| Non-terminal item statuses (4) | pending, awaiting_section, needs_review, failed | Still eligible for sync, validation, and (re-)processing. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | Logger in RolloverRunnerService, AcademicYearSweepService, RolloverAdminNoticeService | Every 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 notice | rollover_admin_notice.service.ts → notification rows | processingFinished (with placed/graduated/exception/failed counts), processingFailed, processingStopped (with the exact failureCode reason) — delivered on rolloverSettings.adminChannels, default ["in_app"]. |
| Audit | rollover_events | The 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 report | GET /rollover/cycles/:publicId/integrity | On-demand cross-check between rollover bookkeeping and live enrollment/graduation state. |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit/integration | rollover/**/__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
- API doc: /docs/developer/rollover/api
- Features and flows doc: /docs/developer/rollover/feature
- Promotions: /docs/developer/promotions/backend
Academic Year Rollover Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Academic Year Rollover.
Academic Year Rollover API Reference
Complete API contracts for Academic Year Rollover, including routes, auth, DTOs, responses, errors, examples, and integration notes.