A 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.
Actors
Administrator, 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 outputs
Exam 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.
Create/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.
Teacher
Enter 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.
Guardian
Read 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/system
Run 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.
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.
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.
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.
Hard-gate checks run first (classes, components, floor band)
ExamLifecycleService.publish
2
Backend
Cascade the status
exams.status='published' cascades exam_status onto every exam_routine_entries/exam_invigilators row in ONE statement, which is what arms the exclusions
ON UPDATE cascade on exams_id_status_unique
3
Backend
Conflict check
Any 23P01 from the three exclusion constraints is resolved to a named pair before the outer transaction rolls back
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.
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 NULLand the pupil's own exam_results.status <> 'withheld'.
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.
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.
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.
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.
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.
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.
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.
For 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
2
Backend
Per-subject arithmetic
computeSubjectFromMarks — 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)
Withhold 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
5
Admin
POST /exams/:publicId/results/publish
resultsPublishedAt/resultsPublishedByUserId set once
ExamResultsService.publish
6
Backend
Notify
One exam.result_published.{student,guardian} event per pupil whose result is published and not withheld
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.
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.
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.
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).
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.
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.
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.
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).
Classes scoped; every entry has a component; grading scale (if any) has a floor band; no conflict
Cascades exam_status to every routine entry/invigilator row; roster arms the three GiST exclusions; notifies pupils/guardians/teachers
exams
published
Unpublish
draft
No results published; no paper's marks are finalized
Cascades back; exam.unpublished reuses the schedule_changed notification rule with an honest kind
exams
published/completed
Cancel
cancelled
Results not yet published
Notifies every audience; invigilators individually
exams
cancelled
Reinstate
draft
—
Clears cancellation and publish stamps; does not re-publish
exams
published
Complete (manual or hourly sweep)
completed
Every paper's own end instant has passed in school time
Sweep and manual route share the identical guard
exams
completed/cancelled
Archive
(same status) + archived_at set
—
Freezes further lifecycle actions; records archived_from_status
exams
archived
Unarchive
Restores 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.
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.
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.
Entity
From
Event/Action
To
Guard Condition
Side Effects
exam_results
(none)
Compute
derived status
Every counted subject present or explicitly absent/withheld
is_stale=false; per-subject snapshot in exam_subject_results
exam_results
any
Correction to a finalized mark
(unchanged)
—
is_stale=true on every result of the exam; publish is refused until recomputed
exam_results
any (non-withheld)
Withhold
withheld
—
The pupil/guardian read answers the identical 404 as "not published"
exam_results
withheld
Release
re-derived via computeOverall from the unchanged subject snapshot
—
No stored "prior status" is read; every field is recomputed
exams.resultsPublishedAt
null
Publish
set
Every routine entry finalized; no result stale
One exam.result_published notification per pupil, excluding withheld
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).
Publishing (exam and results) is SUPERADMIN_ONLY_MODULES
Both authorise any further change reaching a published/result-published row, matching the TimetablePublish construction, not the more freely grantable NoticePublish/AssignmentPublish
Only superadmin holds it by default; a school delegates by creating a role
permission-catalog.ts:397-409,558-566
POST .../publish, .../results/publish
grant 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 marks
ExamMarksVerify_UPDATE is never granted to STAFF_PERMISSIONS/TEACHER_PERMISSIONS
A teacher cannot self-approve their own submission
seed-auth.ts's omission is deliberate
.../marks/{return,approve,finalize}
separate permission code entirely
exam-marks-workflow.integration.spec.ts
Correcting a finalized mark is a DIFFERENT code from approving one
ActivityAuditInterceptor derives module/action from @Permissions(); one code would make a correction indistinguishable from a routine approval in the audit trail
An auditor can filter corrections from approvals
ExamMarksCorrect is its own catalogue module
POST /exam-marks/:id/correct
distinct module: "ExamMarksCorrect" in every audit entry
exam-marks.integration.spec.ts
exam_status on routine entries and invigilators is a copy, cascade-only
One UPDATE exams SET status=… must arm/disarm three exclusion constraints atomically
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.