Skoolsewa - School Management System Docs
Developer ResourcesExams

Exams Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Examinations and Results.

Exams Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/exams/**/*.module.ts (18 modules), apps/api/src/app.module.ts, apps/api/src/modules/mobile/mobile.module.ts, apps/api/src/config/swagger/swagger-documents.tsComposition, ADMIN_MODULES/MOBILE_CHILDREN membership, fast-runtime-profile exclusion of the workers module
Controllers22 controllers under apps/api/src/modules/exams/**/*.controller.tsEvery route path, HTTP verb and @Permissions() code, read directly off the decorators
Services34 services/utils under apps/api/src/modules/exams/**/*.service.ts, *.util.tsBusiness logic, transactions, side effects, all read in full
Schemapackages/db/src/schema/school/exams.ts (17 tables, 8 enums)Every column, constraint, index, relation
Migrationspackages/db/src/migrations/0029_exam_module.sql … 0032_exam_schema_hardening.sql0029 creates the tables and enums; 0030 adds the three GiST exclusions, the version triggers, the two guard triggers and every seed row; 0031 adds the completed_at CHECK, the exam_results.class_id key and the relaxed results-publication CHECK; 0032 adds the result-percentage and finaliser CHECKs and drops six redundant indexes
Down migrationspackages/db/src/migrations/down/0029_exam_module.down.sql … 0032_exam_schema_hardening.down.sqlApplied newest first, each restores the schema exactly as it stood before its migration; 0029's down drops every exam table and its data, so take a pg_dump first
Jobspackages/jobs/src/index.ts (QueueName.EXAMS, ExamJob, ExamSweepPayload, ExamNotifyPayload, ExamResultComputePayload)Queue contract and payload shapes
Error codesapps/api/src/common/types/error-codes.ts — 55 EXAM_*/GRADING_SCALE_* codes under the "EXAMINATIONS AND RESULTS" headingEvery code's throw site
Permissionspackages/db/src/authorization/permission-catalog.ts, apps/api/src/common/authorization/role.guard.ts, apps/api/test/structure/route-permissions.spec.ts13 Exam* modules, the NO_PERMISSION_ADMIN_HANDLERS mirror for the 20 portal handlers
Time compositionapps/api/src/utils/school-time/school-time.util.tstoInstant/toInstantRange, the DST-resolution rules
Upload policypackages/storage/src/upload-type.ts, packages/storage/src/upload-allowlist.tsEXAM_ATTACHMENT private, excluded from PUBLIC_TYPES

2. Backend Scope and Boundaries

Owns

  • The school's own catalogue of examination types (exam_types) and configurable grading scales with GPA bands (grading_scales, grading_scale_bands).
  • Examination authoring: create, update (period, name, notes), delete (draft-only, no class scope), class-scope replacement, attachments (administrator-only or audience-visible).
  • Lifecycle: publish, unpublish, cancel, reinstate, complete (early or hourly-swept), archive, unarchive — one service, one transition per call, database-enforced concurrency.
  • The routine builder: per-class, per-subject routine entries (date, time, room), assessment components (theory/practical/internal/viva/project/other), invigilator assignment, a swap operation that relocates two papers in one transaction, and a publish-preview/conflicts report.
  • Marks: the sheet read/write against the live class roll, a five-state submission workflow (draft → submitted → returned/approved → finalized), and append-only corrections to already-finalized marks.
  • Results: synchronous or queued computation of every pupil's per-subject and overall outcome, withhold/release of an individual result, and a separate results-publication gate from the exam's own lifecycle.
  • Time-driven work: a 15-minute reminder sweep keyed on the live schedule, an hourly lifecycle sweep that auto-completes ended exams and emits "tomorrow"/"today" notifications.
  • Notifications: one ExamNotifierService resolving recipients from live rows and the module's own rule tables, calling the shared NotificationService; one ExamNotificationScopeService doing every recipient-resolution query.
  • Settings: which channels/audiences each of the seven notification events uses, and the reminder-offset list (with its own scratch-slot replace algorithm).
  • The teacher, student and guardian portal surfaces for exams, results, marks entry and invigilation duties — sharing no code with the admin controllers beyond the read-only exam-shared-resolvers/exam-conflict utilities and, for marks, delegating the write itself to the admin-side marks services under a narrower predicate.
  • A calendar projection (ExamCalendarProjectionService) that other modules read from — the calendar has no exam rows of its own.

