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 Type | Files or Docs | What Was Extracted |
|---|---|---|
| Schema | packages/db/src/schema/school/promotions.ts, packages/db/src/migrations/0035_promotions_rollover.sql, 0036_promotions_rollover_guards.sql | Tables, enums, constraints, triggers, lifecycle |
| Recommendation algorithm | apps/api/src/utils/promotion/promotion-recommendation.util.ts, apps/api/src/modules/promotions/shared/promotion-recommendation.service.ts | The full recommendation and progression logic |
| Controllers | apps/api/src/modules/promotions/**/*.controller.ts | Route surface and actors |
| Services | apps/api/src/modules/promotions/**/*.service.ts | Business behavior and side effects |
| Notifications | apps/api/src/modules/notification/templates/promotion-templates.ts | Family- and admin-facing notification copy |
| Permissions | packages/db/src/authorization/permission-catalog.ts | Module grants and superadmin-withheld modules |
2. Feature Summary
| Field | Value |
|---|---|
| Module | Promotions |
| Submodule | Cycles, Decisions, Settings, Academic History, Portal (student/guardian) |
| Primary user value | Turns 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. |
| Actors | Admin/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 outputs | promotion_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 docs | Backend, API |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
Promotions_* holder | Create/read/update/delete a draft cycle, recompute recommendations, run bulk/manual decisions, reset to computed | Finalize, correct a finalized decision, change settings | JWT + Promotions_READ/CREATE/UPDATE/DELETE | Draft-only; every write here is refused once the cycle is finalized. |
PromotionFinalize_UPDATE holder | Finalize a draft cycle, republish a stuck/failed publication | Draft edits (needs Promotions_UPDATE too) | JWT + PromotionFinalize_UPDATE | Withheld from the automatic admin grant — a school must deliberately hand this to a role. |
PromotionCorrections_UPDATE holder | Apply a supplementary exam, correct a finalized decision, resolve a condition | Draft-phase writes | JWT + PromotionCorrections_UPDATE | Also withheld from the automatic admin grant. Every write here fires a family notification inline. |
PromotionSettings_* holder | Read/update the singleton settings row, manage per-grade progression overrides | — | JWT + PromotionSettings_READ/UPDATE | Withheld from the automatic admin grant. |
Students_READ holder | Read a pupil's admin academic-history timeline | — | JWT + Students_READ + PeopleAccessService.assertCanAccess | Same object-level access check every other student read uses. |
| Pupil (student portal) | Read their own promotion and academic history | Read anyone else's | JWT, no permission code — audience-asserted | GET /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 history | Read a child that is not theirs | JWT, no permission code — audience-asserted | resolveOwnChildId throws 404, never 403, for someone else's child. |
| Worker/system | Deliver 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
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Create a promotion cycle | Admin | Promotions_CREATE | POST /api/promotions/cycles | Academic session, exams | promotion_cycles, promotion_cycle_exams | API §8.1 |
| List / read cycles | Admin | Promotions_READ | GET /api/promotions/cycles[/:publicId] | promotion_cycles, decisions counts | — | API §8.2–8.3 |
| Update a draft cycle | Admin | Promotions_UPDATE | PATCH /api/promotions/cycles/:publicId | Cycle, exams | Name, basis exams | API §8.4 |
| Cancel a draft cycle | Admin | Promotions_DELETE | DELETE /api/promotions/cycles/:publicId | Cycle, rollover cycles | promotion_cycles.status='cancelled' | API §8.5 |
| Recompute recommendations | Admin | Promotions_UPDATE | POST /api/promotions/cycles/:publicId/recompute | Roster, exam results, grades | promotion_decisions (auto sources only) | API §8.6 |
| Finalize a cycle | Admin | PromotionFinalize_UPDATE | POST /api/promotions/cycles/:publicId/finalize | Roster, decisions, settings | promotion_cycles.status='finalized', outbox row | API §8.7 |
| Republish a stuck cycle | Admin | PromotionFinalize_UPDATE | POST /api/promotions/cycles/:publicId/republish | Cycle | publication_state='queued', outbox row | API §8.8 |
| Apply a supplementary exam | Admin | PromotionCorrections_UPDATE | POST /api/promotions/cycles/:publicId/supplementary | Roster, decisions, exam | promotion_cycle_exams, promotion_decisions | API §8.9 |
| List a cycle's decisions | Admin | Promotions_READ | GET /api/promotions/cycles/:publicId/decisions | promotion_decisions | — | API §8.10 |
| A pupil's decision history | Admin | Promotions_READ | GET /.../students/:studentPublicId/history | promotion_decisions (all versions) | — | API §8.11 |
| Manually override one pupil | Admin | Promotions_UPDATE | PUT /.../students/:studentPublicId/decision | Current decision | New manual version | API §8.12 |
| Bulk-decide many pupils | Admin | Promotions_UPDATE | POST /.../decisions/bulk | Current decisions | New bulk versions | API §8.13 |
| Reset to computed | Admin | Promotions_UPDATE | POST /.../decisions/reset | Current decisions, fresh recompute | New recommendation versions | API §8.14 |
| Correct a finalized decision | Admin | PromotionCorrections_UPDATE | POST /.../students/:studentPublicId/correct | Current decision, roster | New correction version, notification | API §8.15 |
| Resolve a condition | Admin | PromotionCorrections_UPDATE | POST /.../students/:studentPublicId/condition | Current decision | New condition version, notification | API §8.16 |
| Read/update settings | Admin | PromotionSettings_* | GET/PATCH /api/promotions/settings | promotion_settings | Singleton row | API §8.17–8.18 |
| Manage grade progressions | Admin | PromotionSettings_* | GET/PUT/DELETE /api/promotions/settings/progressions... | grades, promotion_grade_progressions | Per-grade override row | API §8.19–8.21 |
| Read a pupil's academic history (admin) | Admin | Students_READ | GET /api/promotions/students/:studentPublicId/academic-history | Enrollments, decisions, graduations | — | API §8.22 |
| Read own promotion history | Portal | Student (audience) | GET /api/mobile/student/promotion-history | Own enrollments, decisions, graduations | — | API §8.23 |
| Read a child's promotion history | Portal | Guardian (audience) | GET /api/mobile/guardian/children/:id/promotion-history | Child's enrollments, decisions, graduations | — | API §8.24 |
| Deliver publication notifications | Worker | System | academic_year.publish_promotion_decisions job | Un-notified current decisions | notifiedAt, publication_state | Backend §9 |
| Recover a stuck publication | Worker | System | Five-minute academic-year sweep | promotion_cycles.publication_state | Re-queue or mark failed | Backend §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)
studentsrow for the caller'suserId.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Pupil | GET /api/mobile/student/promotion-history | Request accepted (global JwtAuthGuard) | student-promotion-history-portal.controller.ts |
| 2 | Backend | assertPortalAudience(actor, "student") | 403 if the active role is not student | portal-audience.ts |
| 3 | Backend | Resolve own students.id where userId = actor.id AND deletedAt IS NULL | 404 STUDENT_NOT_FOUND if none | Controller |
| 4 | Backend | AcademicHistoryReaderService.read(studentId) | One entry per academic session touched | academic-history-reader.service.ts |
| 5 | Backend | Map to AcademicHistoryEntryDto[] | Response returned | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Wrong audience | Active role is not student | Refused before any row is read | 403 |
| No live student row | Deleted or never provisioned | Refused | 404 STUDENT_NOT_FOUND |
| No sessions touched | Brand-new pupil, no enrollment/decision/graduation yet | Empty array | 200, [] |
| Cycle still draft | A session's cycle exists but is not finalized | That session's outcome is null | Placements 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
:idpath param resolves to one of the caller's own children.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Guardian | GET /api/mobile/guardian/children/:id/promotion-history | Request accepted | guardian-promotion-history-portal.controller.ts |
| 2 | Backend | assertPortalAudience(actor, "guardian") | 403 if not a guardian | portal-audience.ts |
| 3 | Backend | GuardianPortalService.resolveOwnChildId(actor, childId) | 404 if childId is not this guardian's ward | guardian-portal.service.ts |
| 4 | Backend | AcademicHistoryReaderService.read(ownChildId) | Same shape as the student portal | academic-history-reader.service.ts |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not this guardian's child | childId belongs to someone else | Refused as "not found", never "forbidden" | 404 — prevents an id-space enumeration oracle |
| Wrong audience | Caller is not a guardian | Refused before the child lookup | 403 |
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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | POST /api/promotions/cycles with final/supplementary exam ids | Cycle created in draft, basis exams linked | promotion-cycles.service.ts#create |
| 2 | Admin | POST /:publicId/recompute | Every roster member gets a computed promotion_decisions row (source: recommendation or supplementary) | promotion-cycle-recompute.service.ts |
| 3 | Admin | Reviews pendingReasons and basisDrift on GET /:publicId | Sees which pupils need attention and why | promotion-cycles.service.ts#findOne |
| 4 | Admin (optional) | PUT .../decision or POST .../decisions/bulk for edge cases | Manual/bulk decisions overwrite the recommendation for those pupils only | promotion-decision-manual-write.service.ts |
| 5 | Admin | POST /:publicId/finalize | Cycle becomes finalized; a publication job is enqueued unless notifyOnPublish is off | promotion-cycle-finalize.service.ts |
| 6 | Worker | Delivers promotion.decision_published to every eligible pupil and guardian | notifiedAt stamped per decision, publication_state becomes sent | promotion-publication.service.ts |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Unpublished final exam | Any linked final exam has no published results | Finalize refused | 409 PROMOTION_EXAM_NOT_PUBLISHED |
| Missing decisions | A roster member has no decision row at all | Finalize refused | 409 PROMOTION_CYCLE_STALE — recompute first |
| Recommendations drifted | A current auto decision no longer matches a fresh recompute | Finalize refused | 409 PROMOTION_CYCLE_STALE — recompute first |
| Pending pupils, not acknowledged | pendingCount > 0 and acknowledgePending is false | Finalize refused | 409 PROMOTION_PENDING_UNACKNOWLEDGED |
| Concurrent edit | version sent does not match the stored row | Refused | 409 PROMOTION_CYCLE_STALE |
| Duplicate live cycle | Another non-cancelled cycle already exists for the session | Create refused | 409 PROMOTION_CYCLE_EXISTS |
| Rollover already references this cycle | Cancel attempted on a cycle a non-cancelled rollover consumes | Cancel refused | 409 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
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Admin | POST /:publicId/supplementary with an exam id | Exam validated (uncancelled, correct session ordering) and linked as supplementary | promotion-cycle-supplementary.service.ts#attachSupplementaryExam |
| 2 | Backend | Recompute the whole cycle | Fresh ComputedRecommendation per pupil | PromotionRecommendationService |
| 3 | Backend | For each eligible current decision (recommendation/supplementary/conditionally_promoted), compare to the fresh compute | Writes a new version only where it changed | PromotionCycleSupplementaryService.apply |
| 4 | Backend | If eligible + notifications on | Sends promotion.decision_updated per changed pupil | PromotionDecisionWriterService |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Conditional cleared | Existing outcome conditionally_promoted, fresh compute's source is supplementary and outcome is promoted/graduated | conditionStatus moves to satisfied; outcome and target grade are not replaced | conditionsSatisfied count incremented |
| Conditional not cleared | Fresh compute does not resolve via a supplementary pass | Left untouched | Counted as unchanged |
| Manual/bulk/correction decision | Existing source is manual, bulk, or correction (and outcome is not conditionally_promoted) | Skipped — never overwritten silently | Returned in skippedManual |
| Pupil left the session | Fresh compute flags leftSession | Skipped | Counted as unchanged — rollover's concern, not this route's |
| Exam already attached | The same exam is applied twice | No duplicate link row | Second 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
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
promotion_cycles | — | create | draft | No other live cycle for the session | Basis exams linked |
promotion_cycles | draft | finalize | finalized | No unpublished final exams, no missing/drifted auto decisions, pending acknowledged | finalizedAt/finalizedByUserId set; publication enqueued (if notifyOnPublish) |
promotion_cycles | draft | cancel | cancelled | No referencing non-cancelled rollover | cancelledAt/cancelReason set |
promotion_cycles.publication_state | none | finalize (with notifyOnPublish) or republish | queued | — | publicationAttempt bumped, outbox row written |
promotion_cycles.publication_state | queued | Worker completes delivery | sent | Still queued at completion (not superseded) | notifiedAt stamped per decision |
promotion_cycles.publication_state | queued | Final job delivery fails, or sweep exhausts 5 attempts | failed | Attempt still matches | Republishable via /republish |
promotion_cycles.publication_state | none / failed | republish | queued | Cycle is finalized | New 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) | Event | To (source) | Guard | Notes |
|---|---|---|---|---|
| — | Recompute (draft) | recommendation / supplementary | Cycle draft | Computed by the pure algorithm |
recommendation/supplementary | Manual override | manual | Cycle draft | PUT .../decision |
recommendation/supplementary | Bulk decide | bulk | Cycle draft | POST .../decisions/bulk |
| Any (draft-phase) | Reset | recommendation/supplementary | Cycle draft | Recompute overwrites the named pupils |
| Any current | Post-finalize correction | correction | Cycle finalized | POST .../correct |
conditionally_promoted decision | Condition resolved | condition | Cycle finalized | POST .../condition |
recommendation/supplementary/conditionally_promoted | Supplementary applied | supplementary (or condition satisfied in place) | Cycle finalized | POST .../supplementary |
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Create cycle | promotion_cycles, promotion_cycle_exams | None | None | None | None | None |
| Recompute | promotion_decisions (new versions for drifted/added students) | None | None | None | None | None |
| Finalize | promotion_cycles, outbox_events | None | Enqueues academic_year.publish_promotion_decisions | None | None | Deferred to the worker |
| Republish | promotion_cycles, outbox_events | None | Re-enqueues the same job | None | None | Deferred to the worker |
| Worker publish | promotion_decisions.notified_at, promotion_cycles.publication_state | None | Consumes the job | None | None | promotion.decision_published per pupil/guardian |
| Manual/bulk/reset | promotion_decisions | None | None | None | None | None (draft-phase writes never notify) |
| Correct / condition | promotion_decisions | None | None | None | None | promotion.decision_updated, inline, per write |
| Supplementary apply | promotion_cycle_exams, promotion_decisions | None | None | None | None | promotion.decision_updated per changed pupil, inline |
| Settings update | promotion_settings | None | None | None | None | None (audit entry only) |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Cycle already exists for session | Second create while a live one exists | 409 PROMOTION_CYCLE_EXISTS | Use the existing cycle or cancel it first | promotion_cycles_one_live_per_session index |
| Recommendations drifted at finalize | Exam results changed after the last recompute | 409 PROMOTION_CYCLE_STALE | Recompute, then finalize again | promotion-cycle-finalize.service.ts |
Publication stuck queued | Worker crashed mid-delivery, or the job was lost before a worker claimed it | No notification for 10+ minutes | The five-minute academic-year sweep re-enqueues automatically, up to 5 attempts, then marks failed | academic-year-sweep.service.ts |
Publication failed | 5th recovery attempt exhausted, or the final job delivery threw | Families never notified for that cycle | Admin calls POST /:publicId/republish | promotion-cycle-finalize.service.ts#republish |
| Serialization conflict | Two concurrent writers collide despite the fixed cycle→student lock order | Transaction retried once automatically | Transparent to the caller; a second collision surfaces as a normal 5xx | with-lock-retry.ts |
| Optimistic concurrency conflict | version/expectedDecisionPublicId stale | 409 PROMOTION_CYCLE_STALE / PROMOTION_DECISION_STALE | Reload and retry | Every write service |
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Recompute | Adding vs. changing vs. unchanged counted separately | Admin | POST /:publicId/recompute | RecomputeResultDto{added,changed,unchanged} | Only drifted/new rows are written | promotion-cycle-recompute.service.ts |
| Recompute | A left-session pupil is superseded to excluded/LEFT_SESSION even mid-draft | System | Recompute | Pupil disappears from the "needs a decision" set | New excluded version with a fixed reason string | promotion-cycle-recompute.service.ts |
| Finalize | basisDrift surfaces only after finalization | Admin | GET /:publicId | Detail response includes a live drift count | Computed on read, not stored | promotion-cycles.service.ts#computeBasisDrift |
| Bulk decide | Deduplicates studentPublicIds before writing | Admin | POST .../decisions/bulk | One write per unique pupil | [...new Set(...)] | promotion-decision-manual-write.service.ts |
| Bulk decide | Refuses if any listed pupil has no current decision | Admin | POST .../decisions/bulk | 400 PROMOTION_STUDENT_NOT_IN_CYCLE | No partial write | Same file |
| Supplementary | An already-linked exam is not re-linked | Admin | POST .../supplementary twice | No duplicate promotion_cycle_exams row | existingLink check first | promotion-cycle-supplementary.service.ts |
| Notifications | Push/SMS copy for family kinds is deliberately generic | System | Any family notification | Lock-screen-safe text, no outcome word | discreet() helper | promotion-templates.ts |
| Notifications | Condition text is shown only if shareConditionInNotification is on | System | Any notification carrying a condition | Condition line omitted by default | promotion-notification-copy.ts / template condition() helper | — |
| Academic history | Never exposes reason, basis, decidedByUserId, superseded versions, or exclusion reasons | System | Any portal or admin-history read | Family-safe shape only | AcademicHistoryEntryDto deliberately excludes these fields | academic-history.dto.ts |
| Decisions list | isOverridden reflects the write's source, not a live recompute diff | Admin | GET .../decisions | Cheap, page-sized query | Avoids a roster-sized recompute per list request | promotion-decisions-read.service.ts |
| Decisions list | Unpaginated reads are refused | Admin | GET .../decisions?pagination=false | 400 PAGINATION_LIMIT_INVALID | A pupil roster is never dumped in one response | Same file |
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| A decision version is never edited, only superseded | Preserves the exact original decision for audit and appeal | None visible unless viewing history | promotion_decisions_immutable trigger | History endpoint returns every version | PromotionDecisionWriterService is the only writer | apps/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 statements | promotion-decisions.integration.spec.ts |
| A decision's source constrains which cycle status may hold it | Prevents a draft-only write landing in a finalized cycle (bypassing the correction path) and vice versa | — | promotion_decisions_status_gate trigger | 409s surface a named error before the DB ever raises P0001 | Every write service checks status first | promotion-decisions.integration.spec.ts |
A left-session pupil is always excluded / LEFT_SESSION | The 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 school | Pupil sees excluded with a plain-language reason | recommendPromotion's leftSession short-circuit | recommendationReason shows LEFT_SESSION | Caller (recompute) supplies the boolean; the pure function never queries enrollment itself | promotion-recommendation.util.spec.ts (fixtures) |
| A grade cannot both graduate and have a next grade | An unambiguous progression rule per grade | Wrong config would silently corrupt an entire grade's outcomes | promotion_grade_progressions_one_rule/_has_rule CHECKs, mirrored in PromotionSettingsService.putProgression | 400 PROMOTION_PROGRESSION_INVALID | — | — |
| Every manual/bulk/correction/condition write needs a reason | Accountability for every human override | Admin must justify the change | requireReason() | 400 PROMOTION_REASON_REQUIRED | — | — |
| Family notification copy never carries an administrator's reason text | A correction/exclusion reason may describe circumstances a family should not read verbatim | Family sees outcome, not rationale | promotion-templates.ts docblock + implementation | — | — | — |
Publication only completes from queued | Prevents 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 Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Decisions are versioned rows, not mutable state | A family can always see the original decision and what changed it | One writer, one trigger, no update-anomaly surface | In-place update with an audit log table | More rows, more joins to find "current" | Mitigated by the partial unique index and covering indexes |
Recompute, finalize's staleness check, and basisDrift share one function | The three can never disagree about a pupil's outcome | One implementation to test and reason about | Three independent computations | None material — this was the explicit fix for a prior defect class | — |
| Supplementary re-evaluation only touches specific sources | A manually-decided pupil's outcome is never silently overwritten by a later exam | Predictable, auditable behavior | Recompute everyone unconditionally | Correction of a manual decision requires a separate correct call | Acceptable — matches "manual means manual" |
| Push/SMS copy is generic for family kinds | A pupil's result is not printed on a shared lock screen or a metered SMS | One rule instead of per-channel judgment calls | Full detail on every channel | Family must open the app for detail | Deliberate privacy tradeoff |
PromotionFinalize/PromotionCorrections/PromotionSettings withheld from automatic admin grant | A school must deliberately choose who can publish results or rewrite finalized history | Prevents an accidental blanket grant as new admin-adjacent modules are added | Grant everything to admin automatically | A school must remember to grant these explicitly | Documented in permission-catalog.ts; asymmetric because grants are never auto-revoked |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Recompute | Roster is empty | Session has no active enrollments and no prior decisions | Returns an empty map, no writes | {added:0,changed:0,unchanged:0} | promotion-recommendation.service.ts |
| Finalize | Two admins finalize concurrently | Race on the same cycle | Cycle lock (FOR UPDATE) serializes; the loser sees a stale version or NOT_DRAFT | 409 | promotion-cycle-finalize.service.ts |
| Publication | Cycle deleted mid-delivery (erasure cascade) | Worker's re-read finds no cycle | Returns "missing", no exception | Job completes normally | promotion-publication.service.ts#publish |
| Publication | Worker crashes after some chunks notified | Partial notifiedAt stamps already committed | Re-delivery (or recovery re-enqueue) resumes from the un-notified rows only | No duplicate notification (dedupe on decision publicId) | promotion-publication.service.ts#sendChunk |
| Bulk decide | Same pupil listed twice in the request | studentPublicIds array has a duplicate | Deduplicated before processing | One write, not two | promotion-decision-manual-write.service.ts |
| Correction | Pupil has no decision yet at all | existing is null | Requires an active enrollment to snapshot source class/grade from | 400 PROMOTION_STUDENT_NOT_IN_CYCLE if no active enrollment exists | promotion-decision-corrections.service.ts |
| Grade progression | Next grade is inactive | Recompute resolves progression.kind === "next" but the target grade is not active | Recommendation is pending / NEXT_GRADE_INACTIVE | Visible in pendingReasons | promotion-recommendation.util.ts#resolveProgression |
| Grade progression | Two grades share the top sort order, no graduating grade named | Ambiguous "who graduates" | pending / GRADUATING_GRADE_AMBIGUOUS | Admin must set an explicit progression override | Same file |
| Exam applicability | A transfer pupil sat the exam in a class no longer theirs | Exam's exam_classes scope doesn't include the pupil's current source class, but a result row exists | Still counted applicable: true | Pupil is not silently excluded from the basis | promotion-recommendation.service.ts#buildExamInputs |
| Guest/unauthenticated | No JWT | — | Refused before any handler runs | 401 (global JwtAuthGuard) | — |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Recompute/finalize/reset | Enrollments, decisions, cycle exams, exam results, grades, progressions, settings | promotion_decisions | None | Finalize enqueues publication | RecomputeResultDto/PromotionCycleSummaryDto |
| Manual/bulk/correction/condition | Current decision, target grade, settings (notify) | promotion_decisions | None | Inline notification (correction/condition/supplementary only) | PromotionDecisionDto |
| Publication delivery | Un-notified current decisions, students, users, grades, settings | promotion_decisions.notified_at, promotion_cycles.publication_state | None | Notification send | Job result string |
| Academic history (any surface) | Enrollments, finalized decisions, graduations, sessions | None | None | None | AcademicHistoryEntryDto[] |
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
- API doc: /docs/developer/promotions/api
- Backend doc: /docs/developer/promotions/backend