Skoolsewa - School Management System Docs
Developer ResourcesPromotions

Promotions Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Promotions.

Promotions Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Schemapackages/db/src/schema/school/promotions.ts, packages/db/src/migrations/0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sqlTables, enums, constraints, triggers, lifecycle
Recommendation algorithmapps/api/src/utils/promotion/promotion-recommendation.util.ts, apps/api/src/modules/promotions/shared/promotion-recommendation.service.tsThe full recommendation and progression logic
Controllersapps/api/src/modules/promotions/**/*.controller.tsRoute surface and actors
Servicesapps/api/src/modules/promotions/**/*.service.tsBusiness behavior and side effects
Notificationsapps/api/src/modules/notification/templates/promotion-templates.tsFamily- and admin-facing notification copy
Permissionspackages/db/src/authorization/permission-catalog.tsModule grants and superadmin-withheld modules

2. Feature Summary

FieldValue
ModulePromotions
SubmoduleCycles, Decisions, Settings, Academic History, Portal (student/guardian)
Primary user valueTurns a pupil's exam results into a recorded, versioned, family-visible promotion outcome for an academic session, and lets an administrator correct that record after the fact without losing the original.
ActorsAdmin/superadmin (draft cycle work, corrections, settings), pupil, guardian, background worker (publication delivery, stuck-job recovery)
Main entry points/api/promotions/* admin routes, /api/mobile/student/promotion-history, /api/mobile/guardian/children/:id/promotion-history, the academic_year.publish_promotion_decisions BullMQ job, the five-minute academic-year sweep
Main outputspromotion_decisions rows (one current version per pupil per cycle, full history retained), promotion.decision_published / promotion.decision_updated notifications, an outbox-driven publication job
Related docsBackend, API

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
Promotions_* holderCreate/read/update/delete a draft cycle, recompute recommendations, run bulk/manual decisions, reset to computedFinalize, correct a finalized decision, change settingsJWT + Promotions_READ/CREATE/UPDATE/DELETEDraft-only; every write here is refused once the cycle is finalized.
PromotionFinalize_UPDATE holderFinalize a draft cycle, republish a stuck/failed publicationDraft edits (needs Promotions_UPDATE too)JWT + PromotionFinalize_UPDATEWithheld from the automatic admin grant — a school must deliberately hand this to a role.
PromotionCorrections_UPDATE holderApply a supplementary exam, correct a finalized decision, resolve a conditionDraft-phase writesJWT + PromotionCorrections_UPDATEAlso withheld from the automatic admin grant. Every write here fires a family notification inline.
PromotionSettings_* holderRead/update the singleton settings row, manage per-grade progression overrides—JWT + PromotionSettings_READ/UPDATEWithheld from the automatic admin grant.
Students_READ holderRead a pupil's admin academic-history timeline—JWT + Students_READ + PeopleAccessService.assertCanAccessSame object-level access check every other student read uses.
Pupil (student portal)Read their own promotion and academic historyRead anyone else'sJWT, no permission code — audience-assertedGET /api/mobile/student/promotion-history; 404 if the caller has no live students row.
Guardian (guardian portal)Read their own child's promotion and academic historyRead a child that is not theirsJWT, no permission code — audience-assertedresolveOwnChildId throws 404, never 403, for someone else's child.
Worker/systemDeliver promotion.decision_published/_updated notifications for a finalized cycle, requeue a stuck publication, mark publication failed after 5 attempts—Internal (BullMQ)Idempotent by cycle publicId + publicationAttempt.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Create a promotion cycleAdminPromotions_CREATEPOST /api/promotions/cyclesAcademic session, examspromotion_cycles, promotion_cycle_examsAPI §8.1
List / read cyclesAdminPromotions_READGET /api/promotions/cycles[/:publicId]promotion_cycles, decisions counts—API §8.2–8.3
Update a draft cycleAdminPromotions_UPDATEPATCH /api/promotions/cycles/:publicIdCycle, examsName, basis examsAPI §8.4
Cancel a draft cycleAdminPromotions_DELETEDELETE /api/promotions/cycles/:publicIdCycle, rollover cyclespromotion_cycles.status='cancelled'API §8.5
Recompute recommendationsAdminPromotions_UPDATEPOST /api/promotions/cycles/:publicId/recomputeRoster, exam results, gradespromotion_decisions (auto sources only)API §8.6
Finalize a cycleAdminPromotionFinalize_UPDATEPOST /api/promotions/cycles/:publicId/finalizeRoster, decisions, settingspromotion_cycles.status='finalized', outbox rowAPI §8.7
Republish a stuck cycleAdminPromotionFinalize_UPDATEPOST /api/promotions/cycles/:publicId/republishCyclepublication_state='queued', outbox rowAPI §8.8
Apply a supplementary examAdminPromotionCorrections_UPDATEPOST /api/promotions/cycles/:publicId/supplementaryRoster, decisions, exampromotion_cycle_exams, promotion_decisionsAPI §8.9
List a cycle's decisionsAdminPromotions_READGET /api/promotions/cycles/:publicId/decisionspromotion_decisions—API §8.10
A pupil's decision historyAdminPromotions_READGET /.../students/:studentPublicId/historypromotion_decisions (all versions)—API §8.11
Manually override one pupilAdminPromotions_UPDATEPUT /.../students/:studentPublicId/decisionCurrent decisionNew manual versionAPI §8.12
Bulk-decide many pupilsAdminPromotions_UPDATEPOST /.../decisions/bulkCurrent decisionsNew bulk versionsAPI §8.13
Reset to computedAdminPromotions_UPDATEPOST /.../decisions/resetCurrent decisions, fresh recomputeNew recommendation versionsAPI §8.14
Correct a finalized decisionAdminPromotionCorrections_UPDATEPOST /.../students/:studentPublicId/correctCurrent decision, rosterNew correction version, notificationAPI §8.15
Resolve a conditionAdminPromotionCorrections_UPDATEPOST /.../students/:studentPublicId/conditionCurrent decisionNew condition version, notificationAPI §8.16
Read/update settingsAdminPromotionSettings_*GET/PATCH /api/promotions/settingspromotion_settingsSingleton rowAPI §8.17–8.18
Manage grade progressionsAdminPromotionSettings_*GET/PUT/DELETE /api/promotions/settings/progressions...grades, promotion_grade_progressionsPer-grade override rowAPI §8.19–8.21
Read a pupil's academic history (admin)AdminStudents_READGET /api/promotions/students/:studentPublicId/academic-historyEnrollments, decisions, graduations—API §8.22
Read own promotion historyPortalStudent (audience)GET /api/mobile/student/promotion-historyOwn enrollments, decisions, graduations—API §8.23
Read a child's promotion historyPortalGuardian (audience)GET /api/mobile/guardian/children/:id/promotion-historyChild's enrollments, decisions, graduations—API §8.24
Deliver publication notificationsWorkerSystemacademic_year.publish_promotion_decisions jobUn-notified current decisionsnotifiedAt, publication_stateBackend §9
Recover a stuck publicationWorkerSystemFive-minute academic-year sweeppromotion_cycles.publication_stateRe-queue or mark failedBackend §9

5. User-Facing Flows

5.1 A pupil reads their own promotion result

Summary

A logged-in pupil opens their academic-history screen. The backend proves the caller's audience is student, resolves their own students row, and returns every session's placements, promotion outcome (once finalized), and graduation record — nothing that belongs only to staff.

Preconditions

  • Valid JWT, activeRole.scopeKind === "student".
  • A live (non-soft-deleted) students row for the caller's userId.

Main Flow

StepActor/SystemActionResultSource
1PupilGET /api/mobile/student/promotion-historyRequest accepted (global JwtAuthGuard)student-promotion-history-portal.controller.ts
2BackendassertPortalAudience(actor, "student")403 if the active role is not studentportal-audience.ts
3BackendResolve own students.id where userId = actor.id AND deletedAt IS NULL404 STUDENT_NOT_FOUND if noneController
4BackendAcademicHistoryReaderService.read(studentId)One entry per academic session touchedacademic-history-reader.service.ts
5BackendMap to AcademicHistoryEntryDto[]Response returned—

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Wrong audienceActive role is not studentRefused before any row is read403
No live student rowDeleted or never provisionedRefused404 STUDENT_NOT_FOUND
No sessions touchedBrand-new pupil, no enrollment/decision/graduation yetEmpty array200, []
Cycle still draftA session's cycle exists but is not finalizedThat session's outcome is nullPlacements still shown

5.2 A guardian reads a child's promotion result

Summary

A guardian requests one specific child's history by id. The audience check proves the caller is a guardian; the actual ownership check is GuardianPortalService.resolveOwnChildId, which is the one implementation of "is this pupil this guardian's ward" in the codebase.

Preconditions

  • Valid JWT, activeRole.scopeKind === "guardian".
  • The :id path param resolves to one of the caller's own children.

Main Flow

StepActor/SystemActionResultSource
1GuardianGET /api/mobile/guardian/children/:id/promotion-historyRequest acceptedguardian-promotion-history-portal.controller.ts
2BackendassertPortalAudience(actor, "guardian")403 if not a guardianportal-audience.ts
3BackendGuardianPortalService.resolveOwnChildId(actor, childId)404 if childId is not this guardian's wardguardian-portal.service.ts
4BackendAcademicHistoryReaderService.read(ownChildId)Same shape as the student portalacademic-history-reader.service.ts

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not this guardian's childchildId belongs to someone elseRefused as "not found", never "forbidden"404 — prevents an id-space enumeration oracle
Wrong audienceCaller is not a guardianRefused before the child lookup403

5.3 An administrator runs a promotion cycle end to end

Summary

An admin creates a draft cycle against an academic session and its final exams, recomputes recommendations as results come in, resolves manual overrides for edge cases, and finalizes once every pupil has a usable outcome (or the admin explicitly acknowledges the pending ones).

Preconditions

  • The academic session has final exams with published results (or the admin accepts recompute output showing pending/RESULT_NOT_PUBLISHED).
  • No other live (non-cancelled) cycle exists for the same academic session — enforced by promotion_cycles_one_live_per_session.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /api/promotions/cycles with final/supplementary exam idsCycle created in draft, basis exams linkedpromotion-cycles.service.ts#create
2AdminPOST /:publicId/recomputeEvery roster member gets a computed promotion_decisions row (source: recommendation or supplementary)promotion-cycle-recompute.service.ts
3AdminReviews pendingReasons and basisDrift on GET /:publicIdSees which pupils need attention and whypromotion-cycles.service.ts#findOne
4Admin (optional)PUT .../decision or POST .../decisions/bulk for edge casesManual/bulk decisions overwrite the recommendation for those pupils onlypromotion-decision-manual-write.service.ts
5AdminPOST /:publicId/finalizeCycle becomes finalized; a publication job is enqueued unless notifyOnPublish is offpromotion-cycle-finalize.service.ts
6WorkerDelivers promotion.decision_published to every eligible pupil and guardiannotifiedAt stamped per decision, publication_state becomes sentpromotion-publication.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Unpublished final examAny linked final exam has no published resultsFinalize refused409 PROMOTION_EXAM_NOT_PUBLISHED
Missing decisionsA roster member has no decision row at allFinalize refused409 PROMOTION_CYCLE_STALE — recompute first
Recommendations driftedA current auto decision no longer matches a fresh recomputeFinalize refused409 PROMOTION_CYCLE_STALE — recompute first
Pending pupils, not acknowledgedpendingCount > 0 and acknowledgePending is falseFinalize refused409 PROMOTION_PENDING_UNACKNOWLEDGED
Concurrent editversion sent does not match the stored rowRefused409 PROMOTION_CYCLE_STALE
Duplicate live cycleAnother non-cancelled cycle already exists for the sessionCreate refused409 PROMOTION_CYCLE_EXISTS
Rollover already references this cycleCancel attempted on a cycle a non-cancelled rollover consumesCancel refused409 PROMOTION_CYCLE_IN_USE

5.4 Supplementary exam clears a conditional promotion or a failure

Summary

After finalization, a pupil sits a supplementary exam. An admin attaches that exam to the cycle and re-evaluates every eligible current decision against it — never re-deriving a fresh progression for an already-conditional pupil, only marking the condition satisfied when the supplementary itself clears it.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /:publicId/supplementary with an exam idExam validated (uncancelled, correct session ordering) and linked as supplementarypromotion-cycle-supplementary.service.ts#attachSupplementaryExam
2BackendRecompute the whole cycleFresh ComputedRecommendation per pupilPromotionRecommendationService
3BackendFor each eligible current decision (recommendation/supplementary/conditionally_promoted), compare to the fresh computeWrites a new version only where it changedPromotionCycleSupplementaryService.apply
4BackendIf eligible + notifications onSends promotion.decision_updated per changed pupilPromotionDecisionWriterService

Branches and Edge Cases

BranchConditionBehaviorError/Result
Conditional clearedExisting outcome conditionally_promoted, fresh compute's source is supplementary and outcome is promoted/graduatedconditionStatus moves to satisfied; outcome and target grade are not replacedconditionsSatisfied count incremented
Conditional not clearedFresh compute does not resolve via a supplementary passLeft untouchedCounted as unchanged
Manual/bulk/correction decisionExisting source is manual, bulk, or correction (and outcome is not conditionally_promoted)Skipped — never overwritten silentlyReturned in skippedManual
Pupil left the sessionFresh compute flags leftSessionSkippedCounted as unchanged — rollover's concern, not this route's
Exam already attachedThe same exam is applied twiceNo duplicate link rowSecond call proceeds using the existing link

6. Admin Flows

6.1 Correct a finalized decision

Only available once the cycle is finalized (the status gate trigger only accepts source='correction' then). Requires a non-empty reason (3–1000 chars) and, for promoted/retained/conditionally_promoted, an active targetGradePublicId. Optimistic concurrency via expectedDecisionPublicId (omitted only when the pupil has no decision yet). Always notifies inline when notifyOnPublish is on.

6.2 Resolve an outstanding condition

Only for a current decision whose outcome is conditionally_promoted. conditionStatus: "failed" rewrites the outcome to retained at the pupil's own source grade; "satisfied"/"waived" keeps the outcome and target grade, only moving conditionStatus. Requires reason and expectedDecisionPublicId.

6.3 Reset to computed

Draft-only. Recomputes the whole cycle and overwrites the named pupils' current decisions with the fresh recommendation/supplementary result, discarding any manual override for exactly those pupils.

6.4 Cancel a draft cycle

Draft-only. Requires a reason (3–1000 chars). Refused if any non-cancelled rollover cycle references it (PROMOTION_CYCLE_IN_USE) — a rollover must be cancelled first.

6.5 Manage grade progression overrides

PUT /api/promotions/settings/progressions/:gradePublicId sets exactly one of "graduates" or "next grade" for a grade (never both, never neither — enforced by the DB checks promotion_grade_progressions_one_rule and _has_rule, and mirrored by the service). DELETE removes the override, reverting that grade to the default "next grade by sort order" rule.

7. Lifecycle and State Transitions

7.1 Promotion cycle

EntityFromEvent/ActionToGuard ConditionSide Effects
promotion_cycles—createdraftNo other live cycle for the sessionBasis exams linked
promotion_cyclesdraftfinalizefinalizedNo unpublished final exams, no missing/drifted auto decisions, pending acknowledgedfinalizedAt/finalizedByUserId set; publication enqueued (if notifyOnPublish)
promotion_cyclesdraftcancelcancelledNo referencing non-cancelled rollovercancelledAt/cancelReason set
promotion_cycles.publication_statenonefinalize (with notifyOnPublish) or republishqueued—publicationAttempt bumped, outbox row written
promotion_cycles.publication_statequeuedWorker completes deliverysentStill queued at completion (not superseded)notifiedAt stamped per decision
promotion_cycles.publication_statequeuedFinal job delivery fails, or sweep exhausts 5 attemptsfailedAttempt still matchesRepublishable via /republish
promotion_cycles.publication_statenone / failedrepublishqueuedCycle is finalizedNew attempt, new outbox dedupe key

7.2 A promotion decision, per pupil

Every write creates a new row; the old current row is marked superseded, never edited (enforced by the promotion_decisions_immutable trigger). Exactly one row per (cycle, student) has isCurrent = true.

From (source)EventTo (source)GuardNotes
—Recompute (draft)recommendation / supplementaryCycle draftComputed by the pure algorithm
recommendation/supplementaryManual overridemanualCycle draftPUT .../decision
recommendation/supplementaryBulk decidebulkCycle draftPOST .../decisions/bulk
Any (draft-phase)Resetrecommendation/supplementaryCycle draftRecompute overwrites the named pupils
Any currentPost-finalize correctioncorrectionCycle finalizedPOST .../correct
conditionally_promoted decisionCondition resolvedconditionCycle finalizedPOST .../condition
recommendation/supplementary/conditionally_promotedSupplementary appliedsupplementary (or condition satisfied in place)Cycle finalizedPOST .../supplementary

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Create cyclepromotion_cycles, promotion_cycle_examsNoneNoneNoneNoneNone
Recomputepromotion_decisions (new versions for drifted/added students)NoneNoneNoneNoneNone
Finalizepromotion_cycles, outbox_eventsNoneEnqueues academic_year.publish_promotion_decisionsNoneNoneDeferred to the worker
Republishpromotion_cycles, outbox_eventsNoneRe-enqueues the same jobNoneNoneDeferred to the worker
Worker publishpromotion_decisions.notified_at, promotion_cycles.publication_stateNoneConsumes the jobNoneNonepromotion.decision_published per pupil/guardian
Manual/bulk/resetpromotion_decisionsNoneNoneNoneNoneNone (draft-phase writes never notify)
Correct / conditionpromotion_decisionsNoneNoneNoneNonepromotion.decision_updated, inline, per write
Supplementary applypromotion_cycle_exams, promotion_decisionsNoneNoneNoneNonepromotion.decision_updated per changed pupil, inline
Settings updatepromotion_settingsNoneNoneNoneNoneNone (audit entry only)

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Cycle already exists for sessionSecond create while a live one exists409 PROMOTION_CYCLE_EXISTSUse the existing cycle or cancel it firstpromotion_cycles_one_live_per_session index
Recommendations drifted at finalizeExam results changed after the last recompute409 PROMOTION_CYCLE_STALERecompute, then finalize againpromotion-cycle-finalize.service.ts
Publication stuck queuedWorker crashed mid-delivery, or the job was lost before a worker claimed itNo notification for 10+ minutesThe five-minute academic-year sweep re-enqueues automatically, up to 5 attempts, then marks failedacademic-year-sweep.service.ts
Publication failed5th recovery attempt exhausted, or the final job delivery threwFamilies never notified for that cycleAdmin calls POST /:publicId/republishpromotion-cycle-finalize.service.ts#republish
Serialization conflictTwo concurrent writers collide despite the fixed cycle→student lock orderTransaction retried once automaticallyTransparent to the caller; a second collision surfaces as a normal 5xxwith-lock-retry.ts
Optimistic concurrency conflictversion/expectedDecisionPublicId stale409 PROMOTION_CYCLE_STALE / PROMOTION_DECISION_STALEReload and retryEvery write service

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
RecomputeAdding vs. changing vs. unchanged counted separatelyAdminPOST /:publicId/recomputeRecomputeResultDto{added,changed,unchanged}Only drifted/new rows are writtenpromotion-cycle-recompute.service.ts
RecomputeA left-session pupil is superseded to excluded/LEFT_SESSION even mid-draftSystemRecomputePupil disappears from the "needs a decision" setNew excluded version with a fixed reason stringpromotion-cycle-recompute.service.ts
FinalizebasisDrift surfaces only after finalizationAdminGET /:publicIdDetail response includes a live drift countComputed on read, not storedpromotion-cycles.service.ts#computeBasisDrift
Bulk decideDeduplicates studentPublicIds before writingAdminPOST .../decisions/bulkOne write per unique pupil[...new Set(...)]promotion-decision-manual-write.service.ts
Bulk decideRefuses if any listed pupil has no current decisionAdminPOST .../decisions/bulk400 PROMOTION_STUDENT_NOT_IN_CYCLENo partial writeSame file
SupplementaryAn already-linked exam is not re-linkedAdminPOST .../supplementary twiceNo duplicate promotion_cycle_exams rowexistingLink check firstpromotion-cycle-supplementary.service.ts
NotificationsPush/SMS copy for family kinds is deliberately genericSystemAny family notificationLock-screen-safe text, no outcome worddiscreet() helperpromotion-templates.ts
NotificationsCondition text is shown only if shareConditionInNotification is onSystemAny notification carrying a conditionCondition line omitted by defaultpromotion-notification-copy.ts / template condition() helper—
Academic historyNever exposes reason, basis, decidedByUserId, superseded versions, or exclusion reasonsSystemAny portal or admin-history readFamily-safe shape onlyAcademicHistoryEntryDto deliberately excludes these fieldsacademic-history.dto.ts
Decisions listisOverridden reflects the write's source, not a live recompute diffAdminGET .../decisionsCheap, page-sized queryAvoids a roster-sized recompute per list requestpromotion-decisions-read.service.ts
Decisions listUnpaginated reads are refusedAdminGET .../decisions?pagination=false400 PAGINATION_LIMIT_INVALIDA pupil roster is never dumped in one responseSame file

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
A decision version is never edited, only supersededPreserves the exact original decision for audit and appealNone visible unless viewing historypromotion_decisions_immutable triggerHistory endpoint returns every versionPromotionDecisionWriterService is the only writerapps/api/src/modules/promotions/__tests__, promotion-decisions.integration.spec.ts
Exactly one current row per (cycle, pupil)Makes "the pupil's decision" an unambiguous query—promotion_decisions_one_current partial unique index—Writer marks-then-inserts in two statementspromotion-decisions.integration.spec.ts
A decision's source constrains which cycle status may hold itPrevents a draft-only write landing in a finalized cycle (bypassing the correction path) and vice versa—promotion_decisions_status_gate trigger409s surface a named error before the DB ever raises P0001Every write service checks status firstpromotion-decisions.integration.spec.ts
A left-session pupil is always excluded / LEFT_SESSIONThe pupil's outcome for a session they are no longer enrolled in cannot be computed from exam results they may not have sat under this schoolPupil sees excluded with a plain-language reasonrecommendPromotion's leftSession short-circuitrecommendationReason shows LEFT_SESSIONCaller (recompute) supplies the boolean; the pure function never queries enrollment itselfpromotion-recommendation.util.spec.ts (fixtures)
A grade cannot both graduate and have a next gradeAn unambiguous progression rule per gradeWrong config would silently corrupt an entire grade's outcomespromotion_grade_progressions_one_rule/_has_rule CHECKs, mirrored in PromotionSettingsService.putProgression400 PROMOTION_PROGRESSION_INVALID——
Every manual/bulk/correction/condition write needs a reasonAccountability for every human overrideAdmin must justify the changerequireReason()400 PROMOTION_REASON_REQUIRED——
Family notification copy never carries an administrator's reason textA correction/exclusion reason may describe circumstances a family should not read verbatimFamily sees outcome, not rationalepromotion-templates.ts docblock + implementation———
Publication only completes from queuedPrevents a stale worker delivery from overwriting a cycle already failed or superseded by a new attempt—PromotionPublicationService.publish's conditional UPDATE—attempt mismatch → "superseded" return—

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Decisions are versioned rows, not mutable stateA family can always see the original decision and what changed itOne writer, one trigger, no update-anomaly surfaceIn-place update with an audit log tableMore rows, more joins to find "current"Mitigated by the partial unique index and covering indexes
Recompute, finalize's staleness check, and basisDrift share one functionThe three can never disagree about a pupil's outcomeOne implementation to test and reason aboutThree independent computationsNone material — this was the explicit fix for a prior defect class—
Supplementary re-evaluation only touches specific sourcesA manually-decided pupil's outcome is never silently overwritten by a later examPredictable, auditable behaviorRecompute everyone unconditionallyCorrection of a manual decision requires a separate correct callAcceptable — matches "manual means manual"
Push/SMS copy is generic for family kindsA pupil's result is not printed on a shared lock screen or a metered SMSOne rule instead of per-channel judgment callsFull detail on every channelFamily must open the app for detailDeliberate privacy tradeoff
PromotionFinalize/PromotionCorrections/PromotionSettings withheld from automatic admin grantA school must deliberately choose who can publish results or rewrite finalized historyPrevents an accidental blanket grant as new admin-adjacent modules are addedGrant everything to admin automaticallyA school must remember to grant these explicitlyDocumented in permission-catalog.ts; asymmetric because grants are never auto-revoked

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
RecomputeRoster is emptySession has no active enrollments and no prior decisionsReturns an empty map, no writes{added:0,changed:0,unchanged:0}promotion-recommendation.service.ts
FinalizeTwo admins finalize concurrentlyRace on the same cycleCycle lock (FOR UPDATE) serializes; the loser sees a stale version or NOT_DRAFT409promotion-cycle-finalize.service.ts
PublicationCycle deleted mid-delivery (erasure cascade)Worker's re-read finds no cycleReturns "missing", no exceptionJob completes normallypromotion-publication.service.ts#publish
PublicationWorker crashes after some chunks notifiedPartial notifiedAt stamps already committedRe-delivery (or recovery re-enqueue) resumes from the un-notified rows onlyNo duplicate notification (dedupe on decision publicId)promotion-publication.service.ts#sendChunk
Bulk decideSame pupil listed twice in the requeststudentPublicIds array has a duplicateDeduplicated before processingOne write, not twopromotion-decision-manual-write.service.ts
CorrectionPupil has no decision yet at allexisting is nullRequires an active enrollment to snapshot source class/grade from400 PROMOTION_STUDENT_NOT_IN_CYCLE if no active enrollment existspromotion-decision-corrections.service.ts
Grade progressionNext grade is inactiveRecompute resolves progression.kind === "next" but the target grade is not activeRecommendation is pending / NEXT_GRADE_INACTIVEVisible in pendingReasonspromotion-recommendation.util.ts#resolveProgression
Grade progressionTwo grades share the top sort order, no graduating grade namedAmbiguous "who graduates"pending / GRADUATING_GRADE_AMBIGUOUSAdmin must set an explicit progression overrideSame file
Exam applicabilityA transfer pupil sat the exam in a class no longer theirsExam's exam_classes scope doesn't include the pupil's current source class, but a result row existsStill counted applicable: truePupil is not silently excluded from the basispromotion-recommendation.service.ts#buildExamInputs
Guest/unauthenticatedNo JWT—Refused before any handler runs401 (global JwtAuthGuard)—

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Recompute/finalize/resetEnrollments, decisions, cycle exams, exam results, grades, progressions, settingspromotion_decisionsNoneFinalize enqueues publicationRecomputeResultDto/PromotionCycleSummaryDto
Manual/bulk/correction/conditionCurrent decision, target grade, settings (notify)promotion_decisionsNoneInline notification (correction/condition/supplementary only)PromotionDecisionDto
Publication deliveryUn-notified current decisions, students, users, grades, settingspromotion_decisions.notified_at, promotion_cycles.publication_stateNoneNotification sendJob result string
Academic history (any surface)Enrollments, finalized decisions, graduations, sessionsNoneNoneNoneAcademicHistoryEntryDto[]

13. Completion Checklist

  • Every feature, minor action, and submodule capability is listed.
  • Every actor has allowed and forbidden behavior.
  • Every major and minor flow includes steps, branches, and diagrams.
  • Every lifecycle has a transition table and state diagram.
  • Every flow links to the API and backend docs.
  • Related module dependencies (people, exams, academic sessions, rollover) are called out where they shape behavior.

See Also