Skoolsewa - School Management System Docs
Developer ResourcesExams

Exams Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the examination and result module.

Exams Features and Flows

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Schemapackages/db/src/schema/school/exams.tsTables, enums, constraints, lifecycle rules and their rationale
Migrationspackages/db/src/migrations/0029_exam_module.sql through 0032_exam_schema_hardening.sql, each with a down migration under migrations/down/The three GiST exclusion constraints, the two guard triggers, seeded exam types, the default grading scale, notification and reminder defaults
Controllersapps/api/src/modules/exams/**/*.controller.ts, apps/api/src/modules/mobile/teacher/teacher-today-portal.controller.tsRoute surface, actors, guard chain
Servicesapps/api/src/modules/exams/**/*.service.tsBusiness rules, side effects, state transitions
Notification templatesapps/api/src/modules/notification/templates/exam-templates.tsThe 24 notification kinds, recipients, channels, copy
Permission cataloguepackages/db/src/authorization/permission-catalog.tsThe 13 exam permission modules and their rationale
Seeded role grantspackages/db/src/seed/seed-auth.tsWhich role actually holds which exam code
Frontend (admin)../skoolsewa-frontend/app/(dashboard)/dashboard/exams/**Landed admin screens
Frontend (audience)../skoolsewa-frontend/app/(dashboard)/dashboard/my-exams/**, .../my-results/**Landed pupil/guardian/teacher screens

2. Feature Summary

FieldValue
ModuleExams
Submoduleauthoring, routine (schedule/conflicts/invigilators), marks, results, grading, types, settings (notification/reminder rules), dashboard, portal (student/guardian/teacher), workers (sweeps, notifier, queue processor)
Primary user valueA school plans an offline examination, schedules each class's papers, invigilates and administers them, records and verifies marks on paper, and computes and publishes results — while pupils, guardians and teachers each see exactly their own slice of it.
ActorsAdministrator, teacher (a live staff row), a verifier (whoever holds ExamMarksVerify_UPDATE, typically an administrator), pupil, guardian, the scheduler/worker system.
Main entry points/api/exams*, /api/exam-routine-entries*, /api/exam-marks*, /api/exam-types*, /api/grading-scales*, /api/exam-notification-settings*, /api/exam-reminder-rules, /api/exam-dashboard, /api/exam-day, /api/mobile/student/exams*, /api/mobile/guardian/children/:id/exams*, /api/mobile/teacher/exams*, /api/mobile/teacher/invigilations, /api/mobile/teacher/schedule/today, the exams BullMQ queue, two crons (a 15-minute reminder sweep and an hourly lifecycle sweep).
Main outputsExam records, routine entries with their components and invigilators, marks submissions and marks with an append-only correction history, computed per-pupil results, 24 kinds of notification, the teacher's single-read "today" schedule, and a calendar projection consumed by the school calendar.
Related docsBackend, API

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
AdministratorCreate/edit/publish/unpublish/cancel/reinstate/complete/archive/unarchive any exam; manage its class scope and attachments; build the routine, its components and invigilators, reschedule and swap papers; enter, submit, return, approve, finalize and correct marks for any paper, unscoped; compute, publish, unpublish, withhold and release results; manage exam types, grading scales and bands, notification rules and reminder rules; read the exam-day overview and dashboard totals.Nothing is scoped away from an administrator holding the relevant codes.JWT + one of Exams_* / ExamPublish_UPDATE / ExamCancel_UPDATE / ExamRoutines_* / ExamMarks_* / ExamMarksVerify_UPDATE / ExamMarksCorrect_* / ExamResults_* / ExamResultPublish_UPDATE / ExamGrading_* / ExamTypes_* / ExamNotificationSettings_*, per route.ExamPublish_UPDATE and ExamResultPublish_UPDATE are both SUPERADMIN_ONLY_MODULES entries — see §12.3.
TeacherEnter and save marks for a routine entry they teach (or one whose submission they themselves made), and submit it; see their own exam dashboard, exam list, invigilation duties, and their own single-read "today" schedule; download an audience-visible attachment of an exam they teach into or invigilate.Return, approve, finalize, or correct marks (those require ExamMarksVerify_UPDATE/ExamMarksCorrect_UPDATE, never granted to STAFF_PERMISSIONS); reach an exam they neither teach into nor invigilate; act through the unscoped ExamMarks_* codes, which are never granted to the teacher role.JWT only — no @Permissions() on the scoped teacher routes. assertTeacherStaffRow requires the active role named teacher and resolves a live staff row (staff.user_id, deleted_at IS NULL); the exam list, exam detail, attachment download and invigilation duty list use assertStaffRow instead (a live staff row under any non-portal active role), so a non-teaching invigilator, or a teacher acting as staff, still reads their duties and the exams they hold a duty on, while the dashboard, the marks queue, the marks routes and the today schedule answer 403. Every route then scopes rows through class_subjects.teacher_id, exam_invigilators.staff_id or exam_marks_submissions.submitted_by_staff_id.Reached at /api/mobile/teacher/exams*, /api/mobile/teacher/invigilations, /api/mobile/teacher/schedule/today. ExamView_READ gates the panel screen, not any route. The teacher role is granted when a staff member is created on a teaching designation; after a later designation change an administrator grants or revokes it by hand — see PATCH /api/staff/:id in the People API.
Verifier (an administrator, or a custom role holding the code)Return marks with a reason, approve, or finalize a paper's submission; is notified when a teacher submits.Enter or edit the marks themselves through this code alone (that is ExamMarks_UPDATE, a separate code).JWT + ExamMarksVerify_UPDATE (act) / ExamMarksVerify_READ (the operator notification's audience).Neither code is seeded to staff or teacher — see §12.3, ExamMarks catalogue entry.
Pupil (student)Read their own visible (non-draft) exams, their own subject schedule and attachments, their own published and non-withheld result, and their own results history; see the exam on the school calendar.See another pupil's exam detail or result; see a draft exam; see a withheld result, or a result before it is published; see any invigilator identity.JWT only. The portal route resolves the caller's own students row (students.user_id, deleted_at IS NULL) and takes no id parameter.Reached at /api/mobile/student/exams*, /api/mobile/student/academic-history.
GuardianRead a linked child's exams, subject schedule, attachments, published result, and results history.Read/act for a non-linked child; see a submission-only surface (there is none for exams — a guardian's read is identical in shape to the pupil's own, minus ownership).JWT only. resolveOwnChildId(actor, childId) binds the child before any exam row is read; a non-owned child answers 404.Reached at /api/mobile/guardian/children/:id/exams*, /api/mobile/guardian/children/:id/academic-history.
Worker/systemRun the 15-minute reminder sweep and the hourly lifecycle sweep; auto-complete a published exam once its last paper has ended; auto-dispatch schedule/publish/cancel/result/invigilation/marks-submitted notifications from the outbox.Bypass any lifecycle guard the human paths enforce — every sweep re-checks the same guards the manual routes do.Internal (BullMQ), no HTTP surface.ExamScheduler enqueues; ExamsQueueProcessor (queue exams) consumes.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Create exam (draft)AdminAdminPOST /api/examsAcademic session, exam type, grading scaleexams rowAPI
Update examAdminAdminPATCH /api/exams/:publicIdexamsexams (narrowing the period is refused if a routine entry would fall outside it)API
Delete draft examAdminAdminDELETE /api/exams/:publicIdexams, exam_classesrow deleted — only a draft exam with no class scopeAPI
Set class scopeAdminAdminPUT /api/exams/:publicId/classesclasses, exam_classes, recorded marksexam_classes full replacementAPI
Manage attachmentsAdminAdminGET/POST/DELETE /api/exams/:publicId/attachments*exam_attachmentsfile row + storage bytesAPI
Build the routineAdmin/Teacher (write via admin route only)AdminPOST /api/exams/:publicId/routineexam_classes, class_subjectsexam_routine_entries row + a default componentAPI
Reschedule an entryAdminAdmin (+ ExamPublish_UPDATE if published)PATCH /api/exam-routine-entries/:publicIdconflict candidatesexam_routine_entriesAPI
Swap two papersAdminAdmin (+ ExamPublish_UPDATE if either is published)POST /api/exam-routine-entries/swapboth entriesboth entries, deferred-constraint transactionAPI
Delete an entryAdminAdminDELETE /api/exam-routine-entries/:publicIdmarks, results referencing itrow deleted — refused if marks/results existAPI
Mark exam-day completeAdminAdminPOST /api/exam-routine-entries/:publicId/completeexam_routine_entries.is_completedflag set (idempotent)API
Replace componentsAdminAdminPUT /api/exam-routine-entries/:publicId/componentsexam_assessment_componentsfull replacement, refused if marks exist against a removed rowAPI
Assign invigilatorsAdminAdminPUT /api/exam-routine-entries/:publicId/invigilatorsstaff, conflict candidatesexam_invigilators full replacementAPI
Publish-preview / conflicts reportAdminAdminGET .../publish-preview, GET .../conflictsevery read abovenone (read-only)API
Exam-day overviewAdminAdminGET /api/exam-daytoday's published/completed entriesnoneAPI
Publish / unpublishAdminAdminPOST /api/exams/:publicId/{publish,unpublish}class scope, components, grading scale, conflictsexams.status, roster of routine entries armedAPI
Cancel / reinstateAdminAdminPOST /api/exams/:publicId/{cancel,reinstate}examsexams.status='cancelled' or back to draftAPI
Complete (manual)AdminAdminPOST /api/exams/:publicId/completeevery paper's end instantexams.status='completed'API
Archive / unarchiveAdminAdminPOST /api/exams/:publicId/{archive,unarchive}examsarchived_at/archived_from_statusAPI
Enter/save marks (unscoped)AdminAdminGET/PUT /api/exam-routine-entries/:publicId/markslive roll, componentsexam_marks upsertAPI
Enter/save marks (scoped)TeacherTeacherGET/PUT /api/mobile/teacher/exams/:examPublicId/entries/:entryPublicId/markssame, teacher-scopedsameAPI
Submit marksAdmin/TeacherAdmin, teacher (scoped)POST .../marks/submitevery present pupil has a markexam_marks_submissions.state='submitted'API
Return marksAdminVerifierPOST /api/exam-routine-entries/:publicId/marks/returnsubmissionstate='returned' + reasonAPI
Approve marksAdminVerifierPOST .../marks/approvesubmissionstate='approved'API
Finalize marksAdminVerifierPOST .../marks/finalizesubmissionstate='finalized'API
Correct a finalized markAdminAdmin (ExamMarksCorrect_UPDATE)POST /api/exam-marks/:publicId/correctmark, submission statenew exam_mark_corrections row + exam_marks update + results marked staleAPI
Read correction historyAdminAdmin (ExamMarksCorrect_READ)GET /api/exam-marks/:publicId/correctionsexam_mark_correctionsnoneAPI
Compute resultsAdminAdminPOST /api/exams/:publicId/results/computeroll, marks, grading scaleexam_results/exam_subject_results upsert (sync or queued)API
List / read resultsAdminAdminGET /api/exams/:publicId/results*exam_resultsnoneAPI
Withhold a resultAdminAdmin (ExamResultPublish_UPDATE)POST .../results/:studentPublicId/withholdresultstatus='withheld'API
Release a resultAdminAdmin (ExamResultPublish_UPDATE)POST .../results/:studentPublicId/releasesubject resultsstatus re-derived via computeOverallAPI
Publish / unpublish resultsAdminAdmin (ExamResultPublish_UPDATE)POST /api/exams/:publicId/results/{publish,unpublish}every entry finalized, no stale resultexams.results_published_atAPI
Manage exam typesAdminAdmin/api/exam-types*exam_typescreate/update/hard-deleteAPI
Manage grading scales & bandsAdminAdmin/api/grading-scales*, PUT .../bandsgrading_scales, grading_scale_bandscreate/update/delete/full band replacementAPI
Manage notification rulesAdminAdminGET/PUT /api/exam-notification-settings*exam_notification_rulesone row per eventAPI
Manage reminder rulesAdminAdminGET/PUT /api/exam-reminder-rulesexam_reminder_rulesfull-list replace with a scratch-slot swap danceAPI
Dashboard totalsAdminAdminGET /api/exam-dashboardderived countsnoneAPI
Pupil reads own exams/resultPupilPupil/api/mobile/student/exams*, /academic-historyown class scopenoneAPI
Guardian reads a child's exams/resultGuardianGuardian/api/mobile/guardian/children/:id/exams*child's class scopenoneAPI
Teacher reads own exams/dashboard/queueTeacherTeacher/api/mobile/teacher/exams*class_subjects.teacher_id scopenoneAPI
Teacher reads invigilation dutiesTeacherTeacherGET /api/mobile/teacher/invigilationsexam_invigilatorsnoneAPI
Teacher's single-read "today"TeacherTeacherGET /api/mobile/teacher/schedule/todaytimetable + exam entries + invigilation dutiesnoneAPI
Calendar projectionWorker/readAnyRead internally by the calendar moduleexams, exam_routine_entriesnoneBackend
Reminder sweepWorkerSystemCron, every 15 minutesexam_reminder_rules, live scheduleone dispatch row + notificationsBackend
Lifecycle sweepWorkerSystemCron, every hourpublished exams past their last paper; today's/tomorrow's papersexams.status='completed'; date-triggered notificationsBackend

5. User-Facing Flows

5.1 Administrator plans an exam and builds its routine

Summary

An administrator creates a draft examination against an active academic session and an exam type, sets its class scope, and then builds one routine entry per subject per class — each with its own date, time window, room, assessment components and invigilators — before previewing and publishing it.

Preconditions

  • The academic session is active (EXAM_SESSION_NOT_ACTIVE otherwise).
  • The exam type and (optional) grading scale exist.
  • Every routine entry's date falls within the exam's own [startDate, endDate] envelope.
  • The chosen classSubjectId belongs to the chosen class (EXAM_ROUTINE_SUBJECT_NOT_TAUGHT otherwise).

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /api/exams with type, dates, nameexams row, status='draft'ExamWriteService.create
2AdminPUT /exams/:publicId/classes with class public idsexam_classes full replacementExamClassesService.replace
3AdminPOST /exams/:publicId/routine per subject per classexam_routine_entries row + one default theory component copying class_subjects.max_marks/pass_marksExamRoutineWriteService.create
4AdminPUT .../components (optional)Replaces the default component with a real split (theory/practical/internal/…)ExamComponentsWriteService.replace
5AdminPUT .../invigilatorsexam_invigilators rows, schedule columns copied from the entryExamInvigilatorsWriteService.replace
6AdminGET .../publish-previewWarnings (missing room/invigilator/components on a class with no entries) and hard blockers (canPublish)ExamPublishPreviewReadService.publishPreview

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Session retiredacademic_sessions.is_active=falseReject create409 EXAM_SESSION_NOT_ACTIVE
Entry date outside periodexamDate not in [startDate, endDate]Reject422 EXAM_ROUTINE_DATE_OUTSIDE_PERIOD
Subject not taught by classclassSubjectId.classId ≠ chosen classReject422 EXAM_ROUTINE_SUBJECT_NOT_TAUGHT
Narrowing the exam periodNew [startDate,endDate] excludes an existing entry's dateReject the update409 EXAM_PERIOD_HAS_ENTRIES_OUTSIDE
Removing a class already scoredexam_classes removal that still has recorded marksReject409 EXAM_CLASS_HAS_RECORDED_MARKS
Deleting a routine entry with marks/resultsFK restrict on the component/result sideReject409 EXAM_ROUTINE_ENTRY_IN_USE
Deleting a component with recorded marksFK restrict from exam_marksReject409 EXAM_COMPONENT_HAS_RECORDED_MARKS
Preview finds a real collisionTwo candidate entries overlap on room/class/invigilatorcanPublish=false, the conflict named in conflicts[]read-only, no error thrown

5.2 Publishing an exam — the consequential act

Summary

Publishing is what arms the three GiST exclusion constraints (room, class, invigilator), makes the exam visible to families and teachers, and notifies every audience. It is deliberately harder to grant than an ordinary edit: ExamPublish_UPDATE is SUPERADMIN_ONLY_MODULES, because it also authorises any further change that reaches an already-published exam.

Preconditions

  • At least one class is in scope (EXAM_HAS_NO_CLASSES).
  • Every routine entry has at least one assessment component (EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS) — the database cannot express "at least one row", so this is a service-level publish gate.
  • If a grading scale is attached, it has a band whose floor is exactly 0 (GRADING_SCALE_HAS_NO_FLOOR_BAND).
  • No routine entry collides on room, class, or invigilator with another exam's published/completed entry, or with a sibling entry inside the same publish.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /exams/:publicId/publishHard-gate checks run first (classes, components, floor band)ExamLifecycleService.publish
2BackendCascade the statusexams.status='published' cascades exam_status onto every exam_routine_entries/exam_invigilators row in ONE statement, which is what arms the exclusionsON UPDATE cascade on exams_id_status_unique
3BackendConflict checkAny 23P01 from the three exclusion constraints is resolved to a named pair before the outer transaction rolls backrunExamWriteWithConflictGuard, resolveExamConflict
4BackendNotifyexam.published.{student,guardian,teacher} per scoped class, "first paper" resolved per classAssignmentJob-style outbox enqueue, ExamNotifierService.notify

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No classes scopedexam_classes emptyReject before any write409 EXAM_HAS_NO_CLASSES
An entry has no componentsat least one routine entry with zero exam_assessment_components rowsReject409 EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS
Grading scale has no floor bandattached scale, no band at min_percent=0Reject409 GRADING_SCALE_HAS_NO_FLOOR_BAND
Room double-bookedTwo published/completed entries share a room and overlapping time on the same dateReject, naming both entries409 EXAM_ROOM_CONFLICT
Class double-bookedSame class has two overlapping papersReject409 EXAM_CLASS_CONFLICT
Invigilator double-bookedSame staff member assigned to two overlapping papersReject409 EXAM_INVIGILATOR_CONFLICT
Already publishedstatus already publishedNo-op, returns current detail200, no state change
Concurrent editversion mismatch on an action that WOULD change somethingReject409 EXAM_VERSION_CONFLICT
Idempotent double-clickVersion stale but status already the targetEXAM_ALREADY_PUBLISHED, not a version conflict, checked first200-shaped conflict message, no state change

5.3 Pupil and guardian read the exam schedule, calendar and results

Summary

A pupil (or a guardian, for a linked child) opens their exam list, sees a subject's date/time/room, reads audience-visible attachments, and — once published — sees their result. The exam also appears on the school calendar as a read-only projection.

Preconditions

  • The pupil has a live, active enrolment in a class the exam is scoped to, in the exam's own academic session (never "the current session" — a promoted pupil keeps access to a past, still-published exam).
  • The exam's own status is not draft.
  • For the result route specifically: exams.results_published_at IS NOT NULL and the pupil's own exam_results.status <> 'withheld'.

Main Flow

StepActor/SystemActionResultSource
1Pupil/GuardianGET /exams (or /children/:id/exams)List of non-draft exams scoped to a live class enrolmentExamPortalReadsService.listExams
2Pupil/GuardianGET /exams/:examPublicIdSubject entries (date, time, room, notes) + audience-visible attachmentsExamPortalReadsService.getExamDetail
3Pupil/GuardianGET /exams/:examPublicId/resultFull result if published and not withheld; identical 404 otherwiseExamPortalReadsService.getExamResult
4Pupil/GuardianGET /academic-historyEvery published, non-withheld result across every sessionExamPortalReadsService.listAcademicHistory
5AnyThe school calendar reads the same rowsExam appears as a calendar entry, published-only, no invigilator identityExamCalendarProjectionService.portalProjection

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Exam is draftstatus='draft'Excluded from every list and detail readinvisible, not 403
Foreign exam / wrong classThe exam exists but the pupil's class is not in scopeIdentical response to "does not exist"404 EXAM_NOT_FOUND — never 403, so an id space cannot become an enumeration oracle
Results not yet publishedresults_published_at IS NULLRefused404 EXAM_NOT_FOUND
Result withheldexam_results.status='withheld'Refused with the SAME code as "not published"404 EXAM_NOT_FOUND — the absence itself discloses nothing
Null percentagePupil absent from every counted subjectpercentage is null, never 0client must render "not computable", not "0%"
Guardian, non-owned childchildId not linked to the callerRefused before any exam is read404
Guardian downloads a submission-only fileN/A — the exam module has no submission files; every attachment a guardian can reach is visibility='audience'Non-audience (administrator_only) attachments are refused identically404 EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE
Promoted pupil, past examPupil moved class after a still-published examStill readable — scoped by the exam's OWN academic session via exam_classes, not "the current session"200, unchanged

5.4 The teacher's day

Summary

A teacher opens their schedule for today and sees every period in one read — ordinary class periods, exam papers for a class-subject they currently teach, and invigilation duties — each with a server-computed state (upcoming/in_progress/finished) and the one instant at which any label next changes.

Preconditions

  • The teacher has a live staff row.
  • The three period sources are read independently and never suppress one another: a class period and an exam duty legitimately overlap because they come from different tables.

Main Flow

StepActor/SystemActionResultSource
1TeacherGET /mobile/teacher/schedule/todayServer resolves "now" in the school's own timezone and Bikram Sambat dateTeacherTodayPortalService.getToday
2BackendLoad three sourcesPublished class-timetable entries for today; exam papers where the caller teaches the class-subject; invigilation duties for todayloadClassPeriods, loadExamPeriods, loadInvigilationPeriods
3BackendCompute state and tie-breakEvery period gets upcoming/in_progress/finished; a tie for "current" favours exam_invigilation over exam over classcomputeState, KIND_PRIORITY
4BackendCompute the next transitionEarliest of "current period ends" / "next period starts" — one timer for the client, no pollingcomputeNextTransition

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Non-operating dayCalendar classifies today as a holidaycalendar.isOperatingDay=false; exam duties are still returnednot suppressed — a paper can sit on a day the calendar marks non-teaching
No teaching assignmentNo class_subjects.teacher_id match and no invigilation todayperiods=[], summary.total=0empty state, not an error
Duty displaces a lessonAn invigilation and a class period overlap in timeBoth appear; the tie-break picks the invigilation as "current"contract's own displacement rule
Device clock is wrongN/AIrrelevant — every instant is server-computed in the school's own timezonethe device never supplies the time
Nothing left todayEvery period finished, none upcomingnextTransitionAt=nullclient stops polling/timing

5.5 Teacher enters and submits marks

Summary

A teacher opens the marks sheet for a paper they teach, records each pupil's attendance and mark per assessment component, saves as often as they like, and submits once every present pupil has a mark. Marks open the instant the paper's own end time passes in school time — not on an administrator's click.

Preconditions

  • Marks entry is open: is_completed=true OR the paper's end instant (school timezone) has passed.
  • The submission is draft or returned (not submitted/approved/finalized).
  • For a scoped teacher route: the caller teaches the entry's class_subject_id (current assignment) OR is the staff member who previously submitted this entry (submitted_by_staff_id) — this second arm survives a subject reassignment.

Main Flow

StepActor/SystemActionResultSource
1TeacherGET .../marksFull sheet: components, the live roll (not a publish-time snapshot), any existing marks, isOpen/isEditableExamMarksSheetService.getSheet
2TeacherPUT .../marks with {studentId, components:[{componentId, attendance, obtainedMarks, remarks}]}Upserts exam_marks, one row per (component, student)ExamMarksSheetService.saveMarks
3TeacherPOST .../marks/submitEvery present pupil verified to have a mark; exam_marks_submissions.state='submitted', submitted_by_staff_id snapshottedExamMarksWorkflowService.submit
4BackendNotifyAn in-app-only operator signal to every ExamMarksVerify_READ holderExamNotifierService.notifyMarksSubmitted

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Marks not yet openPaper's end instant is still in the future and is_completed=falseReject read/save/submit409 EXAM_MARKS_NOT_YET_OPEN
Already submittedstate is submitted/approved/finalizedReject further edits409 EXAM_MARKS_NOT_EDITABLE
Present pupil missing a markAn attendance='present' pupil (or one with no exam_marks row at all — treated as an implicit present/NULL) has no obtainedMarksReject submit, naming every missing pupil409 EXAM_MARKS_INCOMPLETE with details.students[]
Mark on a non-present pupilattendance≠'present' but obtainedMarks suppliedReject422 EXAM_MARK_ABSENT_CANNOT_SCORE
Mark exceeds ceilingobtainedMarks > component.maxMarksReject422 EXAM_MARK_EXCEEDS_CEILING
Present but not yet scoredattendance='present', obtainedMarks=nullA legitimate, non-zero state — the row simply is not "complete" yetnot an error while still draft
Absent pupilattendance='absent'Carries no mark at all — the database's own disjunction CHECK refuses oneenforced at the schema, not just the service
Reassigned teacherclass_subjects.teacher_id changed after this teacher already submittedThe previous submitter can still act on their own submission via submitted_by_staff_idscoped access preserved, not stripped
Unscoped admin writeAdmin uses /api/exam-routine-entries/:id/marks directlySame service, no teacher-scope predicate — ExamMarks_UPDATE is unscoped by designnever granted to STAFF_PERMISSIONS/TEACHER_PERMISSIONS

5.6 Verifier reviews, returns, approves and finalises marks

Summary

Once a teacher submits, a verifier (an administrator, or a role holding the verify code) reviews the paper's marks and either sends them back with a reason, approves them, or finalises them. Finalising is the last stop before a mark can only be changed through an explicit, reasoned correction.

Preconditions

  • The submission is in the state the action requires: return/approve need submitted; finalize needs approved.
  • return requires a non-empty reason.

Main Flow

StepActor/SystemActionResultSource
1VerifierPOST .../marks/return with a reasonstate='returned', reviewed_by_user_id/reviewed_at/return_reason setExamMarksWorkflowService.returnMarks
1'VerifierPOST .../marks/approvestate='approved'ExamMarksWorkflowService.approve
2VerifierPOST .../marks/finalizestate='finalized', finalized_by_user_id/finalized_at setExamMarksWorkflowService.finalize
3Teacher(on return) re-opens the sheetSubmission is returned, editable again, and re-submittableExamMarksSheetService treats draft/returned alike

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Wrong starting statee.g. finalize attempted on a draft/submitted rowReject409 EXAM_MARKS_TRANSITION_INVALID
Submission never savedNo exam_marks_submissions row exists at allReject with a distinct code409 EXAM_MARKS_SUBMISSION_NOT_FOUND
Return can be re-enteredapproved can also be returned, not only submittedreturnMarks accepts fromStates: ["submitted","approved"] — approved is not a stuck state200
Concurrent actionversion mismatch on a real transitionReject409 EXAM_MARKS_SUBMISSION_VERSION_CONFLICT
No verifier role seededNeither staff nor teacher ever holds ExamMarksVerify_*By design — an administrator (or a school-created role) is the verifiernot a bug; see §12.3

5.7 Correcting a finalized mark

Summary

Once marks are finalized, a mark can still change — but only through a reasoned, append-only correction that leaves a full history and marks the exam's computed results stale until recomputed.

Preconditions

  • The mark's paper submission is exactly finalized (not draft/submitted/approved/returned).
  • A non-empty reason is required and stored.

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /exam-marks/:publicId/correct with {attendance, obtainedMarks, reason}A new exam_mark_corrections row (previous + new values, who, when) is inserted firstExamMarkCorrectionsService.correct
2BackendUpdate the markexam_marks row updated to the new attendance/valuesame transaction
3BackendInvalidate resultsEvery exam_results row for the exam is set is_stale=truesame transaction
4AdminGET /exam-marks/:publicId/correctionsFull ordered history, readable under a DIFFERENT permission (ExamMarksCorrect_READ) than the one that writes it (ExamMarksCorrect_UPDATE)ExamMarkCorrectionsService.listCorrections

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not finalized yetSubmission state ≠ finalizedReject409 EXAM_MARKS_TRANSITION_INVALID
Correction would score an absenceattendance≠'present' with a non-null obtainedMarksReject422 EXAM_MARK_ABSENT_CANNOT_SCORE
Correction exceeds ceilingobtainedMarks > maxMarksReject422 EXAM_MARK_EXCEEDS_CEILING
History is append-onlyAny attempt to UPDATE/DELETE a correction row outside a cascadeRefused at the database trigger levelP0001 exam_mark_corrections is append-only
Publishing results while staleAny exam_results.is_stale=true in the examPublish refused409 EXAM_RESULTS_STALE
Correction leaves an audit trailEvery correctionActivityAuditInterceptor-independent explicit record, module ExamMarksCorrect, action UPDATE, with per-field changesdistinguishable from a routine ExamMarksVerify approval

5.8 Computing, withholding, releasing and publishing results

Summary

Once every paper of an exam is finalized, an administrator computes every pupil's per-subject and overall result, may withhold an individual pupil's result (or release it back), and then publishes results — the act that makes them visible to families.

Preconditions

  • Every routine entry of the exam has a finalized marks submission (EXAM_RESULTS_NOT_ALL_FINALIZED otherwise).
  • No result in the exam is is_stale (EXAM_RESULTS_STALE otherwise — a correction landed after the last compute).

Main Flow

StepActor/SystemActionResultSource
1AdminPOST /exams/:publicId/results/computeFor a roll ≤ 500 pupils, computed inline under an advisory lock; larger rolls are enqueued (ExamJob.RESULT_COMPUTE) and the response reports mode:"queued"ExamResultsComputeService.compute
2BackendPer-subject arithmeticcomputeSubjectFromMarks — the five-row table: not_applicable (no mark row), absent (scores 0, still counts), withheld (nulls, isPassing=false), incomplete (any unscored component), present-complete (obtained/percentage/pass computed)exam-result-rules.ts
3BackendOverall arithmeticcomputeOverall — total, percentage, GPA, pass/fail counts, and status: absent / incomplete / passed / passed_with_conditions / failedexam-result-rules.ts
4Admin(optional) POST .../withhold / .../releaseWithhold sets status='withheld' directly; release RE-DERIVES the overall status from the unchanged per-subject snapshot via computeOverall (there is no "status before withhold" column)ExamResultsService.withhold / .release
5AdminPOST /exams/:publicId/results/publishresultsPublishedAt/resultsPublishedByUserId set onceExamResultsService.publish
6BackendNotifyOne exam.result_published.{student,guardian} event per pupil whose result is published and not withheldExamNotifierService.notifyResultPublished

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Not all finalizedAt least one routine entry has no finalized submissionCompute succeeds (it reads whatever exists), but publish is refused409 EXAM_RESULTS_NOT_ALL_FINALIZED
Stale resultA correction landed after the last computePublish refused until recomputed409 EXAM_RESULTS_STALE
Concurrent computeTwo POST .../compute calls race for the same examSerialised by a per-exam Postgres advisory lock; the roll is re-read inside the lockno duplicate/partial write
Large rollRoll exceeds EXAM_SYNC_COMPUTE_MAX_STUDENTS (500)Enqueued via the outbox instead of computed inline; repeated requests are idempotent by dedupe key{mode:"queued", studentCount, resultCount:null}
Null percentageA pupil is absent from every counted subjectpercentage=null — "not computable", never 0client must not render 0%
Absent subject still countsA pupil misses one paper but sits the restThat subject contributes 0/max; countsTowardTotal=truereflected in totalMax/totalObtained
Withheld subject excludedA subject-level withheld markExcluded from the pupil's total (countsTowardTotal=false), isPassing=false not nulldistinct from not_applicable
Withheld whole resultAdmin withholds a pupil's entire published resultstatus='withheld'; the pupil/guardian read answers the identical 404 as "not published"absence discloses nothing
Release re-derives, does not restorerelease recomputes from the unchanged exam_subject_results snapshot rather than reading a stored prior statusCorrect even if something else changed between withhold and releaseno stale "restore" bug
Already publishedPublish called twiceReject409 EXAM_RESULTS_ALREADY_PUBLISHED
Unpublish blockedAt least one paper's marks are finalized (§5.9's unpublish note applies to the exam itself, not results)N/A here — see §6 for the exam-level unpublish guard—

6. Admin Flows

6.1 Cancel and reinstate an exam

POST /exams/:publicId/cancel (ExamCancel_UPDATE) is reachable from published or completed and is refused once results are published (EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED). It stores an administrator-internal cancellationReason and a family-visible publicCancellationNote — the notification template interpolates only the latter; cancellation_reason and internal_note never reach a template variable map. reinstate returns a cancelled exam to draft, clearing cancelledAt/cancelledByUserId/publishedAt/publishedByUserId — it does not attempt to re-publish.

6.2 Complete (manual and swept), archive, unarchive

POST /exams/:publicId/complete (Exams_UPDATE) is refused while any paper's own end instant is still in the future (EXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER) — the identical predicate the hourly lifecycle sweep enforces, so the manual route and the sweep are two writers of one transition and neither can complete an exam that has not happened. Both repeat the predicate inside the write itself, so a paper rescheduled into the future a moment before still blocks completion. Archiving is reachable only from completed or cancelled, records archivedFromStatus, and freezes the row from every further lifecycle action. Unarchiving restores the recorded prior status with no other side effect. Both are idempotent no-ops when already in the target state.

6.3 Rescheduling and swapping papers

A PATCH that touches examDate/startMinute/endMinute/roomId on a published/completed entry additionally requires ExamPublish_UPDATE and fires exam.schedule_changed; a text-only edit (notes, center) does not. Swapping two papers' schedules (POST /exam-routine-entries/swap) is a single transaction that defers the three exclusion constraints (DEFERRABLE INITIALLY IMMEDIATE) so both UPDATEs can land with a transient overlap, then forces the checks before returning — a genuine collision with a third paper still raises EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT inside the same request, never at some unrelated later commit. Two independent PATCH calls cannot achieve this: the first would raise 23P01 against the second paper's still-old slot.

6.4 Manage exam types

ExamTypes is its own permission module rather than gated by Exams, matching LeaveTypes/NoticeCategories: the person who may run an exam is not automatically the person who may redefine the school's own catalogue of examination types. There is no soft-delete column — is_active plus a partial-unique index on lower(name)/code (both WHERE is_active) lets a retired type's name and code be reused, following the grades construction. code is immutable after create. DELETE is a hard delete, refused by exam_type_id's ON DELETE restrict the moment any exam still points at it (EXAM_TYPE_IN_USE).

6.5 Manage grading scales and bands

A grading scale carries a floor-only band model: the band that applies is the one with the greatest min_percent ≤ pct, which makes overlapping bands and gaps both unrepresentable, provided one band sits at 0 — enforced as a publish-gate rule rather than a schema CHECK, because the database cannot count rows. PUT .../bands is a full replacement inside one transaction, so no caller ever observes a partial band table. Setting a scale isDefault clears the previous default first, inside the same transaction, to avoid colliding with the partial unique index on is_default. Deleting a scale still referenced by an exam is refused (GRADING_SCALE_IN_USE) — exams.grading_scale_id is restrict, never cascade or set null, because either would silently strip the grading basis from every published result the scale produced.

6.6 Manage notification rules and reminder rules

exam_notification_rules has exactly one row per event, seeded by the migration and never created or deleted by the service — only seven events exist (published, schedule_changed, cancelled, invigilation_assigned, exam_tomorrow, exam_today, result_published), and notify_invigilators is refused on any event other than schedule_changed/cancelled/invigilation_assigned (an invigilator has no interest in a whole-exam publish or a result). exam_reminder_rules is a full-list replace: rows changing offset are first parked on a scratch value nothing else occupies (the offset column is a plain, non-deferrable UNIQUE constraint), then moved to their real target in a second pass, so a swap of two offsets never transiently collides. Minimum spacing between two enabled offsets is one sweep tick (15 minutes) — a service rule, because a CHECK cannot span rows.

6.7 Manage attachments and their audience

An exam attachment defaults to administrator_only visibility; only an explicit visibility:"audience" is ever reachable by a pupil or guardian, and only through the portal's own download route, which re-checks the exam is published and the pupil's class is in scope before opening the stream. The generic upload proxy cannot serve these files at all — the storage key prefix is denylisted from it, and the type is never registered under PUBLIC_TYPES, so a question paper can never become readable by the whole internet before the exam sits.

6.8 Exam-day overview and dashboard totals

GET /api/exam-day (ExamRoutines_READ) — moved off the /api/exams prefix entirely so no literal route can ever be shadowed by a :publicId parameter route elsewhere in the module — lists every published/completed paper sitting today, in the school's own timezone, with invigilator names. GET /api/exam-dashboard (Exams_READ) reports upcoming/ongoing (the two derived halves of displayStatus), completed/cancelled (stored status), publishedResults, pendingMarks (published/completed papers with no finalized submission), and pendingVerification (narrowed to submitted, awaiting a verifier).

7. Lifecycle and State Transitions

7.1 Exam status

EntityFromEvent/ActionToGuard ConditionSide Effects
examsdraftPublishpublishedClasses scoped; every entry has a component; grading scale (if any) has a floor band; no conflictCascades exam_status to every routine entry/invigilator row; roster arms the three GiST exclusions; notifies pupils/guardians/teachers
examspublishedUnpublishdraftNo results published; no paper's marks are finalizedCascades back; exam.unpublished reuses the schedule_changed notification rule with an honest kind
examspublished/completedCancelcancelledResults not yet publishedNotifies every audience; invigilators individually
examscancelledReinstatedraft—Clears cancellation and publish stamps; does not re-publish
examspublishedComplete (manual or hourly sweep)completedEvery paper's own end instant has passed in school timeSweep and manual route share the identical guard
examscompleted/cancelledArchive(same status) + archived_at set—Freezes further lifecycle actions; records archived_from_status
examsarchivedUnarchiveRestores archived_from_status—Clears archived_at/archived_from_status

Two further labels are derived, never stored: scheduled (a published exam whose startDate is still in the future) and in_progress (published, otherwise) — deriveExamDisplayStatus, a pure function of status, startDate and "today" in school time. A stored in_progress would be wrong from the minute after it was written; the hourly sweep is what guarantees a published exam never outlives its end_date unlabelled.

7.2 Marks submission state (exam_marks_submissions.state)

EntityFromEvent/ActionToGuard ConditionSide Effects
exam_marks_submissions(none)First save/submitdraftRow created lazily on first sheet save—
exam_marks_submissionsdraft/returnedSubmitsubmittedMarks open; every present pupil has a marksubmitted_by_staff_id snapshotted; operator notification
exam_marks_submissionssubmittedReturnreturnedNon-empty reasonreturn_reason set
exam_marks_submissionssubmitted/approvedReturnreturnedSameapproved is NOT a single-exit stuck state
exam_marks_submissionssubmittedApproveapproved——
exam_marks_submissionsapprovedFinalizefinalized—Marks now correctable only through a reasoned append-only correction
exam_marks_submissionsreturned(teacher resubmits)submittedSame as first submit—

7.3 Mark attendance and "present but not yet scored"

exam_marks.attendance is present | absent | withheld | not_applicable, and the schema enforces a disjunction — attendance='present' OR obtainedMarks IS NULL — rather than a biconditional. That single choice is what makes "present, not yet scored" representable at all: a teacher records who sat the paper in the hall, then enters marks that evening, and the row is legitimately present with a null mark in between. The disjunction still makes an absent pupil's zero unrepresentable — an absence can never be silently scored 0. "Every present pupil has a mark" is asserted at submit, not by the schema, and names exactly who is missing.

AttendanceCarries a mark?Counts toward the subject total?isPassing
present, scoredYesYestrue/false from the sum vs. pass total
present, unscoredNo (legitimate interim state)Yes (forces the overall status to incomplete)null
absentNo — the database refuses oneYes — scores 0/maxfalse
withheldNo, pending releaseNofalse (not null — distinct from not_applicable)
not_applicable (no mark row at all)NoNonull

7.4 Result status and publication

exam_results.status is passed | failed | passed_with_conditions | incomplete | absent | withheld. passed_with_conditions is reachable only when the exam's grading scale has conditional_pass_max_failed_subjects > 0 — zero (the default) makes it a configured absence, not a dead enum branch. graduated is deliberately absent from the enum: nothing in this module writes it, and it belongs to a Promotions module that does not exist yet.

EntityFromEvent/ActionToGuard ConditionSide Effects
exam_results(none)Computederived statusEvery counted subject present or explicitly absent/withheldis_stale=false; per-subject snapshot in exam_subject_results
exam_resultsanyCorrection to a finalized mark(unchanged)—is_stale=true on every result of the exam; publish is refused until recomputed
exam_resultsany (non-withheld)Withholdwithheld—The pupil/guardian read answers the identical 404 as "not published"
exam_resultswithheldReleasere-derived via computeOverall from the unchanged subject snapshot—No stored "prior status" is read; every field is recomputed
exams.resultsPublishedAtnullPublishsetEvery routine entry finalized; no result staleOne exam.result_published notification per pupil, excluding withheld
exams.resultsPublishedAtsetUnpublishnull—No further guard — the reverse of publish

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Create exam / set classesexams, exam_classesnonenonenonenonenone
Build routine (create entry)exam_routine_entries, exam_assessment_components (default)nonenonenonenonenone
Replace components / invigilatorsexam_assessment_components or exam_invigilators (full replace)noneJob: exam.invigilation_assigned (if changed, published entry)nonenoneinvigilation_assigned
Reschedule / swapexam_routine_entries (+ deferred exclusion re-check)noneJob: exam.schedule_changed (if published)nonenoneschedule_changed (student/guardian/teacher/invigilator)
Publishexams, cascade to exam_routine_entries/exam_invigilatorsnoneJob: exam.publishednonenonepublished (student/guardian/teacher)
Unpublishexams (cascade back)noneJob: exam.schedule_changed with reason:"unpublished"nonenoneunpublished
CancelexamsnoneJob: exam.cancellednonenonecancelled (student/guardian/teacher/invigilator)
Complete (manual/sweep)examsnonenonenonenonenone
Save marksexam_marksnonenonenonenonenone
Submit marksexam_marks_submissionsnoneJob: exam.marks_submittednonenonein-app operator signal only
Return / approve / finalize marksexam_marks_submissionsnonenonenonenonenone
Correct a finalized markexam_mark_corrections, exam_marks, exam_results.is_stale=truenonenonenonenonenone
Compute resultsexam_results, exam_subject_resultsnoneJob: exam.result_compute_requested (large roll only)nonenonenone
Withhold / release a resultexam_resultsnonenonenonenonenone
Publish resultsexams.results_published_atnoneJob: exam.result_publishednonenoneresult_published, one event per pupil
Reminder sweepexam_reminder_dispatchesnoneJob: exam.reminder_sweep (cron)nonenonereminder (student/guardian)
Lifecycle sweepexams.status='completed'noneJob: exam.lifecycle_sweep (cron)nonenonetomorrow/today (student/guardian), per class

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Version conflictConcurrent edit or a stale version on an action that WOULD change something409 naming the resource (EXAM_VERSION_CONFLICT, EXAM_MARKS_SUBMISSION_VERSION_CONFLICT)Reload and retryOptimistic-concurrency WHERE version=… on every write
Idempotent double-clickpublish/complete/routine-entry complete called again on a row already in the target stateNo-op success, current detail returned, checked BEFORE the version comparisonnone neededlifecycle/routine services
Exclusion conflict at publish/reschedule/swapRoom, class or invigilator collision, 23P01409 naming the specific pair (EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT)Resolve the schedule and retryrunExamWriteWithConflictGuard, resolveExamConflict
Reminder sweep misses a tickWorker outage up to 60 minutes (GRACE_MINUTES)The next successful tick still catches the window and dispatchesAutomatic, bounded by the grace windowexam-reminder-sweep.service.ts
Reminder offset below one sweep tickAn administrator configures an offset < 15 minutesRefused at the schema CHECK, never reaches the sweepChoose a valid offsetexam_reminder_rules_offset_bounded
Two reminder offsets too closeEnabled offsets closer than 15 minutes apartRefused before the writeSpace the offsets out409 EXAM_REMINDER_RULES_TOO_CLOSE
Result compute contentionTwo POST .../compute calls race for the same examSerialised by a per-exam Postgres advisory lock; the second waits, then re-reads the rollAutomaticExamResultsComputeService.runLocked
Large-roll computeRoll exceeds 500 pupilsMoved to the async job path; a repeated request while the first is in flight is a no-op via the dedupe keyPoll or wait for the jobExamJob.RESULT_COMPUTE
Notification job resolves to a deleted exam/entryThe public id in the payload no longer existsLogged and skipped, not retried foreverInvestigate why the row is goneExamsQueueProcessor.notifyFromPayload
Job name with no handlerA new ExamJob enum member ships with no Record entryCompile error, not a silent no-op at runtimeFix before mergeExamsQueueProcessor's exhaustive Record<ExamJob, …>
Colon in a job idA buildJobId call with a disallowed characterThrows at enqueue time rather than being silently rejected by BullMQ laterFix the id partspackages/jobs/src/build-job-id.ts
Write against a locked academic yearAny create/update/delete on the exam, its routine, marks, submissions, results or attachments once the year's locked_at is set409 ACADEMIC_SESSION_LOCKED, database-enforced regardless of which service issued the writeAn administrator unlocks the year to make a correctionpackages/db/src/migrations/0038_year_lock_records.sql
Result compute reaches a locked yearThe queued compute job runs after the exam's year was locked mid-flightResults are left as they are; no error, a warning is loggedNone needed — the year is historicalExamResultComputeAdapter
Lifecycle/reminder sweep reaches a locked yearThe hourly or 15-minute sweep's candidate query includes an exam whose year is lockedExcluded from the sweep entirely — no auto-completion, no reminderNone neededExamLifecycleSweepService, ExamReminderSweepService

11. Diagrams Required Per Module

Covered above: actor capability (§3), high-level plan → publish → invigilate → mark → verify → compute → publish-results flow (§5), sequence diagrams per major flow (§5.1–5.8), state machines for exam status, marks submission state, and result status (§7), data side-effect table (§9), error branches for cancel (§6.1), swap (§6.3), and the marks/results edge cases (§5.5–5.8).

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Create routine entryDefault component copied from the subjectAdminPOST .../routineA simple subject is publishable with no further setupexam_assessment_components row with class_subjects.max_marks/pass_marksexam-routine-write.service.ts:200-212
Publish previewClass-with-no-entries warningAdminGET .../publish-previewWarning only, never blockswarnings[].kind='class_no_entries'exam-publish-preview-read.service.ts:96-106
Publish previewNon-school-day warningAdminSameA paper on a day the calendar marks non-teaching is flagged, not refusedwarnings[].kind='not_school_day'exam-publish-preview-read.service.ts:183-196
Marks sheetLive roll, not a snapshotTeacher/AdminGET .../marksA pupil enrolled after the paper's own creation still appearsroll query scoped to the entry's class, read fresh every timeexam-marks-sheet.service.ts:319-351
SubmitImplicit "present" defaultTeacher/AdminPOST .../marks/submitA pupil with no exam_marks row at all is still checked for completeness — treated as an implicit present/nullfindIncompletePupils folds a missing row into "present, unscored"exam-marks-workflow.service.ts:289-352
RescheduleText-only edit needs no publish permissionAdminPATCH /exam-routine-entries/:id with only notes/examCenterNo ExamPublish_UPDATE required, no schedule_changed notificationtouchesSchedule gates both the permission check and the notificationexam-routine-write.service.ts:247-257
SwapScratch-slot-free deferred transactionAdminPOST /exam-routine-entries/swapTwo overlapping updates land in one transaction with no transient false conflictSET CONSTRAINTS … DEFERRED then … IMMEDIATEexam-routine-swap.service.ts:139-202
InvigilatorsFull replacement is diff-aware for notification purposesAdminPUT .../invigilatorsA no-op replacement (same set re-submitted) does not re-notifychanged computed before the writeexam-invigilators-write.service.ts:110-169
ComponentsVersion enforced only when suppliedAdminPUT .../componentsA brand-new row added alongside existing ones carries no token to checkcomponent.version !== undefined gateexam-components-write.service.ts:88-96
Class scopeRemoving a scored class is refused with namesAdminPUT /exams/:id/classesThe response names which classes already have recorded marksgrouped withRecordedMarks queryexam-classes.service.ts:76-111
CorrectionsCorrection requires a mandatory reasonAdminPOST /exam-marks/:id/correctEvery correction is traceable to a stated causereason is NOT NULL, format-checkedexam_mark_corrections_reason_format
CorrectionsRead history under a different codeAdminGET /exam-marks/:id/correctionsExamMarksCorrect_READ, distinct from the _UPDATE that writes a correctionseparate @Permissions() per routeexam-mark-corrections.controller.ts:42-72
ResultsNull percentage means "not computable"Any readerCompute, when every counted subject is absentRendered as "no data", never 0%totalMax===0 branch of computeOverallexam-result-rules.ts:214-224
ResultsWithheld is invisible, not badgedPupil/GuardianGET .../resultIdentical 404 to "not published"examNotFound() reused for both casesexam-portal-reads.service.ts:51-60,182-198
ResultsRelease re-derives rather than restoresAdminPOST .../releaseCorrect even if unrelated per-subject data changed while withheldcomputeOverall reapplied to the live snapshotexam-results.service.ts:284-375
Results historyWithheld excluded from academic history and dashboard countsPupil/GuardianGET /academic-history, GET /exams/dashboardA withheld exam never appears in eitherne(examResults.status, 'withheld') on both queriesexam-portal-reads.service.ts:256-303,349-359
Teacher's dayDuty priority tie-breakTeacherGET /schedule/todayAn invigilation "wins" as the current period over an ordinary class if both are liveKIND_PRIORITYteacher-today-portal.service.ts:45-49,304-316
Teacher's dayOne timer, not pollingTeacherSameClient sets exactly one timer to the next state changecomputeNextTransitionteacher-today-portal.service.ts:318-330
RemindersRescheduling re-arms itselfSystemSweep tick after a rescheduleNo cancellation logic exists or is neededThe dispatch key is (entry, rule, examDate, startMinute)exams.ts:1454-1469
Reminders60-minute grace windowSystemA missed tick (worker outage)The next successful tick still catches the reminderGRACE_MINUTES=60exam-reminder-sweep.service.ts:20-26
Lifecycle sweepTwo independent jobs in one tickSystemHourly cronAuto-completion and date-triggered notices never block each otherExamLifecycleSweepService.run runs both unconditionallyexam-lifecycle-sweep.service.ts:40-63
Notification rulesInvigilator-applicable events are a closed setAdminPUT /exam-notification-settings/:eventEnabling notify_invigilators on published/exam_today/etc. is refusedEXAM_NOTIFICATION_RULE_INVALID, mirrored by a DB CHECKexam-notification-rules.service.ts:82-91, exam_notification_rules_invigilator_applicable
AttachmentsAudience visibility is opt-in, never the defaultAdminPOST .../attachmentsA newly uploaded file defaults to administrator_onlyvisibility ?? "administrator_only"exam-attachments.service.ts:117
AttachmentsNever a public URLSystemAny uploadThe type is never listed under PUBLIC_TYPES; the storage-proxy denylist blocks it tooschema docblock on exam_attachmentsexams.ts:1497-1512

12.2 Business Process Diagram Pack

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
A teacher's marks access is always row-scoped, never the unscoped ExamMarks_* codesThe one-line "fix" of granting ExamMarks_UPDATE to staff would permanently hand every teacher every child's marks — grantToRoles never revokesTeacher sees only papers they teach or previously submittedTeacherExamMarksPortalService.isAuthorizedscoped /mobile/teacher/... routes onlyclass_subjects.teacher_id OR submitted_by_staff_id predicateexam-marks.integration.spec.ts, teacher-today.integration.spec.ts
Publishing (exam and results) is SUPERADMIN_ONLY_MODULESBoth authorise any further change reaching a published/result-published row, matching the TimetablePublish construction, not the more freely grantable NoticePublish/AssignmentPublishOnly superadmin holds it by default; a school delegates by creating a rolepermission-catalog.ts:397-409,558-566POST .../publish, .../results/publishgrant is irreversible in practice (onConflictDoNothing, never revoked)permissions-catalog.spec.ts
A returning/approving/finalizing verifier is a DIFFERENT actor from the one who entered marksExamMarksVerify_UPDATE is never granted to STAFF_PERMISSIONS/TEACHER_PERMISSIONSA teacher cannot self-approve their own submissionseed-auth.ts's omission is deliberate.../marks/{return,approve,finalize}separate permission code entirelyexam-marks-workflow.integration.spec.ts
Correcting a finalized mark is a DIFFERENT code from approving oneActivityAuditInterceptor derives module/action from @Permissions(); one code would make a correction indistinguishable from a routine approval in the audit trailAn auditor can filter corrections from approvalsExamMarksCorrect is its own catalogue modulePOST /exam-marks/:id/correctdistinct module: "ExamMarksCorrect" in every audit entryexam-marks.integration.spec.ts
exam_status on routine entries and invigilators is a copy, cascade-onlyOne UPDATE exams SET status=… must arm/disarm three exclusion constraints atomicallyNone visibleexam_status_copy_is_cascade_only trigger (pg_trigger_depth())any direct write to the copy columndatabase-level refusal, not a service conventionmigration 0030, exam-routine.integration.spec.ts
Marks entry opens on the clock, not on a clickGating on is_completed alone means one missed administrator click locks marks out forever, since teachers hold no route to set itTeacher always regains access once the paper's end time passesresolveMarksGateGET/PUT .../marks, .../marks/submitisCompleted OR opensAt<=now()exam-marks.integration.spec.ts
A withheld result is 404, not a badgeDisclosing "this result exists but is hidden" is itself a disclosure to a familyPupil/guardian see nothing different from "not published yet"examNotFound() reusedGET .../resultidentical error code for both casesexam-results.integration.spec.ts
A null percentage means "not computable"Rendering it as 0% would misreport a pupil who sat nothing as having failed everythingFamily sees "no data" rather than a false zerocomputeOverall's totalMax===0 branchGET .../result, /academic-historypercentage: null on the DTOexam-result-rules.spec.ts
A soft-deleted or record-inactive pupil is invisible to every audience readData integrity — the same rule the rest of the school domain followsPupil disappears from lists, not 403row-scope predicates filter deleted_at/record_status/enrolment statuslist/detail/marks/compute routesWHERE clause, not an application-level filterexam-portal.integration.spec.ts
Reminder rules key on the schedule, not a synthetic idA rescheduled paper must re-arm its reminders with no cancellation logic neededFamilies are never missed after a rescheduleexam_reminder_dispatches unique key (entry, rule, examDate, startMinute)none directlydedupe by natural keyexam-sweeps.integration.spec.ts

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Status is stored; "scheduled"/"in_progress" are derivedThe stored lifecycle never goes staleOne pure function (exam-display-status.util.ts) is the single sourceStore a computed display statusA read must know "today"Low — school-timezone clock is centralised
A subject examination has no max_marks column; components own every ceilingA school's real assessment structure (theory+practical+internal) is directly representableNo cross-row invariant to maintain (a CHECK cannot span rows)Store a total and reconcile with a triggerSlightly more joins to compute a subject totalLow — SUM on read is cheap
The attendance CHECK is a disjunction, not a biconditional"Present but not yet scored" — the entire exam-hall workflow — is representableA single, NULL-safe boolean expressionBiconditional (present ⇔ has a mark)Makes an absent pupil's zero unrepresentable too (a feature, not a cost)None — this was the actual defect caught in review
A withheld result is 404, never a distinguishable badgeA family cannot infer "something is being investigated about my child" from the response shape aloneOne code path serves both "not published" and "withheld"A visible "withheld" status on the readSlightly less informative for a legitimately curious familyNone — this is the intended privacy property
Reminder rules key on (entry, rule, date, minute) rather than a synthetic idA rescheduled paper silently re-arms; nothing needs to be found and cancelledNo delayed-job bookkeeping at allOne BullMQ delayed job per reminderNone significantLow — the alternative's failure mode (a stale job firing for a moved date) is strictly worse
Exclusion constraints are partial on IN ('published','completed'), not 'published' aloneA completed exam's room/invigilator stay correctly reserved against a NEW exam being published into the same slotOne predicate covers both live states without a second constraintFilter on 'published' onlyA single completion event would otherwise silently drop protectionCaught in review; the constraint probe would not have found it (probes exercise constraints, not sequences of events)
Marks-correction history is a separate append-only table, not a version bump on exam_marksA full previous/new/reason/who/when record survives, auditable independently of the live markOne INSERT-only trigger enforces it at the database levelOverwrite the mark and log to Mongo onlyThe Mongo audit log is best-effort and can be silently empty on an outage; the SQL history cannotNone — the SQL table is the load-bearing copy

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
PublishEmpty stateNo classes scopedBlocked409 EXAM_HAS_NO_CLASSESpublish guards
PublishFirst useBrand-new exam, first publishRoster of routine entries cascades to published in one statementnotifications fan out per classExamLifecycleService.publish
Marks submitLast pupilEvery present pupil scored except oneSubmit blocked, that one pupil named409 EXAM_MARKS_INCOMPLETE with details.studentsfindIncompletePupils
Marks submitDuplicate actionSubmit called twice on an already-submitted rowSecond call rejected — not a silent no-op409 EXAM_MARKS_TRANSITION_INVALIDworkflow fromStates gate
SwapConcurrent actionTwo swaps touch overlapping entries at onceDeferred-constraint transaction serialises correctly; a genuine third-paper collision still raises409 naming the real conflictexam-routine-swap.service.ts
CorrectionExpired state (not finalized)Correction attempted on draft/submitted/approved/returnedReject, force finalize first409 EXAM_MARKS_TRANSITION_INVALIDExamMarkCorrectionsService.correct
ComputePermission mismatchA caller without ExamResults_UPDATE calls computeRefused by the guard before the service runs403RoleGuard
Guardian readGuest/guardian limitationGuardian requests a non-linked child's examRefused regardless of the exam's own visibility404resolveOwnChildId
Publish resultsMissing dependencyA routine entry has no finalized submissionPublish guard refuses, naming the count409 EXAM_RESULTS_NOT_ALL_FINALIZEDExamResultsService.publish
Reminder sweepCache stale/missN/A — no cache layer in this module———
Reminder sweepQueue failureA worker outage spans more than one 15-minute tick but less than the 60-minute grace windowRecovered on the next successful tickreminder still dispatches, onceGRACE_MINUTES
Lifecycle sweepOverlapping tickTwo sweep ticks run close together (a restart mid-minute)buildJobId buckets at exactly the cron's own period, so the second is deduplicatedno duplicate completion/notificationexam.scheduler.ts
Teacher's dayUnsupported/missing assignmentTeacher has no class-subjects and no invigilation todayEmpty periods[], not an errorsummary.total=0TeacherTodayPortalService.getToday
Marks sheetRoll changes mid-cycleA pupil is enrolled into the class after the routine entry was createdAppears on the live roll immediately, with no exam_marks row (not_applicable at compute time until scored)sheet always re-reads the rollloadRoll

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Submit marksexam_routine_entries, exam_marks, exam_marks_submissions, live rollexam_marks_submissionsnoneexam.marks_submitted (in-app)state, submittedAt, submittedByStaffId, version
Correct a finalized markexam_marks, exam_marks_submissions, exam_routine_entriesexam_mark_corrections, exam_marks, exam_results.is_stalenonenonecorrected attendance/obtainedMarks/version
Compute resultsexam_classes, student_class_enrollments, exam_routine_entries, exam_assessment_components, exam_marks, grading_scales/grading_scale_bandsexam_results, exam_subject_resultsnoneexam.result_compute_requested (large roll)mode, studentCount, resultCount
Publish resultsexam_routine_entries, exam_marks_submissions, exam_results.is_staleexams.results_published_atnoneexam.result_published (per pupil)n/a (background)
Teacher's dayclass_timetable_entries, exam_routine_entries, exam_invigilatorsnonenonenoneperiods[], currentPeriodId, nextTransitionAt

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish (plan/sit/mark/verify/see a result).
  • The doc explains what the backend does that the actor does not see (cascade-armed exclusions, advisory-locked compute, deferred-constraint swaps, the reminder key's self-re-arming).
  • The doc covers every minor flow and branch (idempotent replays, non-school-day warnings, text-only reschedules, scratch-slot reminder swaps).
  • The doc includes user, admin, worker, and system flows.
  • The doc explains business logic, tradeoffs, and rationale.
  • The doc maps every flow to API routes and backend side effects.
  • The doc includes diagrams appropriate to each flow type.
  • The doc covers edge cases and failure recovery.

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.
  • No TDD sibling doc exists for this module; none is referenced as required.

See Also

On this page

Exams Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Administrator plans an exam and builds its routineSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Publishing an exam — the consequential actSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 Pupil and guardian read the exam schedule, calendar and resultsSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.4 The teacher's daySummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.5 Teacher enters and submits marksSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Verifier reviews, returns, approves and finalises marksSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Correcting a finalized markSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.8 Computing, withholding, releasing and publishing resultsSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases6. Admin Flows6.1 Cancel and reinstate an exam6.2 Complete (manual and swept), archive, unarchive6.3 Rescheduling and swapping papers6.4 Manage exam types6.5 Manage grading scales and bands6.6 Manage notification rules and reminder rules6.7 Manage attachments and their audience6.8 Exam-day overview and dashboard totals7. Lifecycle and State Transitions7.1 Exam status7.2 Marks submission state (exam_marks_submissions.state)7.3 Mark attendance and "present but not yet scored"7.4 Result status and publication9. Data and Side Effects by Flow10. Error and Recovery Flows11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also