Does Not Own

  • Classes, sections, rooms, class-subjects, academic sessions, staff and student enrolment (academic-structure, people modules) — exams reference them but never write them.
  • Storage bytes themselves (@skoolsewa/storage's StorageManager) — the module owns exam_attachments metadata and calls the manager directly; nothing here is ever signed.
  • The outbox and BullMQ infrastructure (OutboxService, @skoolsewa/jobs) — the module is a producer/consumer, not the owner.
  • Notification delivery mechanics (channel senders, per-user preference resolution) — NotificationService and its channel drivers own that; this module supplies templates, recipients and rule configuration.
  • Authentication and role/permission evaluation (JwtAuthGuard, RoleGuard) — the module supplies @Permissions() codes for its admin surface and its own audience assertions for the portal surface.
  • Timezone arithmetic itself — SchoolClockService and apps/api/src/utils/school-time/school-time.util.ts own composing a date and a minute-of-day into an instant; every exam service that needs "now" or "is this instant past" calls through them.
  • The academic calendar's own day classification (academic_calendar_days) — read, never written, by ExamCalendarProjectionService and the publish-preview's school-day warning.

Source of Truth

ConcernSource of TruthNotes
Exam lifecycle stateexams.status (stored)"Scheduled"/"in progress" are DERIVED (deriveExamDisplayStatus) from status = 'published' plus today's date in the school's timezone — a stored "in progress" is wrong from the minute after it is written.
Routine entry status copyexam_routine_entries.exam_status, exam_invigilators.exam_statusCopies maintained ONLY by ON UPDATE cascade from exams.status; a direct write is refused by a trigger (§9's guard).
Marks submission stateexam_marks_submissions.stateThe one authority for what a paper's marks workflow allows next; exam_marks.attendance/obtained_marks are the data the state gates.
Computed resultexam_results, exam_subject_resultsPure functions in exam-result-rules.ts, orchestrated by ExamResultsComputeService; is_stale is the reconciliation flag a correction sets and a recompute clears.
Concurrency tokenversion (bigint, bumped by bump_row_version() triggers) on exam_types, grading_scales, exams, exam_routine_entries, exam_assessment_components, exam_marks_submissions, exam_marks, exam_results, exam_notification_rules, exam_reminder_rulesClients send back the last-seen version; a mismatch is EXAM_VERSION_CONFLICT / EXAM_MARKS_SUBMISSION_VERSION_CONFLICT (409).
Room/class/invigilator conflict-freedomThree GiST EXCLUDE constraints (migration 0030)Armed only while exam_status IN ('published','completed'); the database, not a service check, is what makes overlap unrepresentable once live.
Which recipients/channels an event usesexam_notification_rules row for that eventSeven rows, seeded, never created or deleted by the API — only read and updated.
Reminder dedupeexam_reminder_dispatches — (routine_entry_id, rule_id, exam_date, start_minute)The row IS the claim; a reschedule changes the key and re-arms by itself.

3. Module Composition

There is no aggregate ExamsModule. Eight admin leaf modules, one workers module, and three portal leaf modules are each composed directly by app.module.ts / mobile.module.ts, mirroring how ADMIN_MODULES and MOBILE_CHILDREN must each name a module that declares its own controllers.

ModuleTypePathControllersProvidersExportsResponsibility
ExamTypesAdminModuleLeaftypes/admin/exam-types-admin.module.tsExamTypesController—ExamTypesService/api/exam-types*.
ExamsAdminModuleLeafauthoring/admin/exams-admin.module.tsExamsController, ExamLifecycleController, ExamClassesController, ExamAttachmentsController——/api/exams* except the routine group.
ExamAuthoringCoreModuleLeaf (shared)authoring/core/exam-authoring-core.module.tsNoneExamDetailService, ExamWriteService, ExamLifecycleService, ExamClassesService, ExamAttachmentsService, ExamCalendarProjectionServiceSameThe authoring services ExamsAdminModule calls into.
ExamDashboardAdminModuleLeafdashboard/admin/exam-dashboard-admin.module.tsExamDashboardControllerExamDashboardServiceExamDashboardService/api/exam-dashboard.
ExamRoutineAdminModuleLeafroutine/admin/exam-routine-admin.module.tsExamRoutineController, ExamRoutineEntryController, ExamDayController——/api/exams/:publicId/routine, /api/exam-routine-entries*, /api/exam-day.
ExamRoutineCoreModuleLeaf (shared)routine/core/exam-routine-core.module.tsNoneExamRoutineReadService, ExamRoutineWriteService, ExamComponentsWriteService, ExamInvigilatorsWriteService, ExamRoutineSwapService, ExamPublishPreviewReadService, ExamDayReadServiceSameReads, the four write seams, publish-preview, exam-day.
ExamMarksAdminModuleLeafmarks/admin/exam-marks-admin.module.tsExamMarksController, ExamMarksWorkflowController, ExamMarkCorrectionsController——/api/exam-routine-entries/:publicId/marks*, /api/exam-marks*.
ExamMarksCoreModuleLeaf (shared)marks/core/exam-marks-core.module.tsNoneExamMarksSheetService, ExamMarksWorkflowService, ExamMarkCorrectionsServiceSameThe sheet, the state machine, corrections. Also imported directly by TeacherExamsPortalModule.
ExamGradingAdminModuleLeafgrading/admin/exam-grading-admin.module.tsGradingScalesControllerGradingScalesServiceGradingScalesService/api/grading-scales*.
ExamResultsAdminModuleLeafresults/admin/exam-results-admin.module.tsExamResultsController——/api/exams/:publicId/results*.
ExamResultsCoreModuleLeaf (shared)results/core/exam-results-core.module.tsNoneExamResultsService, ExamResultsComputeServiceSameShared by ExamResultsAdminModule (the compute route) and ExamWorkersModule (the RESULT_COMPUTE job), so neither imports the other's module.
ExamSettingsAdminModuleLeafsettings/admin/exam-settings-admin.module.tsExamNotificationSettingsController, ExamReminderRulesControllerExamNotificationRulesService, ExamReminderRulesService—/api/exam-notification-settings, /api/exam-reminder-rules.
ExamsSharedModuleLeaf (shared)shared/exams-shared.module.tsNoneExamSharedResolversServiceSameThe publicId → row resolvers every other submodule reads.
ExamWorkersModuleLeafworkers/exam-workers.module.tsNoneJobFailureRecorderService, ExamNotificationScopeService, ExamNotifierService, ExamReminderSweepService, ExamLifecycleSweepService, ExamScheduler, ExamResultComputeAdapter (bound to ExamResultComputeRunner), ExamsQueueProcessor—Crons + the one processor on QueueName.EXAMS. Imported only outside API_RUNTIME_PROFILE=fast.
ExamPortalCoreModuleLeaf (shared)portal/core/exam-portal-core.module.tsNoneExamPortalReadsServiceExamPortalReadsService, UploadModuleThe pupil-scoped reads shared by student and guardian portals.
StudentExamsPortalModuleLeafportal/student/student-exams-portal.module.tsStudentExamsPortalControllerStudentExamsPortalService—/api/mobile/student/exams*, /api/mobile/student/academic-history.
GuardianExamsPortalModuleLeafportal/guardian/guardian-exams-portal.module.tsGuardianExamsPortalControllerGuardianExamsPortalService—/api/mobile/guardian/children/:id/exams*, .../academic-history.
TeacherExamsPortalModuleLeafportal/teacher/teacher-exams-portal.module.tsTeacherExamsPortalController, TeacherExamMarksPortalControllerTeacherExamsPortalService, TeacherExamDutiesPortalService, TeacherExamMarksPortalService—/api/mobile/teacher/exams*, /api/mobile/teacher/invigilations.

4. File and Directory Map

apps/api/src/modules/exams/
  authoring/
    admin/              exams.controller.ts, exam-lifecycle.controller.ts, exam-classes.controller.ts, exam-attachments.controller.ts, exams-admin.module.ts
    core/               exam-detail/write/lifecycle/classes/attachments/calendar-projection services, exam-display-status.util.ts, exam-attachments.multer.ts, dto/
  dashboard/
    admin/              exam-dashboard.controller.ts, exam-dashboard-admin.module.ts
    core/               exam-dashboard.service.ts, dto/
  grading/
    admin/              grading-scales.controller.ts, exam-grading-admin.module.ts
    core/               grading-scales.service.ts, dto/
  marks/
    admin/              exam-marks.controller.ts, exam-marks-workflow.controller.ts, exam-mark-corrections.controller.ts, exam-marks-admin.module.ts
    core/               exam-marks-sheet/workflow/mark-corrections services, exam-marks-gate.util.ts, dto/
  portal/
    core/               exam-portal-reads.service.ts, exam-portal-download.util.ts, exam-portal-core.module.ts, dto/
    guardian/           guardian-exams-portal.controller.ts, guardian-exams-portal.service.ts, guardian-exams-portal.module.ts
    student/            student-exams-portal.controller.ts, student-exams-portal.service.ts, student-exams-portal.module.ts
    teacher/            teacher-exams-portal.controller.ts, teacher-exam-marks-portal.controller.ts, teacher-exams-portal.service.ts,
                        teacher-exam-duties.service.ts, teacher-exam-marks-portal.service.ts, teacher-exam-scope.util.ts, teacher-exams-portal.module.ts, dto/
  results/
    admin/              exam-results.controller.ts, exam-results-admin.module.ts
    core/               exam-results.service.ts, exam-results-compute.service.ts, exam-result-rules.ts (+.spec.ts), exam-results-core.module.ts, dto/
  routine/
    admin/              exam-routine.controller.ts, exam-routine-entry.controller.ts, exam-day.controller.ts, exam-routine-admin.module.ts
    core/               exam-routine-read/write/swap services, exam-components-write.service.ts, exam-invigilators-write.service.ts,
                        exam-publish-preview-read.service.ts, exam-day-read.service.ts, exam-routine-core.module.ts, dto/
  settings/
    admin/              exam-notification-settings.controller.ts, exam-reminder-rules.controller.ts, exam-notification-rules.service.ts,
                        exam-reminder-rules.service.ts, exam-settings-admin.module.ts, dto/
  shared/
    exam-conflict.util.ts, exam-shared-resolvers.service.ts, exam-write.errors.ts, exams-shared.module.ts, dto/
  types/
    admin/              exam-types.controller.ts, exam-types-admin.module.ts
    core/               exam-types.service.ts, dto/
  workers/
    exam.scheduler.ts, exam-lifecycle-sweep.service.ts, exam-reminder-sweep.service.ts, exam-notification-scope.service.ts,
    exam-notifier.service.ts, exam-notify-format.util.ts, exam-result-compute.contract.ts, exam-result-compute.adapter.ts,
    exams-queue.processor.ts, exam-workers.module.ts
  __tests__/            10 integration specs + fixtures
FilePurposeKey ExportsNotes
shared/exam-shared-resolvers.service.tspublicId → row resolversExamSharedResolversService, ResolvedExam, ResolvedRoutineEntry, ResolvedComponentReturns only the columns a caller needs to gate a write; never a mapped response DTO.
shared/exam-conflict.util.tsThe two-pass conflict resolverresolveExamConflict, runExamWriteWithConflictGuard, loadPublishedSiblingCandidates, throwExamConflictRead by SP-1's publish flow and every routine write; pass 1 against other published/completed exams, pass 2 against the in-memory candidate set.
shared/exam-write.errors.tsThe module's constraint-translation maptranslateExamWriteErrorBackstop only — the primary conflict path names the colliding row and throws before this is reached.
authoring/core/exam-lifecycle.service.tsEvery one of the seven lifecycle transitionsExamLifecycleServiceOne method per transition; every transition is one conditional UPDATE.
authoring/core/exam-display-status.util.tsdisplayStatus derivationderiveExamDisplayStatusPure function of stored status + dates + today.
routine/core/exam-routine-write.service.tsRoutine entry create/patch/remove/completeExamRoutineWriteServiceThe create path copies class_subjects.max_marks/pass_marks into a default component.
routine/core/exam-routine-swap.service.tsPOST /exam-routine-entries/swapExamRoutineSwapServiceThe only caller of SET CONSTRAINTS ... DEFERRED in this module.
marks/core/exam-marks-gate.util.tsThe marks-open gateresolveMarksGateShared by the sheet and the submit transition so the two can never disagree.
results/core/exam-result-rules.tsPure result arithmeticcomputeSubjectFromMarks, computeOverall, lookupBand, round2No NestJS, no database, no clock — exhaustively spec'd in exam-result-rules.spec.ts.
results/core/exam-results-compute.service.tsOrchestrates computationExamResultsComputeService, EXAM_SYNC_COMPUTE_MAX_STUDENTS (500)compute() is the request path (sync or queued); computeNow() is the worker's own entry point.
workers/exam-result-compute.contract.ts / exam-result-compute.adapter.tsThe seam onto result computationExamResultComputeRunner (abstract), ExamResultComputeAdapterThe adapter calls computeNow, never compute — see §9.
workers/exam-notifier.service.tsEvery business occurrence turned into a notificationExamNotifierService, ExamNotifyEventOne NotificationService.send() call per audience per event — never a compound send.
workers/exam-notification-scope.service.tsEvery recipient-resolution queryExamNotificationScopeServiceRead-only, always inside the caller's own transaction.
workers/exam-reminder-sweep.service.tsThe 15-minute reminder sweepExamReminderSweepServiceClaims idempotency by inserting exam_reminder_dispatches first.
workers/exam-lifecycle-sweep.service.tsThe hourly lifecycle sweepExamLifecycleSweepServiceTwo jobs in one run: auto-complete, then tomorrow/today notify.
workers/exams-queue.processor.tsThe one processor on QueueName.EXAMSExamsQueueProcessorRecord<ExamJob, handler> — exhaustive by the type system.
portal/teacher/teacher-exam-marks-portal.service.tsThe module's narrowest authorization boundaryTeacherExamMarksPortalServiceDelegates the write whole to ExamMarksSheetService/ExamMarksWorkflowService; owns only the scoping predicate.
portal/core/exam-portal-download.util.tsAttachment stream + headersopenExamAttachmentStream, setExamAttachmentDownloadHeadersSandboxed CSP, nosniff, always attachment disposition.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/exams.ts

One file holds all 17 tables and 8 enums for this domain. The three GiST exclusion constraints, the two bump_row_version trigger attachments' companion DDL, and the two guard triggers live in packages/db/src/migrations/0030_exam_module_constraints_and_seeds.sql, because drizzle-kit has no builder for either an EXCLUDE constraint or a trigger.

5.2 Tables and Collections

exam_types

The school's own catalogue — Terminal, Unit Test, Pre-Board — a table rather than a pgEnum so a school can add or retire a type without a migration. Thirteen rows are seeded (FIRST_TERM … SCHOLARSHIP).

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PKInternal.
public_iduuidNouuidv7()uniqueExternal identifier.
nametextNo—CHECK trimmed, no control chars, ≤120 chars; unique on lower(name) partial on is_activeRetiring frees the name for reuse.
codetextNo—CHECK ^[A-Z0-9_]{1,16}$; unique partial on is_activeImmutable in practice — renaming the code would silently repoint every report keyed on it.
sort_orderintegerNo—CHECK >= 0
is_activebooleanNotrueNo deleted_at — the partial-unique-index construction is the only lifecycle axis.
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

No index on is_active: the table holds a few dozen rows, and the two partial unique indexes already cover the active-row lookups. Hard DELETE is refused by exams.exam_type_id's ON DELETE restrict the moment a type is filed against.

grading_scales

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
public_iduuidNouuidv7()unique
nametextNo—CHECK trimmed, ≤120 chars; unique on lower(name) partial on is_active
uses_gpabooleanNo—
conditional_pass_max_failed_subjectssmallintNo0CHECK 0..100 means passed_with_conditions is never written — a CONFIGURED absence.
is_activebooleanNotrue
is_defaultbooleanNofalsepartial unique on is_default WHERE is_defaultA constant-true predicate — every row with is_default indexes the same value, so a second collides.
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

Extra constraint: grading_scales_id_uses_gpa_unique — unique(id, uses_gpa), not uniqueIndex, because it is the target of grading_scale_bands' composite FK and drizzle-kit emits every ADD CONSTRAINT … FOREIGN KEY before every CREATE INDEX; a uniqueIndex here would not yet exist when the referencing FK is created, failing a clean-database migration with SQLSTATE 42830. No index on is_active: a school keeps a handful of scales.

grading_scale_bands

A band has a floor and no ceiling — see the design-decisions section. uses_gpa is a copy of the parent scale's, carried by a cascading composite FK so grading_scale_bands_gpa_complete can be a plain single-row CHECK.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
scale_idintegerNo—Composite FK (scale_id, uses_gpa) → grading_scales(id, uses_gpa), ON DELETE/UPDATE cascade
uses_gpabooleanNo—Copy, cascade-maintainedNever written directly by the application.
grade_labeltextNo—CHECK trimmed, no control chars, ≤8 chars; unique on (scale_id, lower(grade_label))
min_percentnumeric(5,2)No—CHECK 0..100; unique on (scale_id, min_percent)The floor. No max_percent column exists.
grade_pointnumeric(4,2)Yes—CHECK NULL OR 0..5; CHECK NOT uses_gpa OR grade_point IS NOT NULLThe second CHECK is what refuses turning GPA on for a scale whose bands have none.
is_passingbooleanNo—
sort_ordersmallintNo0
created_at/updated_attimestamptzNonow()

Index: grading_scale_bands_scale_idx on (scale_id, min_percent) — the scan lookupBand mirrors (greatest min_percent <= pct).

exams

One examination: definition, period (an envelope, not a per-subject schedule), lifecycle and publication stamps.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
public_iduuidNouuidv7()unique
academic_session_idintegerNo—FK academic_sessions.id, RESTRICT; part of exams_id_session_unique
exam_type_idintegerNo—FK exam_types.id, RESTRICT
grading_scale_idintegerYes—FK grading_scales.id, RESTRICTNullable on purpose — a school that records marks without grading gets marks-only results; RESTRICT (not SET NULL) so a scale in use can never be silently stripped from a published result.
nametextNo—CHECK trimmed, no control chars, ≤160 chars
descriptiontextYes—CHECK NULL OR (trimmed, ≤2000 chars)Audience-visible.
internal_notetextYes—CHECK NULL OR (trimmed, ≤2000 chars)Administrator-internal; absent from every portal DTO and notification variable map.
statusexam_status enumNodraftenum draft/published/completed/cancelled/archivedOnly these five are stored; "scheduled"/"in progress" are derived. Part of exams_id_status_unique, the cascade target for the two child tables' status copy.
start_date/end_datedateNo—CHECK end_date >= start_dateThe envelope; each routine entry carries its own date inside it.
published_attimestamptzYes—CHECK (status='draft') = (published_at IS NULL)Biconditional over two non-null booleans — never NULL, never passes by accident.
published_by_user_iduuidYes—FK users.id, SET NULL
cancelled_at/cancelled_by_user_id/cancellation_reasonmixedYes—CHECK: cancelled_at set iff status='cancelled' or (status='archived' and archived_from_status='cancelled')Deliberately not a plain biconditional against status='cancelled' alone — see §6.6 for the defect this avoided.
public_cancellation_notetextYes—CHECK formatThe only cancellation text a family ever sees; cancellation_reason never reaches a template.
completed_attimestamptzYes—CHECK exams_completed_at_matches_status: required when status='completed', and allowed only on completed, cancelled or archivedSet by the manual complete route or the lifecycle sweep — never before every paper's end instant has passed.
archived_attimestamptzYes—CHECK (status='archived') = (archived_at IS NOT NULL)
archived_from_statusexam_status enumYes—CHECK (status='archived') = (archived_from_status IS NOT NULL) AND (archived_from_status IS NULL OR archived_from_status IN ('completed','cancelled'))What unarchive restores. Written as an equivalence over IS NOT NULL, not a plain OR, because NULL IN (...) is NULL and a bare disjunction let an archive land with no archived_from_status at all — caught by the constraint probe.
results_published_at/results_published_by_user_idmixedYes—CHECK exams_results_publication_complete: a publisher implies a timestampThe results-publication gate, independent of the exam's own lifecycle. The publisher alone may be NULL, because its key is SET NULL when that user is erased.
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

Indexes: exams_session_status_idx (academic_session_id, status), exams_dates_idx (start_date, end_date), exams_type_idx, exams_grading_scale_idx. exams_id_session_unique and exams_id_status_unique are unique(...) constraints, not uniqueIndex, for the same FK-ordering reason as grading_scales_id_uses_gpa_unique.

exam_classes

Which classes sit this examination.

ColumnTypeNullableNotes
idserialNoPK.
exam_idintegerNoComposite FK (exam_id, academic_session_id) → exams(id, academic_session_id), CASCADE delete, RESTRICT update.
class_idintegerNoComposite FK (class_id, academic_session_id) → classes(id, academic_session_id), RESTRICT.
academic_session_idintegerNoBoth composite FKs are NOT NULL on both columns, so MATCH SIMPLE's any-column-NULL skip is unreachable — asserted by the constraint probe.
created_attimestamptzNonow().

Unique: exam_classes_exam_class_unique (exam_id, class_id) — one scope row per class. exam_classes_id_exam_class_unique (id, exam_id, class_id) is the composite FK target for exam_routine_entries. Indexes on exam_id, class_id.

exam_routine_entries

One subject examination for one class: when it is sat, where, by whom it is invigilated.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
public_iduuidNouuidv7()unique
exam_idintegerNo—
exam_class_idintegerNo—Composite FK (exam_class_id, exam_id, class_id) → exam_classes(id, exam_id, class_id), CASCADE/RESTRICT
class_idintegerNo—
class_subject_iduuidNo—Composite FK (class_subject_id, class_id) → class_subjects(id, class_id), RESTRICTProves the subject is one this class actually teaches.
exam_statusexam_status enumNo—Copy, cascade-maintained; composite FK (exam_id, exam_status) → exams(id, status), CASCADE/CASCADENever written directly — a guard trigger refuses a change at pg_trigger_depth() = 1. See §9.
exam_datedateNo—
start_minute/end_minutesmallintNo—CHECK start_minute >= 0 AND end_minute <= 1440 AND end_minute > start_minuteend_minute > start_minute (not >=) is load-bearing: int4range(600,600) is empty and overlaps nothing, so a zero-length entry would be invisible to all three exclusions while appearing live on every screen.
room_idintegerYes—FK rooms.id, SET NULLA deleted room must not make history undeletable; the read side reports "unassigned".
exam_centertextYes—CHECK format, ≤160 charsAudience-visible.
notestextYes—CHECK format, ≤500 charsAudience-visible.
internal_notetextYes—CHECK format, ≤500 charsAdministrator-internal.
is_completedbooleanNofalseCHECK is_completed = (completed_at IS NOT NULL)The exam-DAY administration flag — not the marks gate; see §6.6.
completed_attimestamptzYes—
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

Unique: exam_routine_entries_schedule_unique (id, exam_date, start_minute, end_minute, exam_status) — the composite FK target exam_invigilators carries by cascade. exam_routine_entries_one_paper_per_subject (exam_class_id, class_subject_id) — one paper per subject per class per exam; two papers are two components, not two entries. Indexes: exam_routine_entries_exam_idx, exam_routine_entries_class_subject_idx, exam_routine_entries_date_idx, exam_routine_entries_class_status_idx (class_id, exam_status) (the audience read), exam_routine_entries_room_idx partial on non-null room_id. Three GiST exclusions (migration 0030) — see §6.1.

exam_assessment_components

Theory, practical, internal, viva, project, other — every mark ceiling in the system. Mandatory: a subject examination with no component cannot be published.

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
routine_entry_idintegerNo—FK exam_routine_entries.id, CASCADE
kindexam_component_kind enumNo—enum theory/practical/internal/viva/project/other
labeltextNo—CHECK format, ≤64 chars; unique on (routine_entry_id, lower(label))
max_marksnumeric(6,2)No—CHECK > 0 AND <= 1000; part of exam_assessment_components_id_max_unique (id, max_marks)The <= 1000 fat-finger guard matches class_subjects_marks_bounded, the value a default component is copied FROM.
pass_marksnumeric(6,2)No—CHECK >= 0 AND <= max_marks
sort_ordersmallintNo0
versionbigintNo0bumped by triggerPresent because the components PUT is a full replacement and lowering a ceiling cascades into exam_marks.
created_at/updated_attimestamptzNonow()

Composite unique exam_assessment_components_id_entry_unique (id, routine_entry_id) is the FK target that carries a mark's routine entry membership. exam_assessment_components_id_max_unique (id, max_marks) is what carries the ceiling onto every exam_marks row — the mechanism behind "a mark can never exceed its ceiling"; see §6.2. Index: exam_assessment_components_entry_idx (routine_entry_id, sort_order).

exam_invigilators

Who invigilates which paper. The four schedule columns are copies of the parent's, cascade-maintained, which is the mechanism behind "moving a paper re-arms the invigilator exclusion" (§6.1).

ColumnTypeNullableNotes
idserialNoPK.
routine_entry_idintegerNoHalf of the composite FK to exam_routine_entries.
staff_iduuidNoFK staff.id, CASCADE — NOT NULL forces CASCADE here; staff soft-deletes so this rarely fires.
exam_date/start_minute/end_minute/exam_statusmixedNoComposite FK (routine_entry_id, exam_date, start_minute, end_minute, exam_status) → exam_routine_entries(id, exam_date, start_minute, end_minute, exam_status), CASCADE/CASCADE.
is_chiefbooleanNo, default false
created_attimestamptzNonow().

Unique exam_invigilators_entry_staff_unique (routine_entry_id, staff_id) — its leading column also serves every per-entry lookup. Index: exam_invigilators_staff_date_idx (staff_id, exam_date). One GiST exclusion — see §6.1.

exam_marks_submissions

The marks workflow record for one subject examination — one row per routine entry.

ColumnTypeNullableNotes
idserialNoPK; public_id unique.
routine_entry_idintegerNoFK exam_routine_entries.id, CASCADE; unique — one submission per entry.
statemarks_submission_state enumNo, default draftenum draft/submitted/returned/approved/finalized. approved has TWO exits (finalize, return) — a single-exit approved is a stuck state, caught in review.
submitted_by_staff_iduuidYesFK staff.id, SET NULL. A SNAPSHOT of who submitted — class_subjects.teacher_id is a current-assignment pointer with no history, so reassigning a subject must not hand the new teacher every past submission's attribution.
submitted_attimestamptzYesCHECK (state='draft') = (submitted_at IS NULL).
reviewed_by_user_id/reviewed_atmixedYesFK users.id, SET NULL.
return_reasontextYesCHECK format, ≤500 chars — administrator-internal.
finalized_by_user_id/finalized_atmixedYesCHECK (state='finalized') = (finalized_at IS NOT NULL), and CHECK exam_marks_submissions_finalizer_requires_finalized_at: a finaliser implies a timestamp. The finaliser alone may be NULL, because its key is SET NULL when that user is erased.
versionbigintNobumped by trigger.
created_at/updated_attimestamptzNonow().

Index: exam_marks_submissions_state_idx.

exam_marks

One pupil's mark for one component. The database guarantee described in §6.2.

ColumnTypeNullableNotes
idserialNoPK; public_id unique.
component_idintegerNoTwo composite FKs to exam_assessment_components, BOTH RESTRICT: (component_id, routine_entry_id) → (id, routine_entry_id) and (component_id, max_marks) → (id, max_marks), both ON UPDATE cascade.
routine_entry_idintegerNoDenormalised so the scope predicate and the marks-sheet read never join through the component.
max_marksnumeric(6,2)NoA COPY of the component's ceiling, cascade-maintained. The marks sheet writes the ceiling it loaded; if an administrator changed the ceiling in between, the composite key refuses the stale copy with 23503.
student_iduuidNoFK students.id, CASCADE.
attendanceexam_mark_attendance enumNo, default presentenum present/absent/withheld/not_applicable.
obtained_marksnumeric(6,2)YesCHECK attendance='present' OR obtained_marks IS NULL (disjunction, not biconditional — see §6.2); CHECK obtained_marks IS NULL OR (>= 0 AND <= max_marks).
remarkstextYesCHECK format, ≤500 chars — audience-visible.
versionbigintNobumped by trigger.
created_at/updated_attimestamptzNonow().

Unique exam_marks_component_student_unique (component_id, student_id). Indexes: exam_marks_entry_student_idx (routine_entry_id, student_id), exam_marks_student_idx. Both FKs to exam_assessment_components are declared RESTRICT deliberately and must agree: Postgres evaluates both on a parent delete, so a RESTRICT on either aborts before the other's CASCADE could run — "restrict on one, cascade on the other" is not a mixture, it is restrict. An earlier draft had CASCADE here, which would have let a full-replacement components PUT silently delete every mark for a class.

exam_mark_corrections

The record of a change to a finalized mark. Append-only, enforced by a trigger (§9).

ColumnTypeNullableNotes
idserialNoPK.
mark_idintegerNoFK exam_marks.id, CASCADE.
previous_attendance/new_attendanceexam_mark_attendance enumNoBoth enumerated.
previous_obtained_marks/new_obtained_marksnumeric(6,2)Yes
reasontextNoCHECK format, ≤500 chars — administrator-internal, never a notification variable.
corrected_by_user_iduuidYesFK users.id, SET NULL.
corrected_by_user_public_iduuidNoNOT NULL snapshot beside the SET NULL FK — attribution survives an erasure of the acting user.
corrected_attimestamptzNo, default now()

Index: exam_mark_corrections_mark_idx (mark_id, corrected_at).

exam_results

One computed overall result per pupil per examination.

ColumnTypeNullableNotes
idserialNoPK; public_id unique.
exam_idintegerNoFK exams.id, CASCADE.
student_iduuidNoFK students.id, CASCADE.
class_idintegerNoFK classes.id, RESTRICT. Deliberately NO composite anchor to the current enrolment — records which class the pupil SAT the exam in, must survive the pupil later moving class; written once at compute time, never updated.
total_obtainednumeric(8,2)YesNULL means "not computable" (absent from every counted subject); zero means "scored nothing" — kept distinct.
total_maxnumeric(8,2)No
percentagenumeric(5,2)YesCHECK 0..100. CHECK exam_results_percentage_matches_totals: NULL exactly when total_obtained is NULL, and otherwise within 0.01 of round(total_obtained * 100 / total_max, 2) with total_max > 0. The tolerance absorbs the compute path's floating-point half-up rounding; the divisor is NULLIF-guarded because Postgres does not promise to evaluate total_max > 0 first.
gpanumeric(4,2)YesCHECK 0..5.
overall_grade_labeltextYes
passed_subjects/failed_subjectssmallintNo, default 0CHECK >= 0.
statusexam_result_status enumNoenum passed/failed/passed_with_conditions/incomplete/absent/withheld.
remarkstextYesCHECK format, ≤500 chars — audience-visible.
is_stalebooleanNo, default falseSet by a correction, cleared by recompute; publication is refused while any result in the exam is stale.
computed_attimestamptzNo, default now()
versionbigintNobumped by trigger.
created_at/updated_attimestamptzNonow().

Unique exam_results_exam_student_unique (exam_id, student_id) — the onConflictDoUpdate target for recompute. Indexes: exam_results_exam_status_idx (exam_id, status), exam_results_student_idx, exam_results_stale_idx (exam_id) WHERE is_stale.

exam_subject_results

One subject's contribution to a result — a snapshot, not a pointer.

ColumnTypeNullableNotes
idserialNoPK.
result_idintegerNoFK exam_results.id, CASCADE.
routine_entry_idintegerNoFK exam_routine_entries.id, RESTRICT — a routine entry with a computed result cannot be deleted.
obtained/maxnumeric(6,2)obtained Yes, max No
percentagenumeric(5,2)YesCHECK 0..100.
grade_label/grade_pointmixedYesSNAPSHOTS, not a band_id — a published result must be immune to a band being edited afterward.
is_passingbooleanYesNULL for a subject that is neither pass nor fail (withheld, not_applicable); only an explicit false increments failed_subjects.
attendanceexam_mark_attendance enumNo
counts_toward_totalbooleanNofalse for not_applicable and withheld; true otherwise, including absent (an absence scores 0/max and counts).
remarkstextYes
component_breakdownjsonbNoCHECK jsonb_typeof(...) = 'array' — an array of {componentId, label, kind, max, obtained, attendance} snapshots.
created_attimestamptzNonow().

Unique exam_subject_results_result_entry_unique (result_id, routine_entry_id) — its leading column also serves every per-result read. Index: exam_subject_results_entry_idx.

exam_notification_rules

Which channels and audiences each of the seven examination notification events uses.

ColumnTypeNullableDefaultNotes
eventtextNo—PK. CHECK ∈ ('published','schedule_changed','cancelled','invigilation_assigned','exam_tomorrow','exam_today','result_published'). exam.unpublished reuses the schedule_changed row rather than adding an eighth. exam.marks_submitted is deliberately absent — an operator-facing realtime signal, in-app only, with no channel configuration to offer.
enabledbooleanNotrue
channelsjsonbNo—CHECK is a JSON array, <= 4 entries; membership validated against NOTIFICATION_CHANNEL in the service, not the DB.
notify_students/notify_guardiansbooleanNotrue/false respectively (per-event seed value)
notify_teachersbooleanNofalse
notify_invigilatorsbooleanNofalseCHECK NOT notify_invigilators OR event IN ('schedule_changed','cancelled','invigilation_assigned') — an invigilator has no interest in a result or in an exam being published at all.
updated_by_user_iduuidYes—No FK — deliberate, matching assignment_notification_rules: the test harness truncates users with CASCADE and an FK here would wipe the seeded rows.
versionbigintNo0bumped by trigger.
created_at/updated_attimestamptzNonow()—

exam_reminder_rules

When reminders are sent, as offsets before a paper starts.

ColumnTypeNullableDefaultNotes
idserialNo—PK.
offset_minutesintegerNo—CHECK 15..43200; unique. The lower bound is the sweep tick — an offset below it can fall entirely between two ticks and never dispatch, with no error and no row to show for it.
labeltextYes—CHECK format, ≤64 chars.
enabledbooleanNotrue
channelsjsonbNo—CHECK array, <= 4.
versionbigintNo0bumped by trigger.
created_at/updated_attimestamptzNonow()—

Three rows seeded: 4320 min (3 days), 1440 min (1 day), 180 min (3 hours). Minimum spacing between two ENABLED offsets is one tick (15 min) — a service rule (EXAM_REMINDER_RULES_TOO_CLOSE), because a CHECK cannot span rows.

exam_reminder_dispatches

What the reminder sweep has already sent — the dedupe key IS the schedule.

ColumnTypeNullableNotes
idserialNoPK.
routine_entry_idintegerNoFK exam_routine_entries.id, CASCADE.
rule_idintegerNoFK exam_reminder_rules.id, CASCADE.
exam_date/start_minutemixedNoPart of the unique key.
dispatched_attimestamptzNo, default now()

Unique exam_reminder_dispatches_key (routine_entry_id, rule_id, exam_date, start_minute) — inserting this row IS how the sweep claims "already sent", and its leading column serves the per-entry lookups. No other index.

exam_attachments

Instructions, seating plans, circulars.

ColumnTypeNullableDefaultNotes
idserialNo—PK; public_id unique.
exam_idintegerNo—FK exams.id, CASCADE.
storage_keyvarchar(500)No—Never signed; served through a portal route that checks visibility + scope.
original_namevarchar(255)No—
mime_typevarchar(120)No—
size_bytesintegerNo—CHECK > 0.
visibilityexam_attachment_visibility enumNoadministrator_onlyenum administrator_only/audience.
uploaded_by_user_iduuidYes—FK users.id, SET NULL.
sort_ordersmallintNo0
created_attimestamptzNonow()

Index: exam_attachments_exam_idx (exam_id, sort_order). EXAM_ATTACHMENT is a private UploadType and must never be added to packages/storage/src/upload-type.ts's PUBLIC_TYPES — a public type is stored under public/, which the bucket policy grants anonymous s3:GetObject; verified current (PUBLIC_TYPES holds only AVATAR, LANDING, THUMBNAIL, PRODUCT_VIDEO).

5.3 Relationship Diagram

6. Services and Responsibilities

The module has 34 service/util files. This section groups them by area; §16.1 gives the full submodule coverage matrix.

6.1 ExamSharedResolversService (shared/exam-shared-resolvers.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
resolveExam(publicId)Every routine/marks/results writeexamsnonenoneEXAM_NOT_FOUND
resolveRoutineEntry(publicId)Every routine/marks write, both portalsexam_routine_entries joined examsnonenoneEXAM_ROUTINE_ENTRY_NOT_FOUND
resolveComponent(routineEntryId, componentId)Marks write pathsexam_assessment_componentsnonenoneEXAM_COMPONENT_NOT_FOUND

6.2 ExamLifecycleService (authoring/core/exam-lifecycle.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
publish(user, publicId, version)ExamLifecycleController.publishexam_classes count, entry-without-component count, grading floor bandexams (conditional UPDATE, one statement)outbox.enqueue(exam.published → PUBLISH_NOTIFY)EXAM_HAS_NO_CLASSES, EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS, GRADING_SCALE_HAS_NO_FLOOR_BAND, EXAM_ALREADY_PUBLISHED, EXAM_STATE_TRANSITION_INVALID, EXAM_VERSION_CONFLICT, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT
unpublish(user, publicId, version).unpublishfinalized-submission countexamsnoneEXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS, EXAM_NOT_PUBLISHED, EXAM_VERSION_CONFLICT
cancel(user, publicId, version, reason, publicNote).cancelexams.results_published_atexamsoutbox.enqueue(exam.cancelled → CANCEL_NOTIFY)EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED, EXAM_STATE_TRANSITION_INVALID
reinstate(user, publicId, version).reinstate—exams (cancelled → draft)noneEXAM_STATE_TRANSITION_INVALID
complete(user, publicId, version).completeevery routine entry's (examDate, endMinute), composed through SchoolClockService; the same "no paper ends after now" predicate (noPaperEndsAfter) is repeated inside the UPDATE, because moving a paper does not bump the exam's version and a check read before the write would miss a paper rescheduled in betweenexamsnoneEXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER, EXAM_STATE_TRANSITION_INVALID
archive(user, publicId, version).archive—exams (archived_from_status = OLD status via a raw sql fragment)noneEXAM_STATE_TRANSITION_INVALID
unarchive(user, publicId, version).unarchive—exams (status = OLD archived_from_status)noneEXAM_STATE_TRANSITION_INVALID

Every transition follows the same shape: one conditional UPDATE … WHERE public_id = ? AND status IN (fromStatuses) AND version = ?. A zero-row result triggers a disambiguating re-read that never decides whether the write happens — only which error message to show (already-published vs. wrong-state vs. version conflict). bump_row_version (trigger) owns version/updated_at; the service never sets either.

6.3 ExamWriteService (authoring/core/exam-write.service.ts)

MethodCalled ByReadsWritesErrors
create(dto)ExamsController.createacademic_sessions, exam_types, grading_scales (each resolved by publicId)exams (INSERT)VALIDATION_FAILED (dates), ACADEMIC_SESSION_NOT_FOUND, EXAM_SESSION_NOT_ACTIVE, EXAM_TYPE_NOT_FOUND, GRADING_SCALE_NOT_FOUND
update(publicId, dto).updateexams, routine entries outside a narrowed periodexamsEXAM_VERSION_CONFLICT, VALIDATION_FAILED, EXAM_TYPE_NOT_FOUND, GRADING_SCALE_NOT_FOUND, EXAM_PERIOD_HAS_ENTRIES_OUTSIDE
remove(publicId, version).removeexam_classes countexams (DELETE)EXAM_VERSION_CONFLICT, EXAM_STATE_TRANSITION_INVALID (only a draft exam with no class scope may be deleted)

Widening the exam's period is never checked; narrowing it is refused when a routine entry would fall outside the new bound.

6.4 ExamClassesService / ExamAttachmentsService / ExamCalendarProjectionService / ExamDetailService

MethodCalled ByReadsWritesErrors
ExamClassesService.replace(publicId, classPublicIds)ExamClassesController.replaceclasses scoped to the exam's session, current exam_classes, recorded marks under classes being removedexam_classes (delete + insert, one transaction)VALIDATION_FAILED (class outside session), EXAM_CLASS_HAS_RECORDED_MARKS
ExamAttachmentsService.upload(examPublicId, file, visibility, uploadedByUserId)ExamAttachmentsController.uploadexamsStorageManager.handleUpload (UploadType.EXAM_ATTACHMENT), exam_attachments (INSERT)UPLOAD_FILE_REQUIRED, UPLOAD_VALIDATION_FAILED, UPLOAD_STORAGE_FAILED; a DB-insert failure after a successful upload deletes the orphaned file
ExamAttachmentsService.remove(examPublicId, attachmentPublicId).removeexam_attachmentsDELETE row, then StorageManager.deleteFile (best-effort, logged on failure)EXAM_ATTACHMENT_NOT_FOUND
ExamCalendarProjectionService.adminProjection(sessionId)Another module's calendar controller (out of this module's ownership)Every exam in the session, any status, with class/routine countsnone—
ExamCalendarProjectionService.portalProjection(classId)Same, for /api/mobile/calendarOnly status='published' entries for the classnone—
ExamDetailService.list/.detail/.toDetail/.loadRowEvery controller needing a re-read after a writeexams joined refs and classesnoneEXAM_NOT_FOUND

6.5 Routine services (routine/core/*)

MethodCalled ByReadsWritesSide EffectsErrors
ExamRoutineWriteService.create(examPublicId, dto, actor)ExamRoutineController.createclasses, exam_classes, class_subjectsexam_routine_entries INSERT + default exam_assessment_components INSERT, inside runExamWriteWithConflictGuardnone (draft) / conflict candidates loaded when the exam is publishedEXAM_ROUTINE_DATE_OUTSIDE_PERIOD, CLASS_NOT_FOUND, EXAM_ROUTINE_SUBJECT_NOT_TAUGHT, EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT, PERMISSION_INSUFFICIENT (published write without ExamPublish_UPDATE)
ExamRoutineWriteService.patch(routineEntryPublicId, dto, actor)ExamRoutineEntryController.patchCurrent entry, current invigilators (if schedule touched)exam_routine_entries (conditional UPDATE keyed on id + version + examStatus)outbox.enqueue(exam.schedule_changed → SCHEDULE_CHANGE_NOTIFY) only when a schedule field changed on a published/completed entryEXAM_ROUTINE_ENTRY_STATE_CHANGED, conflict codes, PERMISSION_INSUFFICIENT
ExamRoutineWriteService.remove(routineEntryPublicId, actor).removeCurrent entryDELETE (conditional on examStatus)outbox.enqueue when publishedEXAM_ROUTINE_ENTRY_STATE_CHANGED, EXAM_ROUTINE_ENTRY_IN_USE/EXAM_COMPONENT_HAS_RECORDED_MARKS (23001/23503 mapped)
ExamRoutineWriteService.complete(routineEntryPublicId).completeCurrent entryis_completed = true (idempotent no-op if already true)none—
ExamRoutineSwapService.swap(...)ExamRoutineEntryController.swapBoth entries, both invigilator sets, both exams' published siblingsTwo UPDATEs inside one transaction, under SET CONSTRAINTS ... DEFERRED then IMMEDIATEoutbox.enqueue per side that is publishedEXAM_ROUTINE_ENTRY_STATE_CHANGED, conflict codes, PERMISSION_INSUFFICIENT
ExamComponentsWriteService.replace(routineEntryPublicId, dto, actor)ExamRoutineEntryController.replaceComponentsExisting componentsFull replacement: update-in-place by id, insert new, delete absentnoneEXAM_COMPONENT_NOT_FOUND, EXAM_VERSION_CONFLICT, EXAM_COMPONENT_HAS_RECORDED_MARKS (23001), EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK (23514 on the cascaded exam_marks row)
ExamInvigilatorsWriteService.replace(routineEntryPublicId, dto, actor).replaceInvigilatorsstaff (live rows only)Full replacement via delete-not-in-list + onConflictDoUpdate on isChiefoutbox.enqueue(exam.invigilation_assigned → INVIGILATION_NOTIFY) only when the assigned set actually changedEXAM_INVIGILATOR_NOT_FOUND, conflict codes, PERMISSION_INSUFFICIENT
ExamPublishPreviewReadService.publishPreview/.conflictsReportExamRoutineController.publishPreview/.conflictsEvery read-only signal: class scope, components, invigilators, grading floor band, calendar day classification, live roll count, conflictsnonenone— (read-only; warnings, not refusals, except the ones that also gate publish itself)
ExamDayReadService.today()ExamDayController.todayEvery published/completed entry on the school's own todaynonenone—
ExamRoutineReadService.listForExam/.loadEntryDto/.availabilityRoutine controllers, the swap/write services' own re-readsFull entry shape with components + invigilatorsnonenoneEXAM_ROUTINE_ENTRY_NOT_FOUND

Explain: input normalization happens at the DTO boundary (class-validator); validation order is always resolve-then-authorize-then-write; transaction boundaries wrap every write that touches more than one table; idempotency is explicit for complete() and for the invigilator/component full-replacement PUTs; retry behavior belongs to BullMQ for the async paths, not to these synchronous services; response mapping always re-reads the row after a write rather than echoing the request.

6.6 Marks services (marks/core/*)

MethodCalled ByReadsWritesSide EffectsErrors
resolveMarksGate(clock, executor, entry) (util)ExamMarksSheetService, ExamMarksWorkflowService.submitentry.examDate/endMinute/isCompletednonenone—
ExamMarksSheetService.getSheet(routineEntryPublicId)ExamMarksController.getSheet, TeacherExamMarksPortalService.getSheetLive roll (row-scope predicate), components, existing exam_marks, submission row, the gatenonenoneEXAM_ROUTINE_ENTRY_NOT_FOUND
ExamMarksSheetService.saveMarks(routineEntryPublicId, dto).saveMarks, portal saveMarksSame, plus the live roll to validate studentId membershipexam_marks bulk onConflictDoUpdate on (component_id, student_id)noneEXAM_MARKS_NOT_YET_OPEN, EXAM_MARKS_NOT_EDITABLE, STUDENT_NOT_FOUND, EXAM_COMPONENT_NOT_FOUND, EXAM_MARK_ABSENT_CANNOT_SCORE, EXAM_MARK_EXCEEDS_CEILING
ExamMarksWorkflowService.submit(routineEntryPublicId, version, submittedByStaffId?)ExamMarksWorkflowController.submit, TeacherExamMarksPortalService.submitThe gate, every present pupil's mark completenessexam_marks_submissions (draft/returned → submitted)outbox.enqueue(exam.marks_submitted → MARKS_SUBMITTED_NOTIFY)EXAM_MARKS_NOT_YET_OPEN, EXAM_MARKS_INCOMPLETE (names the missing pupils), EXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_SUBMISSION_VERSION_CONFLICT
ExamMarksWorkflowService.returnMarks(...).return—submitted/approved → returnednoneSame transition-error family
ExamMarksWorkflowService.approve(...).approve—submitted → approvednoneSame
ExamMarksWorkflowService.finalize(...).finalize—approved → finalizednoneSame
ExamMarkCorrectionsService.correct(markPublicId, dto, actorUserId, activityContext)ExamMarkCorrectionsController.correctThe mark, its routine entry, its submission stateexam_mark_corrections INSERT, exam_marks UPDATE, exam_results.is_stale = true for the whole examActivityRecordService.recordActivity (per-field changes)EXAM_MARK_NOT_FOUND, EXAM_ROUTINE_ENTRY_NOT_FOUND, EXAM_MARKS_TRANSITION_INVALID (only legal once finalized), EXAM_MARK_ABSENT_CANNOT_SCORE, EXAM_MARK_EXCEEDS_CEILING
ExamMarkCorrectionsService.listCorrections(markPublicId).listCorrectionsexam_mark_corrections ordered by timenonenoneEXAM_MARK_NOT_FOUND

6.7 Results services (results/core/*)

MethodCalled ByReadsWritesSide EffectsErrors
ExamResultsComputeService.compute(examPublicId)ExamResultsController.computeThe live roll (row-scope predicate)Enqueues ExamJob.RESULT_COMPUTE when the roll exceeds 500, else runs runLocked inlineoutbox.enqueue (queued path only, deduped per exam)EXAM_NOT_FOUND
ExamResultsComputeService.computeNow(examPublicId)ExamResultComputeAdapter.compute (the worker's own path)SameSame as runLocked, always inlinenoneEXAM_NOT_FOUND
ExamResultsComputeService.runLocked (private)Both of the aboveGrading scale + bands, every routine entry's component-max totals, every mark for the roll, under pg_advisory_xact_lock keyed per examexam_results upsert (onConflictDoUpdate on (exam_id, student_id), class_id deliberately excluded from the update set), exam_subject_results delete-then-insert per pupilnone—
ExamResultsService.list/.detailExamResultsController.list/.detailexam_results joined student/class namesnonenoneEXAM_RESULT_NOT_FOUND
ExamResultsService.withhold(examPublicId, studentPublicId, dto).withholdexam_results row FOR UPDATEstatus = 'withheld'noneEXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT
ExamResultsService.release(examPublicId, studentPublicId, dto).releaseexam_subject_results (unchanged by withhold), the grading scale's conditional-pass countRecomputes status/percentage/gpa/counts via computeOverall and writes them back — no stored "status before withhold" column existsnoneEXAM_RESULT_NOT_FOUND, EXAM_VERSION_CONFLICT
ExamResultsService.publish(examPublicId, actorUserId).publishUnfinalized-entry count, stale-result countexams.results_published_at/results_published_by_user_id (conditional on being currently null)outbox.enqueue(exam.result_published → RESULT_PUBLISH_NOTIFY)EXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALE, EXAM_RESULTS_ALREADY_PUBLISHED
ExamResultsService.unpublish(examPublicId).unpublish—Clears both publication columnsnoneEXAM_RESULTS_NOT_PUBLISHED

ExamResultComputeAdapter reads the exam's academic session lock state before dispatching a queued compute; when the year is locked it leaves the results as they are and logs a warning ([exam-result-compute] exam {examId} is in a locked academic year; results left as they are) instead of running computeNow. This is a soft skip rather than a thrown error, because the compute here runs from a worker, not from a request an administrator is watching.

6.8 GradingScalesService, ExamTypesService, ExamDashboardService, settings services

MethodCalled ByNotes
GradingScalesService.create/.update/.remove/.replaceBandsGradingScalesControllerisDefault toggling clears the previous default first inside the same transaction, mirroring academic_sessions_one_current_idx's own service, so the write never collides with grading_scales_one_default_idx. remove is refused (GRADING_SCALE_IN_USE) the moment exams.grading_scale_id still points at it.
ExamTypesService.create/.update/.removeExamTypesControllerHard DELETE, refused by exam_type_id's RESTRICT while any exam is filed against it. Retiring a type an admin no longer wants offered is PATCH { isActive: false }, not remove.
ExamDashboardService.totals(academicSessionPublicId?)ExamDashboardController.totalsupcoming/ongoing split published exams on today's date; pendingMarks counts entries with no finalized submission; pendingVerification narrows to submitted.
ExamNotificationRulesService.list/.updateExamNotificationSettingsControllerRows are seeded, never created or deleted here. notifyInvigilators is refused outside its three applicable events (EXAM_NOTIFICATION_RULE_INVALID).
ExamReminderRulesService.list/.replaceExamReminderRulesControllerA full-list replace with a two-phase "park on a free scratch offset, then land on the real target" algorithm — see §6.7 for why. Enforces no duplicate offsets and minimum 15-minute spacing between enabled rules.

7. Runtime Flows

7.1 Publish an exam

StepCode PathBehaviorFailure Case
1ExamLifecycleController.publishReceives request, DTO-validates version.Malformed body → 400.
2ExamLifecycleService.publishThree publish-gate checks: class scope, every entry has a component, grading floor band.EXAM_HAS_NO_CLASSES / EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS / GRADING_SCALE_HAS_NO_FLOOR_BAND.
3loadConflictCandidatesLoads every routine entry + invigilator BEFORE the write — after a savepoint rollback the transaction's own view of them is gone.—
4runExamWriteWithConflictGuardRuns the status flip in a SAVEPOINT; a 23P01 rolls back to the savepoint, not the outer transaction.EXAM_VERSION_CONFLICT / EXAM_ALREADY_PUBLISHED on a zero-row update.
5Three GiST exclusionsCascaded exam_status arms room/class/invigilator conflict-freedom for every entry at once.23P01, resolved to a named conflict — first against other published/completed exams, then against the candidate set itself (an exam's own collision can be entirely internal to what is being published).
6outbox.enqueueWrites the PUBLISH_NOTIFY outbox row in the SAME transaction as the status flip.A dispatcher failure never rolls back the publish — it retries independently.

7.2 Routine entry patch that touches a published exam's schedule

Include: success path (row 5-6 above); permission failure (missing ExamPublish_UPDATE on a schedule-touching write to a published entry); missing entity (EXAM_ROUTINE_ENTRY_NOT_FOUND); duplicate/concurrent write (EXAM_ROUTINE_ENTRY_STATE_CHANGED); a genuine schedule collision (EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICT, named against the exam's own siblings via loadPublishedSiblingCandidates when the collision is with a sibling of the same exam rather than a different one).

7.3 Swap two papers (deferred constraints)

The transient overlap between the two UPDATEs is legal only because the two exclusions are DEFERRABLE INITIALLY IMMEDIATE and this transaction explicitly defers them. SET CONSTRAINTS … IMMEDIATE forces the real check before the method returns, so a genuine collision with an unrelated third paper still raises 23P01 here, synchronously, rather than surfacing at an unrelated later commit.

7.4 Submit marks

Branch notes: guest/logged-in is not applicable (every exam route requires a session); the guest-equivalent branch here is "not this teacher's paper", which answers the identical 404 as "does not exist" — a 403 would confirm the paper exists. Cache miss/hit is not applicable — the module has no cache (§8). Async enqueue failure: outbox.enqueue writes inside the same transaction as the state flip, so a dispatcher outage never loses the submission, only delays its notification.

7.5 Compute results — synchronous vs. queued

runLocked takes pg_advisory_xact_lock keyed on the exam before re-reading the roll — a request and a queued job racing on one exam serialize rather than writing over each other's exam_results rows. See §9's design-decision note on why computeNow, never compute, is the worker's entry point.

7.6 Reminder sweep tick

Include: success path (above); a rescheduled paper re-arms by itself because its new (exam_date, start_minute) was never claimed; a cancelled/unpublished exam still claims the row (so it never re-fires) but sends nothing, checked by re-reading exams.status inside the same transaction as the claim; a per-candidate exception is caught, logged and does not stop the rest of the sweep.

8. Caching

The module declares no cache. No CacheKeyUtil import appears anywhere under apps/api/src/modules/exams/, and no Redis read/write occurs in any of the 34 service files read for this page. Every read — the marks sheet, the routine, the publish preview, the portal dashboards — goes to PostgreSQL directly, on every request. This is a deliberate absence, not a gap: the module's write paths are already gated by the version optimistic-concurrency column and, for schedule conflicts, by the database's own GiST exclusions, so a stale cache read would be the one path capable of showing a slot as free after it had just been taken.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.EXAMSExamJob.REMINDER_SWEEP (exam.reminder_sweep)ExamScheduler cron 0 */15 * * * * UTCExamsQueueProcessor → ExamReminderSweepService.runExamSweepPayload {v:1, requestId}BullMQ default retry; job id bucketed at the 15-minute period via buildJobIdThe claim is exam_reminder_dispatches insert ON CONFLICT DO NOTHING, keyed on (routine_entry_id, rule_id, exam_date, start_minute) — the schedule itself is the key.
QueueName.EXAMSExamJob.LIFECYCLE_SWEEP (exam.lifecycle_sweep)ExamScheduler cron 0 0 * * * * UTC (hourly)ExamsQueueProcessor → ExamLifecycleSweepService.runExamSweepPayloadSameAuto-complete is conditional on status='published' in its own WHERE, so a re-run or an overlapping tick is a no-op zero-row update; date-triggered notifications dedupe through NotificationService's own aggregateId.
QueueName.EXAMSExamJob.PUBLISH_NOTIFYExamLifecycleService.publish (via outbox)notifyFromPayload("published", ...)ExamNotifyPayload {v:1, requestId, examId}Outbox-relayed, default retrycollapse kind — one publish per exam id.
QueueName.EXAMSExamJob.SCHEDULE_CHANGE_NOTIFYExamRoutineWriteService.patch/.remove, ExamRoutineSwapService.swap (via outbox)Same processor, dispatches to "schedule_changed" or "unpublished" by an extra reason field the strict payload type omitsExamNotifyPayload & {reason?:"unpublished"}Samerepeatable — a second reschedule for the same exam is its own event, keyed by requestId.
QueueName.EXAMSExamJob.CANCEL_NOTIFYExamLifecycleService.cancelnotifyFromPayload("cancelled", ...)ExamNotifyPayloadSamecollapse.
QueueName.EXAMSExamJob.INVIGILATION_NOTIFYExamInvigilatorsWriteService.replace (only when the assigned set changed)notifyFromPayload("invigilation_assigned", ...)ExamNotifyPayload (with routineEntryId)Samecollapse, keyed on the sorted assigned staff id list.
QueueName.EXAMSExamJob.MARKS_SUBMITTED_NOTIFYExamMarksWorkflowService.submitnotifyFromPayload("marks_submitted", ...)ExamNotifyPayloadSamecollapse, versioned by exam-marks-submitted-{entryId}-v{version}.
QueueName.EXAMSExamJob.RESULT_PUBLISH_NOTIFYExamResultsService.publishnotifyFromPayload("result_published", ...)ExamNotifyPayloadSamecollapse, one per exam.
QueueName.EXAMSExamJob.RESULT_COMPUTEExamResultsComputeService.compute when the roll exceeds 500resultCompute.compute(examId, requestId) → ExamResultComputeAdapter → ExamResultsComputeService.computeNowExamResultComputePayload {v:1, requestId, examId}SameDeduped per exam (exam.result_compute:{examId}) — a repeated request while one is in flight is a no-op enqueue, never a second job.

Both sweeps exclude a locked academic year. ExamLifecycleSweepService and ExamReminderSweepService both filter their candidate query on isNull(academicSessions.lockedAt) — a locked year is historical, so its exams are neither auto-completed nor still sent reminders. exam_reminder_dispatches' own claim row is not enough on its own to stop a reminder for a locked year, because the claim only prevents a duplicate of a dispatch that already ran; the lockedAt filter is what stops a new one starting.

Every enqueue goes through the transactional outbox. Every outbox.enqueue call site above runs inside the same db.transaction as the business write it accompanies — a direct queue.add() in a request handler is a defect this repo has already paid for once (a queue sweep that silently never ran). The outbox row this module writes carries aggregateType, aggregateId, eventType, targetQueue: QueueName.EXAMS, jobName: ExamJob.<member>, an optional dedupeKey, and payload.

compute vs. computeNow. ExamsQueueProcessor's handler map is Record<ExamJob, handler>, so ExamJob.RESULT_COMPUTE needs a compile-time handler even though the computation logic belongs to the results area — ExamResultComputeRunner (an abstract-class DI token) is the seam, and ExamResultComputeAdapter binds it to ExamResultsComputeService. The adapter calls computeNow, never compute: compute is the request path and itself enqueues ExamJob.RESULT_COMPUTE when the roll exceeds EXAM_SYNC_COMPUTE_MAX_STUDENTS. A worker bound to compute would re-enqueue the very job it is running, and because the enqueue carries a per-exam dedupeKey, the repeat write would be swallowed as idempotent rather than looping visibly — the queue would drain, every gate would stay green, and no result would ever be computed for exactly the large exams the asynchronous path exists to serve. computeNow always runs inline because the worker is already off the request path and has no response-time budget to protect.

Concurrency 1. ExamsQueueProcessor declares @Processor(QueueName.EXAMS, { concurrency: 1 }) — every job here is either a whole-school sweep or a single exam's notification fan-out, both idempotent by construction (a claimed dispatch row, a collapse/repeatable dedupe key), so nothing needs to run concurrently with itself.

10. Realtime and Events

The module emits no Socket.IO/SSE realtime event. The only "realtime" signal is exam.marks_submitted, and it travels through the ordinary notification pipeline (kind: "exam.marks_submitted", channels: ["in_app"], audience {kind:"role", rolePublicIds} resolved from every role currently holding ExamMarksVerify_READ) rather than the module registry modules/realtime/shared/realtime.contract.ts uses — that registry currently carries exactly one live mapping (feedback.submitted) and this module does not add to it.

EventProducerRoom/TargetPayloadConsumerReliability Notes
exam.marks_submittedExamNotifierService.notifyMarksSubmittedIn-app notification to every role holding ExamMarksVerify_READ{className, subjectName, teacherName?}Admin activity/notification screencollapse, no SMS/push/email channel offered — an operator-facing queue signal, not a family-facing message.

11. Security, Auth, and Abuse Controls

Guards. JwtAuthGuard is global; every exam route requires a session. RoleGuard enforces every admin controller's @Permissions() code (see §13's permission table). The three portal modules carry no @Permissions() at all.

Permission catalogue (13 modules). ExamTypes, Exams, ExamPublish, ExamCancel, ExamRoutines, ExamMarks, ExamMarksVerify, ExamMarksCorrect, ExamResults, ExamResultPublish, ExamGrading, ExamNotificationSettings, ExamView (packages/db/src/authorization/permission-catalog.ts). ExamPublish and ExamResultPublish are the only two also listed in SUPERADMIN_ONLY_MODULES — both authorize a change that reaches an already-published/family-visible row, and a grant can never be revoked (grantToRoles uses onConflictDoNothing), so both start with admin restricted to superadmin and widen only by deliberate role creation. ExamView appears on zero API routes — it is the audience-screen code that makes the exams tile safe to hand to a pupil.

ExamMarks must never be granted to STAFF_PERMISSIONS/TEACHER_PERMISSIONS. It is the unscoped whole-school code; a teacher's own marks access is the scoped portal route (TeacherExamMarksPortalService), which asserts a live staff row and then filters class_subjects.teacher_id = :myStaffId. The permission catalogue's own comment names the one-line "fix" an executor might reach for — adding the code to the teacher role — as exactly the mistake this predicate exists to avoid, because the grant, once made, can never be taken back.

Portal routes carry no @Permissions() and assert their audience instead. TeacherExamsPortalController, TeacherExamMarksPortalController, StudentExamsPortalController and GuardianExamsPortalController — 20 handlers across the four — are each registered as a key in NO_PERMISSION_ADMIN_HANDLERS (apps/api/src/common/authorization/role.guard.ts) and mirrored in apps/api/test/structure/route-permissions.spec.ts; verified identical in both files this session. Missing the role.guard.ts entry makes the handler 403 for every caller including superadmin, with a fully green integration suite (the specs call services directly and never pass through the guard). Missing the spec entry fails test:structure instead. Both are required because each is the only place a different failure mode is caught.

The teacher predicate is a plain eq, never IS NOT DISTINCT FROM. subjectTeacherExists/invigilatorExists (teacher-exam-scope.util.ts) and the identical inline predicate in TeacherExamMarksPortalService.isAuthorized all compare class_subjects.teacher_id = :staffId with an ordinary equality — IS NOT DISTINCT FROM would match every unassigned (NULL) row, handing every teacher every unassigned subject's papers.

The submitted_by_staff_id OR-arm is deliberate. A teacher's access to a marks sheet is class_subjects.teacher_id = me OR exam_marks_submissions.submitted_by_staff_id = me. Reassigning a subject must not simultaneously strip the previous teacher of a submission they already made and hand the new teacher every past examination's marks — the snapshot column exists for exactly this, and the predicate is what makes the snapshot load-bearing rather than decorative.

404, never 403, on a scoped miss. Every portal read — resolveExamInScope, TeacherExamsPortalService.resolveExamInScope, TeacherExamMarksPortalService.assertAccess — answers the identical EXAM_NOT_FOUND/EXAM_ROUTINE_ENTRY_NOT_FOUND whether the exam does not exist, is not in the caller's scope, or (on the result route) has not had its results published yet. A 403 on any one of those would disclose that the exam exists and that the specific pupil sat it — an id space over a school's own children must not become an enumeration oracle.

Guardian order of operations. GuardianExamsPortalService.resolveChild calls guardianPortal.resolveOwnChildId before any exam predicate runs. A foreign childId is refused by the guardian's own ownership check first; only after it succeeds does ExamPortalReadsService ever see the request.

EXAM_ATTACHMENT is a private upload type, verified absent from PUBLIC_TYPES in packages/storage/src/upload-type.ts this session. A public type is served from the public/ prefix, which the bucket policy grants anonymous s3:GetObject — a question paper there would be readable by the whole internet before the exam is sat. The generic upload-proxy route is also closed to it (ownership-only, and an exam attachment's owner is the administrator who uploaded it), so the only path a pupil, teacher or guardian reaches an audience-visible attachment through is the portal download route, which checks the exam is published/scoped and visibility='audience' before opening the stream.

Download headers. setExamAttachmentDownloadHeaders sets X-Content-Type-Options: nosniff, Cache-Control: private, no-store, a sandboxed Content-Security-Policy: sandbox; default-src 'none', and always Content-Disposition: attachment — a question paper or seating plan is never rendered inline in the API's own origin.

Fail-closed defaults. EXAM_ATTACHMENT.visibility defaults to administrator_only; a pupil never sees an attachment unless an administrator explicitly flips it to audience. exam_notification_rules.notify_invigilators is CHECK-refused outside its three applicable events. exam_attachment.storage_key uploads are never client-named — the caller supplies a file, StorageManager decides the key.

A locked academic session makes an exam's whole record read-only, enforced at the database. Once academic_sessions.locked_at is set, Postgres triggers refuse INSERT/UPDATE/DELETE on exams, exam_classes, exam_routine_entries, exam_assessment_components, exam_invigilators, exam_marks_submissions, exam_marks, exam_results, exam_subject_results and exam_attachments — every one of them resolved back to the exam's academic_session_id (packages/db/src/migrations/0038_year_lock_records.sql). The refusal is a fixed trigger message, ACADEMIC_SESSION_LOCKED, caught by AllExceptionsFilter's GUARD_MESSAGES map and returned as 409 ACADEMIC_SESSION_LOCKED with the message "That academic year is locked, so its records can no longer change. An administrator can unlock the year to make a correction." exam_mark_corrections carries no trigger of its own — the edit to the exam_marks row a correction accompanies is what the lock actually refuses. A write nested inside a cascade (pg_trigger_depth() > 1, e.g. a pupil's erasure) always passes, so archiving or erasing a person does not require unlocking their exam history first.

ExamDetailService reads academicSessions.lockedAt alongside the exam row and exposes it on ExamDetailDto.academicSessionLocked, so the admin screen can render the exam read-only without a separate lock lookup.

Rate limits / anti-abuse. No exam-specific rate limiter exists; the module relies on the platform-wide guards (JwtAuthGuard, upload size/type validation in StorageManager) for abuse resistance. There is no bulk-export or unbounded-read route in this module — every list route is either paginated (PaginationUtil) or capped at PaginationUtil.UNPAGINATED_HARD_CAP with a truncated flag rather than an unbounded fetch.

13. Error Handling

55 EXAM_*/GRADING_SCALE_* codes are declared under the "EXAMINATIONS AND RESULTS" heading in apps/api/src/common/types/error-codes.ts (counted directly against the file this session — an earlier estimate of 62 does not match the current tree; the code is the source of truth).

Error CodeHTTP StatusThrown ByConditionClient Action
EXAM_NOT_FOUND404ExamDetailService.loadRow, ExamSharedResolversService.resolveExam, most read pathsNo exam with that publicId, or (portal) not in the caller's scope.Reload the list.
EXAM_TYPE_NOT_FOUND404ExamTypesService.findOrThrow, ExamWriteService.create/.updateUnknown exam type.Refresh the type list.
EXAM_TYPE_IN_USE409ExamTypesService.translateDELETE while an exam still references it.Deactivate instead of deleting.
EXAM_TYPE_CODE_TAKEN409ExamTypesService.translateexam_types_code_unique (active types only).Choose another code.
EXAM_TYPE_NAME_TAKEN409ExamTypesService.translateexam_types_name_unique (active types only).Choose another name.
EXAM_ALREADY_PUBLISHED409ExamLifecycleService.publishRe-publishing an already-published exam.Refresh; the exam is already live.
EXAM_NOT_PUBLISHED409ExamLifecycleService.unpublishUnpublishing a non-published exam.Refresh state.
EXAM_HAS_NO_CLASSES409ExamLifecycleService.publishPublish attempted with an empty class scope.Add at least one class.
EXAM_CLASS_HAS_RECORDED_MARKS409ExamClassesService.replace/.translateRemoving a class from scope whose entries already carry marks.Do not remove; archive instead.
EXAM_SESSION_NOT_ACTIVE409ExamWriteService.createCreating an exam in a retired academic session.Select the current session.
EXAM_CANNOT_COMPLETE_BEFORE_LAST_PAPER409ExamLifecycleService.complete, mirrored (not thrown, silently skipped) by ExamLifecycleSweepServiceAt least one routine entry's end instant is still in the future.Wait, or complete after every paper ends.
EXAM_CANNOT_UNPUBLISH_WITH_FINALIZED_MARKS409ExamLifecycleService.unpublishResults already published, or at least one submission is finalized.Cannot unpublish; use cancel/archive instead.
EXAM_CANNOT_CANCEL_AFTER_RESULTS_PUBLISHED409ExamLifecycleService.cancelResults already published for this exam.Cannot cancel a graded exam.
EXAM_STATE_TRANSITION_INVALID409Every ExamLifecycleService transition's onWrongState, ExamWriteService.removeThe exam is not in one of the transition's fromStatuses.Reload the exam's current status.
EXAM_PERIOD_HAS_ENTRIES_OUTSIDE409ExamWriteService.updateNarrowing startDate/endDate would exclude an existing routine entry.Reschedule or remove the entry first.
EXAM_VERSION_CONFLICT409Every versioned write across the moduleThe supplied version does not match the current row.Reload and retry.
EXAM_ROUTINE_DATE_OUTSIDE_PERIOD422ExamRoutineWriteService.createexamDate outside the exam's [startDate, endDate] envelope.Pick a date inside the exam's period.
EXAM_ROUTINE_ENTRY_NOT_FOUND404ExamSharedResolversService.resolveRoutineEntry and every callerNo entry with that publicId.Reload the routine.
EXAM_ROUTINE_ENTRY_IN_USE409translateExamWriteError (23001/23503 mapping)Deleting an entry a computed result still references.Cannot delete; results exist.
EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS409ExamLifecycleService.publish, flagged (not thrown) by ExamPublishPreviewReadServiceA publish-time hard precondition: every entry needs >= 1 component.Add an assessment component.
EXAM_ROUTINE_ENTRY_STATE_CHANGED409ExamRoutineWriteService.patch/.remove, ExamRoutineSwapService.swapZero-row conditional update — concurrent edit or a status change landed between read and write.Reload and retry.
EXAM_ROUTINE_SUBJECT_NOT_TAUGHT422ExamRoutineWriteService.create, translateExamWriteErrorThe chosen class_subject_id does not belong to the chosen class.Choose a subject this class teaches.
EXAM_ROOM_CONFLICT409exam_conflict.util.ts (throwExamConflict), translateExamWriteError backstopTwo published/completed papers would share a room and overlapping time.Choose another room or time.
EXAM_CLASS_CONFLICT409SameTwo published/completed papers would clash for the same class.Reschedule one of the papers.
EXAM_INVIGILATOR_CONFLICT409SameAn invigilator would be double-booked.Reassign the invigilator.
EXAM_INVIGILATOR_NOT_FOUND404ExamInvigilatorsWriteService.replaceA requested staffId has no live staff row.Choose a current staff member.
EXAM_COMPONENT_NOT_FOUND404ExamSharedResolversService.resolveComponent, ExamComponentsWriteService.replace, ExamMarksSheetService.saveMarksUnknown component id (scoped to the entry).Reload the components.
EXAM_COMPONENT_HAS_RECORDED_MARKS409translateExamWriteError (23001)Deleting/replacing away a component that already has recorded marks.Cannot remove; marks exist.
EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARK422translateExamWriteError (23514 on the cascaded exam_marks row)Lowering a component's max_marks below a mark already recorded against it.Raise the ceiling or correct the mark first.
EXAM_MARKS_NOT_EDITABLE409ExamMarksSheetService.saveMarksThe submission has already left draft/returned.Marks are locked; ask for a return if needed.
EXAM_MARKS_NOT_YET_OPEN409resolveMarksGate callersThe paper's end instant has not passed and it is not manually completed.Wait for the paper to end, or mark it complete.
EXAM_MARKS_INCOMPLETE409ExamMarksWorkflowService.submitA present pupil has no mark on at least one component.Fill in every present pupil's mark (names supplied).
EXAM_MARKS_TRANSITION_INVALID409ExamMarksWorkflowService.runTransition, ExamMarkCorrectionsService.correctWrong current state for the requested transition/correction.Reload the submission state.
EXAM_MARKS_SUBMISSION_NOT_FOUND409ExamMarksWorkflowService.runTransitionNo submission row exists yet for an action other than submit.Save marks first.
EXAM_MARKS_SUBMISSION_VERSION_CONFLICT409ExamMarksWorkflowService.runTransitionVersion mismatch on the submission row.Reload and retry.
EXAM_MARK_NOT_FOUND404ExamMarkCorrectionsServiceUnknown mark publicId.Reload the marks sheet.
EXAM_MARK_EXCEEDS_CEILING422ExamMarksSheetService.saveMarks, ExamMarkCorrectionsService.correct (service check and 23514 backstop)obtainedMarks > component.maxMarks.Enter a mark within the ceiling.
EXAM_MARK_ABSENT_CANNOT_SCORE422Same call sitesA non-present attendance carries a non-null mark.Clear the mark or set attendance to present.
EXAM_RESULTS_NOT_ALL_FINALIZED409ExamResultsService.publishAt least one routine entry has no finalized submission.Finalize every paper's marks first.
EXAM_RESULTS_STALE409ExamResultsService.publishAt least one result is is_stale (a correction happened since the last compute).Recompute results first.
EXAM_RESULTS_ALREADY_PUBLISHED409ExamResultsService.publishResults already published for this exam.Refresh; already live.
EXAM_RESULTS_NOT_PUBLISHED409ExamResultsService.unpublishUnpublishing results that are not published.Refresh state.
EXAM_RESULT_NOT_FOUND404ExamResultsService, ExamPortalReadsServiceNo result row for that pupil on this exam.Compute results, or (portal) results not yet published.
GRADING_SCALE_NOT_FOUND404GradingScalesService, ExamWriteServiceUnknown grading scale publicId.Reload the scale list.
GRADING_SCALE_IN_USE409GradingScalesService.removeDeleting a scale an exam still references.Deactivate instead.
GRADING_SCALE_HAS_NO_FLOOR_BAND409ExamLifecycleService.publish, flagged by ExamPublishPreviewReadServiceA scale is attached with no band at min_percent = 0.Add a floor band.
GRADING_SCALE_BAND_INVALID409GradingScalesService.translateBandWriteErrorA band violates its label/floor/GPA-completeness constraints.Fix the offending band.
GRADING_SCALE_NAME_TAKEN409GradingScalesService.translateWriteErrorgrading_scales_name_unique (active scales only).Choose another name.
EXAM_REMINDER_RULES_TOO_CLOSE409ExamReminderRulesService.assertMinimumSpacingTwo enabled offsets closer than the 15-minute sweep tick.Space the offsets further apart.
EXAM_REMINDER_RULE_INVALID400ExamReminderRulesServiceDuplicate offset in one request, or an id with no matching row.Fix the request payload.
EXAM_NOTIFICATION_RULE_INVALID400ExamNotificationRulesService.updateUnknown channel, or notifyInvigilators on a non-applicable event.Fix the request payload.
EXAM_NOTIFICATION_RULE_NOT_FOUND404ExamNotificationRulesService.updateevent not one of the seven known events.Use a valid event name.
EXAM_ATTACHMENT_NOT_FOUND404ExamAttachmentsService.remove, portal readsNo attachment with that id on this exam.Reload the attachment list.
EXAM_ATTACHMENT_NOT_AUDIENCE_VISIBLE404Portal/teacher attachment downloadsThe attachment is administrator_only.Not available to this caller.
EXAM_BS_DATE_OUT_OF_RANGE400Wherever a Bikram Sambat conversion is attempted outside the supported rangeBikramSambatRangeError mapped deliberately so a caller can distinguish it.Choose a date within the supported BS range.
ACADEMIC_SESSION_LOCKED409A database trigger (packages/db/src/migrations/0038_year_lock_records.sql), caught by AllExceptionsFilterAny write to an exam, its routine, components, invigilators, marks, submissions, results, subject results or attachments while the exam's academic year is locked.An administrator can unlock the year to make a correction.

14. Observability

SignalLocationPurpose
LogExamsQueueProcessor (Logger)[start]/[success] per job with requestId and duration; [failure]/[retry] on OnWorkerEvent("failed"), distinguishing a terminal failure (recorded by JobFailureRecorderService) from a retry.
LogExamSchedulerLogs and swallows an enqueue failure rather than throwing — a throwing cron handler would stop every later tick.
LogExamReminderSweepServicePer-candidate failure is caught, logged with routineEntryId/ruleId/requestId, and does not stop the rest of the sweep.
LogExamLifecycleSweepServiceLogs each auto-completed exam id and the per-run tomorrow/today notified-class counts.
LogExamResultComputeAdapterLogs the written result count for every worker-path compute, including a legitimate zero (an empty roll) — indistinguishable from a broken scope predicate without the log.
LogExamAttachmentsServiceLogs (warn) a failed best-effort storage cleanup after a DB insert failure, and a failed best-effort delete on attachment removal.
AuditMongo audit_logs, via the global ActivityAuditInterceptorEvery mutating admin route is recorded automatically from its @Permissions() code (module = Exams/ExamRoutines/ExamMarks/… , action = CREATE/READ/UPDATE/DELETE).
Audit (detail)ExamMarkCorrectionsService.correct → ActivityRecordService.recordActivityThe one call site in this module that supplies per-field changes the interceptor cannot see — attendance and mark before/after, plus the correction reason.
MetricNone declaredNo named metric exists for this module; operational health is read from the log lines above and from SELECT queries against exam_reminder_dispatches/outbox_events (see §16.7).

15. Testing and Validation

Test TypeFilesCoverage
Unitresults/core/exam-result-rules.spec.tsThe five-row NULL-propagation table for computeSubjectFromMarks, plus computeOverall, lookupBand, round2 — pure functions, no database.
Integration__tests__/exam-authoring.integration.spec.tsCreate/update/delete, class scope, attachments, every lifecycle transition, completion once every paper has ended, and the completion guard refusing inside the write when the version still matches.
Constraint probescripts/probe-exam-constraints.shAccept and reject cases for every exam CHECK, key, exclusion and trigger — including the result-percentage and finaliser CHECKs — in one rolled-back transaction against a migrated database.
Integration__tests__/exam-routine.integration.spec.tsEntry CRUD, components, invigilators, swap.
Integration__tests__/exam-conflicts.integration.spec.tsThe three GiST exclusions, both conflict-resolution passes, the deferred-constraint swap.
Integration__tests__/exam-marks.integration.spec.ts, exam-marks-workflow.integration.spec.tsThe sheet read/write, the five-state workflow, the marks gate.
Integration__tests__/exam-grading.integration.spec.tsScale/band CRUD, the default-scale swap, band completeness.
Integration__tests__/exam-results.integration.spec.tsSynchronous and queued compute, withhold/release, publish/unpublish gates.
Integration__tests__/exam-notifications.integration.spec.tsExamNotifierService fan-out per event, rule gating, invigilator applicability.
Integration__tests__/exam-sweeps.integration.spec.tsThe reminder sweep's window arithmetic and dispatch claim, the lifecycle sweep's auto-complete and tomorrow/today notify.
Integration__tests__/exam-portal.integration.spec.ts, teacher-today.integration.spec.tsThe three portal surfaces' scoping predicates, the 404-not-403 discipline, the teacher's own-day view.
Integration__tests__/exam-types.integration.spec.tsType CRUD, active-partial uniqueness, RESTRICT on delete-while-in-use.
Structureapps/api/test/structure/route-permissions.spec.tsAsserts the exam module's 20 no-permission portal handlers match NO_PERMISSION_ADMIN_HANDLERS exactly.
Structureapps/api/test/structure/swagger-coverage.spec.tsEvery exam route appears in exactly one of /api/api-docs / /api/mobile-docs; no shared tag across two exam controllers.

Validation commands:

pnpm --filter @skoolsewa/api test          # unit + integration, real Postgres/Redis/Mongo
pnpm --filter @skoolsewa/api test:structure       # route/guard/tag fingerprint
pnpm rules                                  # error-codes, module-size, module-shape, job-ids, env-orphans
find apps packages -name '*.tsbuildinfo' -not -path '*/node_modules/*' -delete && pnpm check-types

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
ExamTypesController / ExamTypesServiceController/serviceexam_types CRUD—Adminexam_typesexam_typesEXAM_TYPE_*
ExamsController / ExamWriteService / ExamDetailServiceController/serviceexams CRUD, list/detail read shapeacademic_sessions, exam_types, grading_scalesAdminexamsexamsEXAM_NOT_FOUND, EXAM_VERSION_CONFLICT, EXAM_SESSION_NOT_ACTIVE, EXAM_PERIOD_HAS_ENTRIES_OUTSIDE
ExamLifecycleController / ExamLifecycleServiceController/serviceThe seven lifecycle transitionsExamDetailService, SchoolClockService, OutboxService, exam-conflict.utilAdminexams, outbox_eventsexams.status and every cascaded childEvery EXAM_* transition code, three conflict codes
ExamClassesController / ExamClassesServiceController/serviceClass-scope full replacementExamDetailServiceAdminexam_classes, exam_marks (recorded-marks check)exam_classesEXAM_CLASS_HAS_RECORDED_MARKS, VALIDATION_FAILED
ExamAttachmentsController / ExamAttachmentsServiceController/serviceAttachment upload/list/removeStorageManager, ExamDetailServiceAdminexam_attachments, storageexam_attachments, object storageEXAM_ATTACHMENT_NOT_FOUND, UPLOAD_*
ExamCalendarProjectionServiceServiceTwo read-only calendar projections—Calendar module (out of ownership), mobile calendar routeexams, exam_classes, exam_routine_entriesnone—
ExamDashboardController / ExamDashboardServiceController/serviceSession totals tileSchoolClockServiceAdminexams, exam_routine_entries, exam_marks_submissionsnoneACADEMIC_SESSION_NOT_FOUND
ExamRoutineController / ExamRoutineEntryController / ExamDayControllerControllersRoutine surface HTTPExamRoutineCoreModule servicesAdmin———
ExamRoutineWriteServiceServiceEntry create/patch/remove/completeExamSharedResolversService, RoleService, OutboxService, exam-conflict.utilControllersexam_routine_entries, exam_assessment_components (default), outbox_eventsroutine entriesEXAM_ROUTINE_*, conflict codes, PERMISSION_INSUFFICIENT
ExamRoutineSwapServiceServiceDeferred-constraint two-entry swapSameExamRoutineEntryController.swapexam_routine_entriestwo routine entriesSame + conflict codes
ExamComponentsWriteServiceServiceComponents full replacementExamSharedResolversService, RoleServiceExamRoutineEntryController.replaceComponentsexam_assessment_componentscomponentsEXAM_COMPONENT_*, EXAM_VERSION_CONFLICT
ExamInvigilatorsWriteServiceServiceInvigilators full replacementSame, OutboxService.replaceInvigilatorsexam_invigilators, outbox_eventsinvigilatorsEXAM_INVIGILATOR_NOT_FOUND, conflict codes
ExamPublishPreviewReadServiceService (read-only)Publish-preview + conflicts reportCalendarDayService, exam-conflict.util.publishPreview/.conflictsreads onlynone—
ExamDayReadServiceService (read-only)Admin's exam-day overviewSchoolClockServiceExamDayController.todayreads onlynone—
ExamRoutineReadServiceService (read-only)Full entry DTO assembly, availabilityExamSharedResolversServiceEvery routine write's own re-read, .availabilityreads onlynoneEXAM_ROUTINE_ENTRY_NOT_FOUND
ExamMarksController / ExamMarksSheetServiceController/serviceSheet get/putExamSharedResolversService, SchoolClockService, exam-marks-gate.utilAdmin, TeacherExamMarksPortalServiceexam_marks, exam_marks_submissions (read)exam_marksEXAM_MARKS_NOT_EDITABLE, EXAM_MARKS_NOT_YET_OPEN, mark-format codes
ExamMarksWorkflowController / ExamMarksWorkflowServiceController/serviceFive-state workflowSame + OutboxServiceAdmin, portal submitexam_marks_submissions, outbox_eventssubmission stateEXAM_MARKS_TRANSITION_INVALID, EXAM_MARKS_INCOMPLETE, version conflicts
ExamMarkCorrectionsController / ExamMarkCorrectionsServiceController/servicePost-finalization correctionsActivityRecordServiceAdminexam_mark_corrections, exam_marks, exam_results.is_stalemarks + results stalenessEXAM_MARKS_TRANSITION_INVALID, mark-format codes
GradingScalesController / GradingScalesServiceController/serviceScale + band CRUD—Admingrading_scales, grading_scale_bandsscales/bandsGRADING_SCALE_*
ExamResultsController / ExamResultsServiceController/serviceList/detail/withhold/release/publish/unpublish—Adminexam_results, exam_subject_results, examsresults, exam publication stampsEXAM_RESULTS_*, EXAM_RESULT_NOT_FOUND
ExamResultsComputeServiceServiceSync/queued computationOutboxService, advisory lock.compute route, ExamResultComputeAdapterexam_results, exam_subject_resultsSameEXAM_NOT_FOUND
ExamResultComputeRunner / ExamResultComputeAdapterAbstract token / serviceThe worker-side seam onto computeExamResultsComputeServiceExamsQueueProcessorcomputeNowSame—
ExamNotificationSettingsController / ExamNotificationRulesServiceController/serviceNotification rule read/update—Adminexam_notification_rulesrule rowsEXAM_NOTIFICATION_RULE_*
ExamReminderRulesController / ExamReminderRulesServiceController/serviceReminder offset full-list replace—Adminexam_reminder_rulesrule rowsEXAM_REMINDER_RULE*
ExamSharedResolversServiceServicepublicId resolvers—Nearly every other unit in the moduleexams, exam_routine_entries, exam_assessment_components (read)noneEXAM_NOT_FOUND family
exam-conflict.util.tsPlain functionsTwo-pass conflict detection—Every schedule-touching write, publish-previewreads onlynone—
exam-write.errors.tsPlain functionConstraint → error-code translationpg-constraint.utilRoutine/components/invigilators writes—none—
ExamTypesAdminModule etc. (18 modules)NestJS modulesDI wiring—app.module.ts / mobile.module.ts———
ExamSchedulerSchedulerTwo cronsbuildJobId, BullMQ QueueScheduleModuleEnqueue onlyoutbox_events-free (cron exemption)Logged-and-swallowed enqueue failure
ExamsQueueProcessorProcessorThe one QueueName.EXAMS handler mapEvery worker serviceBullMQDispatches to workers—Unhandled job name throws (compile-time exhaustive, so unreachable at runtime for a live enum member)
ExamReminderSweepServiceWorker service15-min reminder tickSchoolClockService, NotificationService, ExamNotificationScopeServiceExamsQueueProcessorexam_reminder_dispatches, notification_eventReminder claimsPer-candidate exception caught and logged
ExamLifecycleSweepServiceWorker serviceHourly auto-complete + tomorrow/todaySameSameexams.status, notification_eventExam completion—
ExamNotifierServiceWorker serviceEvery occurrence → notificationExamNotificationScopeService, NotificationServiceExamsQueueProcessor, ExamReminderSweepService (indirectly via scope service reuse)notification_event—Silently no-ops on a disabled rule or an unresolvable scope
ExamNotificationScopeServiceWorker service (read-only)Every recipient-resolution query—ExamNotifierService, both sweepsreads onlynone—
TeacherExamsPortalController/Service, TeacherExamDutiesPortalService, TeacherExamMarksPortalController/ServiceControllers/servicesTeacher portal surfaceassertTeacherStaffRow (assertStaffRow for listExams, getExamDetail, streamAttachment and listInvigilations), ExamMarksCoreModule servicesTeacher clientreads + delegated marks writesexam_marks* (delegated)EXAM_ROUTINE_ENTRY_NOT_FOUND (identical-404 discipline)
StudentExamsPortalController/Service, ExamPortalReadsServiceControllers/servicesStudent portal surfaceassertPortalAudience, studentsStudent clientreads onlynoneEXAM_NOT_FOUND (identical-404)
GuardianExamsPortalController/ServiceController/serviceGuardian portal surfaceGuardianPortalService.resolveOwnChildId, ExamPortalReadsServiceGuardian clientreads onlynoneWard-resolution 404 runs before any exam read

16.2 UML and Architecture Diagram Pack

16.3 Code Flow Narrative

ExamLifecycleService.publish (the module's most consequential write):

StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1ExamLifecycleController.publishReceives POST /exams/:publicId/publish with a version body.Entry point; Permissions("ExamPublish_UPDATE") gates it.Missing permission → 403 before the handler runs.
2ExamLifecycleService.publishLoads the exam row by publicId.Every downstream check needs the internal id and current state.EXAM_NOT_FOUND.
3SameCounts exam_classes for this exam.A publish-gate precondition the database cannot express as a CHECK (it would need a cross-table count).Zero → EXAM_HAS_NO_CLASSES.
4SamenotExists query for any routine entry with no component.Same reason — "every entry has >= 1 component" spans two tables.Any found → EXAM_ROUTINE_ENTRY_HAS_NO_COMPONENTS.
5SameIf a grading scale is attached, checks for a min_percent = 0 band.The floor-band invariant also spans tables.Missing → GRADING_SCALE_HAS_NO_FLOOR_BAND.
6SameOpens a db.transaction.The status flip, its cascade, and the outbox enqueue must commit or roll back together.—
7loadConflictCandidatesReads every routine entry (still draft) plus their invigilators, BEFORE the write.After a savepoint rollback the transaction's own view of these rows would be gone; the candidates must be captured first.—
8runExamWriteWithConflictGuardRuns runTransition (the conditional UPDATE) inside a nested tx.transaction() — a SAVEPOINT.Publishing arms three exclusion constraints in ONE cascaded statement; a collision must not poison the outer transaction.Zero-row update → re-read decides EXAM_ALREADY_PUBLISHED vs. EXAM_STATE_TRANSITION_INVALID vs. EXAM_VERSION_CONFLICT.
9PostgresThe UPDATE exams SET status='published' cascades exam_status onto every routine entry (ON UPDATE cascade) in the same statement, arming the three GiST exclusions.This is the entire mechanism — one row's status change is what turns "draft, unconstrained" into "published, conflict-free by construction".A 23P01 here is caught by runExamWriteWithConflictGuard.
10resolveExamConflict (on 23P01)Pass 1: query other published/completed exams' entries for room/class/invigilator overlap. Pass 2: check the candidate set against itself in memory.An exam's own collision is usually entirely INSIDE the set being published, where pass 1 finds nothing.Neither pass finds anything → the original error is rethrown untouched (a genuine race with a row that moved).
11ExamLifecycleService.publishoutbox.enqueue({eventType:"exam.published", jobName: ExamJob.PUBLISH_NOTIFY, dedupeKey: "exam-published-{publicId}-v{version}"}) in the same transaction.The outbox exemption rule: an enqueue outside the write's own transaction can commit the write and lose the notification.—
12ExamDetailService.toDetailRe-reads the row (never echoes the request) and maps to ExamDetailDto.Repo convention: a write's response is a fresh read.—
13———Logs: none at this layer; the eventual worker log (ExamsQueueProcessor) records the notify job.
14———Known tradeoff: the three publish-gate reads (steps 3-5) are NOT re-checked inside the transaction — a concurrent class-scope edit between the check and the write is closed by the transaction's own isolation on the rows it touches, not by a second read.

ExamResultsComputeService.compute → runLocked:

StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1.compute(examPublicId)Loads the exam, fetches the live roll (row-scope predicate: deleted_at, record_status='active', enrolment status='active', scoped to exam_classes).The roll is read live, never a snapshot, so a pupil enrolled after scope was set still counts.EXAM_NOT_FOUND.
2SameIf roll.length > 500, enqueues ExamJob.RESULT_COMPUTE (deduped per exam) and returns {mode:"queued"} immediately.Keeps the request path fast; large-roll computation moves off it.A repeated request while one is queued is a no-op (dedupe key).
3SameOtherwise calls runLocked inline.Small rolls compute synchronously so the admin sees the result immediately.—
4runLockedSELECT pg_advisory_xact_lock(...) keyed on the exam id, inside a fresh transaction.Serializes a request and a queued job racing on the same exam — both take this same lock.Blocks until the other holder's transaction ends; never deadlocks (single lock, no nesting).
5SameRe-fetches the roll INSIDE the lock.A student's enrolment could have changed between the pre-check (step 1) and acquiring the lock.—
6computeSyncLoads the grading scale + bands (if any), every routine entry's component-max total, every mark for the roll, joined to its component.One batch of reads, not N+1 per pupil.—
7SameFor each pupil: computeSubjectFromMarks per relevant routine entry, lookupBand per subject and overall, computeOverall across subjects.Pure functions from exam-result-rules.ts — no I/O, exhaustively tested.See §16.5 for the invariant table these encode.
8SameINSERT ... ON CONFLICT (exam_id, student_id) DO UPDATE into exam_results; class_id is excluded from the update set.Recompute must not retroactively change which class a pupil is recorded as having SAT the exam in.—
9SameDELETE then INSERT exam_subject_results for the pupil.Full replacement is simpler and cheaper than diffing at this table's size (one row per subject per pupil).—
10—Transaction commits; lock releases.—A thrown error anywhere in steps 6-9 rolls back the whole compute for every pupil in this run — partial writes are not possible.

16.4 Data Layer Deep Dive

Every table's full field-level detail is in §5.2. This subsection adds the index-rationale table the format requires.

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
exam_routine_entries_room_freeexam_date, room_id, int4range(start_minute,end_minute)GiST EXCLUDE, partial (published/completed), WHERE room_id IS NOT NULLNo two live papers share a room and overlapping time.Every scheduling write against a published exam pays a GiST probe; unavoidable given the guarantee is database-enforced.
exam_routine_entries_class_freeexam_date, class_id, int4range(start_minute,end_minute)GiST EXCLUDE, partialNo two live papers clash for one class — and, transitively, no pupil sits two papers at once (via student_class_enrollments_one_active_per_session_unique).Same write-time cost; this is the STUDENT conflict expressed at the cheaper, enforceable class granularity.
exam_invigilators_freeexam_date, staff_id, int4range(start_minute,end_minute)GiST EXCLUDE, partialNo invigilator double-booked across live papers.Lives on the child table because an exclusion cannot span a join; every schedule move must cascade the four copied columns here first.
exams_id_session_uniqueid, academic_session_idunique constraintComposite FK target proving exam_classes.exam_id is in the exam's OWN session.unique(...), not uniqueIndex, to satisfy drizzle-kit's FK-before-index emission order.
exams_id_status_uniqueid, statusunique constraintComposite FK target that lets one UPDATE cascade status to every child row.Same ordering constraint.
exam_routine_entries_schedule_uniqueid, exam_date, start_minute, end_minute, exam_statusunique constraintComposite FK target for exam_invigilators' cascading schedule copy.Widens the "unique" surface to include non-identity columns purely to serve the cascade.
exam_routine_entries_one_paper_per_subjectexam_class_id, class_subject_iduniqueOne entry per subject per class per exam — a second paper is a second component, not a second row.Forces the components model rather than a naive "add another entry" workaround.
exam_assessment_components_id_max_uniqueid, max_marksuniqueCarries a component's ceiling onto every exam_marks row via cascade — the mechanism behind "a mark can never exceed its ceiling".A max_marks change is a cascading write across every recorded mark for that component, not a local update.
exam_marks_component_student_uniquecomponent_id, student_iduniqueOne mark per pupil per component — the target of the sheet's bulk onConflictDoUpdate.—
exam_results_exam_student_uniqueexam_id, student_iduniqueOne result per pupil per exam — the target of the compute upsert.—
exam_reminder_dispatches_keyroutine_entry_id, rule_id, exam_date, start_minuteuniqueThe dedupe mechanism itself — claiming this row IS "already sent".A rescheduled paper's new key was never claimed, so it re-arms with zero cleanup code; the cost is one row per (paper, rule) forever, never pruned.
exam_reminder_rules_offset_bounded (CHECK)offset_minutesCHECK 15..43200An offset below one sweep tick can fall entirely between two ticks and never dispatch — silently.The floor is the sweep's own period; changing the cron period without changing this CHECK would reopen the gap.
grading_scale_bands_scale_idxscale_id, min_percentbtreeServes lookupBand's "greatest floor <= pct" scan.—
exam_routine_entries_class_status_idxclass_id, exam_statusbtreeThe audience read: published papers for a class on a date.—
exam_results_stale_idxexam_idbtree, partial WHERE is_staleThe publish gate's stale-result count, and an operator's "what needs recomputing" view.Index only carries rows currently stale — near-zero size in the steady state.

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
No two live papers share a room/class/invigilator and overlapping time3 GiST EXCLUDE constraints, partial on published/completedThe database, not a service check, makes overlap unrepresentable once an exam is live.EXAM_ROOM_CONFLICT/EXAM_CLASS_CONFLICT/EXAM_INVIGILATOR_CONFLICTexam-conflicts.integration.spec.ts
exam_status on the two child tables is never written directlyGuard trigger, pg_trigger_depth() = 1 refusedA stray UPDATE would arm/disarm the exclusions with nothing failing and nothing logged.Postgres RAISE EXCEPTION (P0001)Constraint probe; asserted implicitly by every routine test relying on the cascade.
A mark can never exceed its component's ceilingComposite FK (component_id, max_marks) carries the ceiling onto the row; plain CHECK on one rowCross-row invariants cannot be CHECKs; the composite FK turns it into a single-row one.EXAM_MARK_EXCEEDS_CEILING, EXAM_COMPONENT_CEILING_BELOW_RECORDED_MARKexam-marks.integration.spec.ts
An absent/withheld/not-applicable pupil can never carry a markCHECK disjunction attendance='present' OR obtained_marks IS NULLDeliberately a disjunction, not a biconditional — makes "present, not yet scored" representable (the normal exam-hall workflow) while still forbidding an absence carrying a score.EXAM_MARK_ABSENT_CANNOT_SCORESame
Only five statuses are stored; "scheduled"/"in progress" are derivedderiveExamDisplayStatus (pure function)A stored "in progress" is wrong the minute after it is written.— (display-only)Implicit in exam-authoring.integration.spec.ts
A published exam is auto-completed once every paper has endedExamLifecycleSweepService.completeEndedExams, hourlyWithout the sweep, a published exam past its period has no derived status at all.—exam-sweeps.integration.spec.ts
A grading band has a floor and no ceilingSchema shape (min_percent only), lookupBand's greatest-floor scanMakes overlapping bands and gaps both unrepresentable, provided a 0% band exists.GRADING_SCALE_HAS_NO_FLOOR_BAND (checked because "a 0% band exists" cannot be a CHECK)exam-grading.integration.spec.ts, exam-result-rules.spec.ts
A reminder never fires twice for the same (paper, rule, schedule)exam_reminder_dispatches unique key, claimed before sendThe key contains the schedule, so a reschedule re-arms without any cancellation code.Silent skip (no error — the claim insert returns nothing)exam-sweeps.integration.spec.ts
Minimum spacing between two enabled reminder offsets is one sweep tickExamReminderRulesService.assertMinimumSpacing (service, not CHECK)A CHECK cannot span rows.EXAM_REMINDER_RULES_TOO_CLOSESame
Marks entry opens on time, not on a clickresolveMarksGate — end instant passed OR isCompletedGating on the exam-day flag alone would permanently lock out marks entry if the flag's one route (which teachers do not hold) is never clicked.EXAM_MARKS_NOT_YET_OPENexam-marks-workflow.integration.spec.ts
A marks submission has no stuck statemarks_submission_state enum with approved having two exitsA single-exit approved was caught in review as unreachable-from.EXAM_MARKS_TRANSITION_INVALIDSame
A correction is legal only once finalized, and is append-onlyService check (state==='finalized') + append-only trigger on exam_mark_correctionsCorrections exist to record a change to a SETTLED record; UPDATE is refused always, DELETE only through cascade.EXAM_MARKS_TRANSITION_INVALID; trigger RAISE EXCEPTION on a direct UPDATE/DELETEexam-marks.integration.spec.ts, constraint probe
A correction marks every result in the exam staleExamMarkCorrectionsService.correct sets exam_results.is_stale=true for the examA correction to a finalized mark leaves the computed result silently wrong without this.EXAM_RESULTS_STALE blocks publish until recomputeSame
Results publication is blocked until every paper is finalized and no result is staleExamResultsService.publishPublishing results is the act that makes a child's marks visible to a family — the highest-consequence write in the module.EXAM_RESULTS_NOT_ALL_FINALIZED, EXAM_RESULTS_STALEexam-results.integration.spec.ts
computeNow, never compute, from the workerExamResultComputeAdapterCalling compute from the worker would re-enqueue the job it is running; the per-exam dedupe key would swallow the repeat, draining the queue with nothing computed.— (design invariant, not a runtime error)exam-results.integration.spec.ts (queued-path assertions)
A teacher reaches only their own subject/invigilation scopesubjectTeacherExists/invigilatorExists, the submitted_by_staff_id OR-armExamMarks/ExamRoutines_READ must never be granted to a teacher role — see §11.Identical 404 on any out-of-scope accessexam-portal.integration.spec.ts, teacher-today.integration.spec.ts
A pupil's/guardian's exam visibility excludes draft onlyExamPortalReadsService.resolveExamInScopeEvery other status was, at some point, a live decision for this family — including cancelled, so public_cancellation_note remains readable.EXAM_NOT_FOUND (identical for absent/out-of-scope)exam-portal.integration.spec.ts
A result reaches a family only when published AND not withheldExamPortalReadsService.getExamResultBoth halves of the gate are load-bearing independently.EXAM_NOT_FOUNDSame
EXAM_ATTACHMENT is never a public upload typePUBLIC_TYPES set in upload-type.ts (verified this session)A public type is anonymously readable — a question paper would leak before the exam.— (a code-level invariant, not a runtime error)No dedicated spec found; verified by direct code inspection

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Conflicts as database constraintsA room/class/invigilator double-booking must be provably impossible once live.Three GiST EXCLUDE constraints, partial on published/completed, DEFERRABLE INITIALLY IMMEDIATE.A service-level overlap check before every write.A service check can race (two concurrent writes both pass the check, both then write); the database check cannot.Every schedule write against a live exam pays a GiST probe, and the conflict-resolution utility exists solely to turn a raw 23P01 into a named, actionable error.If exclusion performance ever becomes a bottleneck at very large per-day paper counts.
Partial on IN ('published','completed'), not 'published' aloneexam_status cascades from exams.status; marking one exam complete rewrites every entry beneath it.Both statuses in the predicate.'published' only.A 'published'-only predicate would drop a just-completed exam out of all three exclusions the instant it completes, letting another exam publish into the same room/slot/invigilator silently.None — this is strictly the correct predicate; the alternative was a caught defect.—
No exams.grading_scale_id = NULL on scale deleteA grading scale referenced by a published result must not vanish from that result's history.RESTRICT.SET NULL.SET NULL would silently strip the grading basis from every published result the scale produced — a result card that once showed A+ would show nothing, with no record of why.Deleting a scale in use is refused (GRADING_SCALE_IN_USE); an admin must deactivate instead.—
No max_marks column on exam_routine_entries"Components sum to the paper's total" spans rows, cannot be a single-row CHECK, and a trigger reconciling it gives one column two writers.No total column at all — SUM(components.max_marks) computed on read.A stored total, kept in sync by a trigger.Removes the invariant entirely rather than defending it awkwardly — there is no second copy to drift.Every total is a join+sum on read rather than a column read; at this table's per-exam row count the cost is negligible.—
submitted_by_staff_id snapshot on exam_marks_submissionsclass_subjects.teacher_id is a current-assignment pointer with no history.A snapshot column, checked by an OR in the portal predicate.Trust class_subjects.teacher_id alone for access.Reassigning a subject must not retroactively strip the previous teacher's already-submitted work nor hand the new teacher every past exam's marks.One more column, one more OR branch in every access check that touches marks.—
computeNow as the worker's entry point, compute as the request'sThe processor's handler map needs a RESULT_COMPUTE handler; the size check belongs to the request path.Two methods, the adapter calling only the inline one.One method with an "isWorker" flag.A flag is easy to pass wrong once and silent when wrong; two methods make the distinction a compile-time choice at the one call site (the adapter).Two public methods on one service instead of one.—
No cache anywhere in this moduleEvery read is either a small reference table or already gated by version/GiST exclusions.No cache.A Redis-backed cache for the routine/marks-sheet reads (the highest-traffic ones).A stale cache is the one thing that could show a slot as free after it was just taken — worse than an extra query, for a module whose entire value proposition is "provably conflict-free".Every request round-trips to Postgres; acceptable at school-scale traffic.If a specific read is measured as a hot path under real load.
exam_status copy guarded by a depth-1 trigger, not by revoking column-level UPDATE privilegePostgres has no per-column trigger scope finer than "any UPDATE touching this row".pg_trigger_depth() = 1 check inside a BEFORE UPDATE trigger.REVOKE UPDATE (exam_status) ON exam_routine_entries FROM api_role.The API connects as a single role; per-column privilege revocation would also block the legitimate cascade unless carefully scoped, and the trigger approach is what the rest of this schema already uses for similar guards.A stray direct UPDATE ... SET exam_status = ... from a future migration or a raw SQL escape hatch would still be caught, but only at runtime, not at grant time.—
The guardian portal resolves the ward BEFORE the examA foreign childId must never reach an exam-scope query.resolveOwnChildId first, always.Resolve the exam first, then check the ward relationship.The wrong order lets a foreign child id fall through to the exam service's own 404 — which still refuses, but only after skipping the guardian's own authorization decision, which is the shape this repo has already recorded as something that must never happen.None.—

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Reminder sweepSELECT count(*) FROM exam_reminder_dispatches WHERE dispatched_at > now() - interval '1 hour'Non-zero whenever a published paper's window is activeZero rows for an hour with published exams having upcoming papers; [exams] no handler/[failure] REMINDER_SWEEP in the logCheck ExamScheduler's cron is registered (ScheduleModule is skipped in API_RUNTIME_PROFILE=fast — confirm the workers module is actually composed); check BullMQ for a stuck/failed job with buildJobId's bucketed id
Lifecycle sweepLog line [exams] lifecycle-sweep completed exam=<id>; SELECT count(*) FROM exams WHERE status='published' AND <every entry ended>The count above stays at zero within an hour of the last paper endingA published exam visibly past its period with no completionManually invoke POST /exams/:publicId/complete, or re-trigger the hourly cron job
Result computation queueSELECT * FROM outbox_events WHERE event_type='exam.result_compute_requested' ORDER BY created_at DESCRows dispatched promptlyRows accumulating with no corresponding [success] RESULT_COMPUTE log lineCheck the dispatcher; check ExamResultsComputeService.computeNow for a thrown error against a specific exam (advisory lock contention resolves itself on retry)
GiST exclusion healthEXPLAIN a schedule write against a large routine; SELECT conname FROM pg_constraint WHERE conname LIKE 'exam_%_free'All three constraints present, DEFERRABLEA 23P01 that the conflict resolver could not name (falls through to the raw constraint-name error)Check exam-conflict.util.ts's pass-1/pass-2 logic against the specific rows involved; this is the backstop path and its firing usually means a genuinely concurrent write raced the lookup
Marks workflow stuckSELECT state, count(*) FROM exam_marks_submissions GROUP BY state for a sessionDistribution moves toward finalized as exam period endsA large submitted bucket with no ExamMarksVerify activityCheck NO_PERMISSION_ADMIN_HANDLERS/permission grants for ExamMarksVerify_UPDATE holders; check the exam.marks_submitted in-app notification actually reached them (role-scoped audience resolution)
Notification deliverySELECT * FROM notification_event WHERE source_module='exams' ORDER BY created_at DESCRows with recipients resolved per the rule's notify_* flagsA published/cancelled exam with zero notification rowsCheck exam_notification_rules.enabled for the event; check ExamNotificationScopeService's scope query returned any class/routine rows at all
Storage cleanupGrep API logs for Failed to clean up orphaned exam attachment / Failed to delete stored file for attachmentNo such linesRecurring cleanup failures for the same keyInvestigate the storage backend directly; the DB row is already consistent (insert failed, or delete succeeded) — only the object may be orphaned

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
A stray raw-SQL migration writes exam_status directlySchema integritySilently arms/disarms a live exclusion, freeing a room or invigilator with nothing failingexam_status_copy_is_cascade_only trigger refuses at pg_trigger_depth()=1None identified — the trigger is unconditional and covers every write path, including a future service bug
A publish's three pre-checks (class scope, components, floor band) are not re-verified inside the transactionConcurrencyA concurrent class-scope edit landing between the check and the write could theoretically slip throughThe transaction's own row-level locking on the rows it actually touchesA concurrent PUT .../classes removing the last class between step 3 and the commit is not explicitly re-checked; the constraint probe does not cover this window
ExamMarks/ExamRoutines_READ granted to a teacher role by a future changeAuthorizationWould permanently (unrevocably) hand every teacher every child's marks/routine, school-wideComments in the permission catalogue and in TeacherExamMarksPortalService's own docblock name this explicitlyNo automated guard (e.g., a permissions:sync-time assertion) prevents the grant if someone edits seed-auth.ts directly
The docblock in guardian-exams-portal.module.ts still describes GuardianPortalModule as not exporting GuardianPortalServiceDocumentation driftA reader trusting the comment would believe the module does not compileVerified this session: GuardianPortalModule DOES export GuardianPortalService — the comment is stale, describing a state that has since been fixedNot fixed in this pass (out of this documentation task's ownership); flagged in the report below
No automated test asserts EXAM_ATTACHMENT stays out of PUBLIC_TYPESSecurityA future edit to upload-type.ts could silently make exam attachments publicCode comment + this documentation pageNo CI assertion found specific to this invariant (the general upload-type test suite was not part of this module's ownership to inspect exhaustively)
Reminder sweep dispatch rows are never prunedStorage growthexam_reminder_dispatches grows without bound, one row per (paper, rule) foreverNone — an accepted tradeoff for the correctness the schedule-keyed design buysNo archival/retention policy found for this table

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented or explicitly marked non-runtime (DTO files summarized by directory, not individually — their shapes belong to the API doc).
  • Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
  • Every method with business behavior has a code-flow narrative (§16.3 covers the two highest-complexity methods in full; §6's tables cover every other method's reads/writes/side-effects/errors).
  • Every table/collection/cache object/job payload has field-level detail (§5.2, §9).
  • Every index, constraint, relation, and delete behavior has rationale (§5.2, §16.4).
  • Every lifecycle/status transition has a state diagram and transition table (§16.2, §6.2).
  • Every read/write/action/job flow has sequence and activity diagrams (§7, §9).
  • Every business invariant is cataloged (§16.5).
  • Every cache key, invalidation path, queue job, realtime event, and external call is documented (§8 states the absence of a cache; §9, §10).
  • Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
  • Every operational failure mode has a runbook entry (§16.7).

18. Backend Completion Checklist

  • Module boundaries are documented (§2).
  • Every controller, service, DTO, schema file, job, cache key, and event is covered (§3, §4, §5, §9, §10; no cache exists, stated in §8).
  • Every database table/collection has a field table and relationship diagram (§5.2, §5.3).
  • Every runtime flow has a diagram and branch notes (§7).
  • API, feature/flows, and TDD docs are linked (below).
  • No claim is made without a source file or documented source reference — every table, constraint, permission code and error code in this page was read directly from the current tree this session.

See Also

  • API doc: /docs/developer/exams/api
  • Features and flows doc: /docs/developer/exams/feature

On this page

Exams Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionsexam_typesgrading_scalesgrading_scale_bandsexamsexam_classesexam_routine_entriesexam_assessment_componentsexam_invigilatorsexam_marks_submissionsexam_marksexam_mark_correctionsexam_resultsexam_subject_resultsexam_notification_rulesexam_reminder_rulesexam_reminder_dispatchesexam_attachments5.3 Relationship Diagram6. Services and Responsibilities6.1 ExamSharedResolversService (shared/exam-shared-resolvers.service.ts)6.2 ExamLifecycleService (authoring/core/exam-lifecycle.service.ts)6.3 ExamWriteService (authoring/core/exam-write.service.ts)6.4 ExamClassesService / ExamAttachmentsService / ExamCalendarProjectionService / ExamDetailService6.5 Routine services (routine/core/*)6.6 Marks services (marks/core/*)6.7 Results services (results/core/*)6.8 GradingScalesService, ExamTypesService, ExamDashboardService, settings services7. Runtime Flows7.1 Publish an exam7.2 Routine entry patch that touches a published exam's schedule7.3 Swap two papers (deferred constraints)7.4 Submit marks7.5 Compute results — synchronous vs. queued7.6 Reminder sweep tick8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also