Skip to content

Report Cards

Report cards are the official end-of-period record of each student. Management defines reusable templates, opens cycles over chosen grades, and fills or delegates the hand-entered values. Each student's card is then generated into a frozen, versioned JSON document and published grade by grade to referents and students. Structure (subjects, criteria, class, teacher) and attendance are derived; only the values are typed in: subject values, the template's custom evaluations and its custom texts. The backend never renders a PDF and sends no notification at any point.

Canonical implementation: src/report-cards/ (one folded module, no dominant resource):

Folder Owns
shared/ Qualified authority (report-cards.access.ts), reference dates, the fixed Effort list (used by a template without an Effort scale), the report-card default values (default-values.ts), versioned JSON contracts and decoders (snapshots.ts), cycle/card locks and stamps (queries.ts), live logo minting
structure/ Card structure rules (card-structure.ts), the batched structure resolver, the population resolver and card-evaluation.resolver.ts (structure + entries + status for a cycle)
status/ Pure requirement and status computation (card-status.ts, no I/O)
attendance/ Pure attendance tally and the adapter over the attendance reader
templates/, cycles/, cards/, worksheets/, family/ One sub-resource each: controller, service, queries.ts, swagger, dto/

1. Model

Table Row Notes
report_card_templates Tenant-wide, reusable across years Title, a grade scale (evaluationScaleId, preset or own), includeEffort with an optional Effort scale (effortScaleId, preset or own; NULL is the fixed Effort list), includeCriteria (default true), includeCustomTexts / includeAttendance, ordered custom text items (customTextItems, versioned JSON with a stable key per item and an optional presetText, kept while texts are off)
report_card_template_evaluations One custom evaluation of a template The row id is the evaluation's stable key; name, ordinalPosition, its own evaluation scale (preset or own), ordered criteria (JSON, each with a stable key). Deleted with the template
report_card_cycles One evaluation period of one academic year Name, start/end dates, informative deadline, state OPEN / CLOSED / PUBLISHED
report_card_cycle_grades One grade inside one cycle templateSnapshot (the template frozen when the grade was added or re-applied), layoutRevision, publication stamp
report_cards One student in one cycle grade Created on demand; rosteredOn, value revision, generation stamp, document, attendance columns, publishedAt
report_card_entries One value slot of one card slotKey = S:<subjectId> / C:<criterionId> / T:<textKey> / E:<evaluationKey> / EC:<evaluationCriterionKey>; soft references to curriculum rows and to the snapshot's evaluations (evaluationKey, evaluationCriterionKey); notEvaluated, grade, effort, text; last-editor provenance
  • All six tables are tenant-bearing with FORCE RLS (Class S) and a Restrict tenant FK; see Multitenancy.
  • Curriculum subjects and criteria are referenced softly: curriculum edits may delete them, and an entry whose subject, criterion or text key is no longer in the card's structure is an orphan — kept, never required, never printed. Evaluation keys reference the grade's snapshot, not the live template; an entry whose evaluation or criterion key is not in the snapshot is an orphan too.
  • The partial unique index report_card_cycle_grades_one_unpublished allows one unpublished cycle grade per grade. Named CHECKs pin date order, publication coherence, the generation stamp (document, revisions and attendance columns set together), a template's Effort scale only while Effort is on (report_card_templates_effort_scale_requires_effort) and the entry kind/slot-key shape (report_card_entries_kind_coherent: each kind sets exactly its own reference columns, an evaluation criterion slot also carries its evaluation's key, notEvaluated only on subject and evaluation slots, and a kind without a branch is refused).
  • The template's two scales (grade and Effort) and each custom evaluation's scale are real FKs (Restrict), each registered in SCALE_FK_REFERENCES (reportCardTemplates, reportCardTemplateEfforts, reportCardTemplateEvaluations): a scale used by a template, as either scale, or by a custom evaluation is in use, so it cannot be deleted and its values cannot be edited (409 EVALUATION_SCALE_IN_USE), and a tenant template on a platform preset locks that preset (Grades). Because Effort off clears the Effort scale, only a template with Effort on holds one.
  • Template settings. The Effort scale is the scale of every Effort value on the template's cards (subjects, curriculum criteria, custom evaluations and their criteria): any scale visible to the school, whatever the curriculum, possibly the grade scale itself. The stored value follows the resulting includeEffort (resolveEffortScaleId): with Effort off nothing is stored and a sent id is ignored unvalidated; with Effort on a sent value wins (null returns to the fixed list) and an unsent one keeps the stored scale. A grade or Effort scale id that a write would newly store must be visible to the school (isScaleVisible), else 422 REPORT_CARD_TEMPLATE_SCALE_NOT_FOUND naming the field (§12). includeCriteria governs the curriculum criteria only (§3); custom evaluations keep their own. Duplicate copies both settings. Like every template setting, both are frozen into the cycle grade's snapshot, so a template edit reaches a cycle grade only when it is re-applied (§5).

2. Roster and anchor dates

  • The reference date of a cycle grade is schoolToday clamped into the grade department's calendar (referenceDateFor).
  • While the cycle is OPEN and the grade unpublished, the roster is live: the year's students placed in the grade on the reference date, with the live participation fence when that date is today and the history fence otherwise. Nothing is written to follow enrolment. Joiners appear; leavers and grade movers disappear, and their card rows become dormant (kept, invisible, uncounted).
  • Close materialises the roster of every unpublished grade: it upserts a card row for each live-roster student with rosteredOn = reference date and nulls rosteredOn on the other rows of those grades. A CLOSED or PUBLISHED cycle, or a published grade, reads the rows with rosteredOn set. Reopen writes nothing.
  • Card rows are created on demand by the first entry write or generation (ensureCardRows, insert-on-conflict-nothing in id order, before the row lock). A student without a row has an empty entry set. Values never follow a student to another grade of the same cycle.
  • A student on the rosters of two grades of one cycle (a grade move after the first grade was frozen) has two cards. Single-card routes and entry writes locate the card that is not published (locateCards); bulk generation and publication consider every card.
  • The anchor date of a card — the date on which plan, homeroom, course and teachers are resolved — is the reference date for a live roster and rosteredOn for a frozen one. Dated reads use the end-of-day effectiveOn view.

3. Card structure

For a student, cycle grade and anchor date, buildCardStructure derives:

  1. Study plan. The selection effective on the anchor date. No selection adds one blocking requirement, STUDY_PLAN, owned by management; an incomplete selection is not blocking and is flagged studyPlanIncomplete on the monitoring row.
  2. Subjects. Top-level subjects of the curriculum that have an hours cell for the grade, are covered for the student's track and option choices (resolveTargetCoverage), and are taught during the cycle (taughtAllYear, or a teaching window that overlaps the cycle dates). Order: the common section first, then the track section; mandatory subjects by ordinal, then option blocks by ordinal with the chosen subjects by ordinal; ties by id.
  3. Criteria. pickCriteriaForGrade (a per-grade override replaces the defaults), sorted by name. A subject without units uses its own criteria (SUBJECT, or NONE when empty); a subject with units uses one list for criteriaLevel = SUBJECT, or one group per unit for UNIT (units without criteria are omitted; no criteria at all is NONE). When the grade's snapshot excludes criteria (criteria: false, frozen from the template's includeCriteria), every subject is NONE with no criteria and no units; the subject list, its order and the responsibilities are unchanged. evaluateCycle passes the flag (withCriteria) to the structure resolver, so the card detail, worksheets, monitoring, the outstanding list, the preview and generation all follow. Custom evaluations keep their own criteria. The template preview structure does not go through buildCardStructure and always lists the curriculum's criteria.
  4. Subject responsibility. The student's own course for the subject on the anchor date, with every teacher of that course effective on the date (loadSubjectGroupTeachersOn, any ordinal, in teaching order). No course, or a course without teachers, is UNASSIGNED. Lesson-assistance teachers and the teachers of sibling courses in a combined class are never responsible.
  5. Class responsibility (the custom texts and the custom evaluations), resolved when the grade's snapshot has a custom text without a preset text or at least one custom evaluation (hasClassItems); first non-empty tier: the homeroom's current homeroomTeacherId (HOMEROOM_TEACHER); then active holders of a CURRICULUM-parametric role with the student's curriculum (COORDINATOR); then Director-function holders (DIRECTOR); then admins (ADMIN). Tiers 2–4 count only assignments whose role grants report_cards.entries WRITE and whose user is active (loadClassFallbackHolders), so a read-only Director-function role never becomes responsible.
  6. Header. Class = the homeroom on the anchor date, else the grade; teacher = the homeroom teacher or null; school = School.legalName.

People. Every person in a responsibility, in the computed header (stored documents: §8) and in the outstanding list carries firstName, lastName and name (formatPersonName, first name first), so clients can render either order. Holder lists (COORDINATOR, DIRECTOR, ADMIN) sort by last name, then first name, then id; course teachers keep their teaching order. The outstanding list sorts by missing count (descending), then last name, first name, role and id.

Required values (status/card-status.ts), in card order: STUDY_PLAN; for every subject not marked Not evaluated, the subject grade and every criterion grade of its structure (none when the snapshot excludes criteria), plus the matching Effort values when the snapshot has Effort; the same for every custom evaluation of the snapshot not marked Not evaluated (its grade, every criterion grade, Effort when on), with or without a study plan; every custom text item without a preset text when the snapshot has texts. An Effort value counts once it is stored: writes accept only the snapshot's levels and the default values (§7), and re-apply clears the values outside them (§5). A default value (-) counts as given like any other.

Preset texts. A custom text item with a presetText is fixed content: management writes it once in the template and every card prints it under the item's title. It is not a class item, requires no value, is left out of the class worksheet, shows read-only on the card detail (presetText, text = the preset text, editable: false, missing: false), and a text write on it is refused (TEXT_ITEM_PRESET). The snapshot freezes it like the rest of the item, so editing the template changes cards only after a re-apply.

Owner of a missing value (missingValueOwner). Evaluation values reuse the kinds GRADE and EFFORT and carry evaluationKey (and evaluationCriterionKey) instead of subjectId, so the kind alone does not say whose a value is. STUDY_PLAN is management's; TEXT and every value with an evaluationKey are the class responsibility's; every other value is its subject's course. The outstanding list, the worksheet counts and the card detail's per-field missing all dispatch through this predicate.

4. Status and staleness

stale  = document != null AND (generatedRevision != revision OR generatedLayoutRevision != cycleGrade.layoutRevision)
status = publishedAt != null → PUBLISHED   (stale exposed separately: "Published · Stale")
         document && !stale  → GENERATED
         document &&  stale  → STALE
         no missing value    → READY
         otherwise           → INCOMPLETE
  • Status is computed on every read and never stored; monitoring filters and paginates in memory.
  • revision increments once per committed batch that changes a stored value of the card; a no-op write does not.
  • layoutRevision increments on a template change or re-apply for that grade, and on a change of the cycle's name, start or end date for every unpublished grade.
  • Structural drift after generation (curriculum, homeroom, teachers) and register edits do not make a card stale: the document froze those facts. missingCount stays live.

5. Lifecycle

open ─→ OPEN ──close──→ CLOSED ──publish(grades)──→ CLOSED (some grades published)
         ↑                │                              │
         └────reopen──────┘                              └─publish(last grade)─→ PUBLISHED (terminal)
Command Allowed when Effect
Open Active year; ≥1 grade; grade rules pass One cycle grade per grade with a frozen snapshot
Edit name / dates Not PUBLISHED and no grade published Grade rules re-checked; layoutRevision++ on unpublished grades
Edit deadline Not PUBLISHED Informative only
Add grade, change or re-apply template OPEN, grade unpublished Re-snapshot and layoutRevision++; on an existing grade, the values the new snapshot cannot hold are cleared (below)
Remove grade OPEN, grade unpublished Deletes the cycle grade, its cards and entries; when only published grades remain, the cycle becomes PUBLISHED
Close OPEN Materialises the roster of unpublished grades
Reopen CLOSED Unpublished grades are live again; published grades keep their roster
Publish grades CLOSED; every rostered card of each grade GENERATED (not stale) Stamps the grades and their cards; the last grade makes the cycle PUBLISHED
Delete OPEN, no grade published Cascade delete

Re-apply clearing (pruneIncompatibleEntries, in the transaction that re-snapshots the grade). Changing or re-applying the template of an existing grade clears, on the grade's cards:

  • subject and curriculum criterion grades outside the new scale, and evaluation and evaluation criterion grades outside that evaluation's new scale; a default value is in every scale, so it is never cleared;
  • Effort values, matched by code against the new snapshot's Effort levels on subject, criterion, evaluation and evaluation criterion slots: a code in both lists (default values included) is kept and takes the new label, any other is cleared, and with Effort off every Effort value is cleared, a default value too. Moving between the fixed list and a scale, or between two scales, therefore clears the values the new list lacks;
  • by deleting the slot: texts whose key is gone (every text when texts are off; a text on an item that became preset is kept, unread, and shows again if the preset text is removed and the template re-applied), evaluation slots whose evaluation is gone, evaluation criterion slots whose criterion is gone from their evaluation, and every curriculum criterion slot when the new snapshot excludes criteria. Turning criteria back on does not restore them.

Each card where a cleared or deleted slot held a grade, an Effort value, a text or a Not evaluated mark gets one revision++, and the grade_template_changed audit lists those cards (clearedCardIds). Every generated card of the grade turns stale in any case, through layoutRevision++.

Grade rules (open, add grade, date change): a grade may not have an unpublished cycle grade in another cycle, its published cycles may not overlap the dates, and the dates must fit its department calendar.

Concurrency. Open, add grade, date changes, template update and template delete take the per-tenant advisory lock report-cards:structure:<tenantId> before the cycle row lock. A template update takes it first and then reads the template, validates the request's keys and scales, replaces the custom evaluations and writes the audit in the same transaction, so a snapshot never sees a half-replaced evaluation list; template create needs no lock. Lifecycle commands lock the cycle row FOR UPDATE; entry writes and generation lock it FOR SHARE and then the affected card rows FOR UPDATE in id order. Generation stamps compare-and-set on the revision it read under the lock. Close and bulk generation run under the REPORT_CARD_HEAVY transaction budget.

6. Authority

Every decision goes through ReportCardsAccess over PermissionsService.resolveAuthorizedRoleSlice: only assignments whose own grants satisfy the requirement contribute their role keys and parameters. School-wide means a platform admin, admin, or a Director-function role in the slice. Route gates use the singular @RequireScope; the service adds reach.

Operation Slice Reach
Template create / edit / delete report_card_templates.configuration WRITE (+ action) School-wide only
Cycle open / edit / grades / close / reopen / delete report_cards.cycles WRITE (+ create / delete) School-wide, or every grade of the cycle in the slice's departments
Publish cycles WRITE + publish Each requested grade's department
Value write, unpublished card report_cards.entries WRITE Management: school-wide, the grade's department, or the student's curriculum; writes while OPEN or CLOSED. Teacher, OPEN only, per slot (ownsSlot): subject slots of a course they teach on the anchor date; custom text and custom evaluation slots when they hold the class responsibility as HOMEROOM_TEACHER
Generate, unpublished card entries WRITE + generate Management reach (no teacher branch)
Write or regenerate a published card entries WRITE + correct Management reach of the correct slice; otherwise 409 REPORT_CARD_PUBLISHED
Read cards, previews, documents, worksheets entries READ Tenant-wide for admin, Director/Front Office functions, SEN Manager and any DEPARTMENT/CURRICULUM-parametric role; a teacher-only slice sees only cards with one of its responsibilities; anything else fails closed
Read templates, cycles, monitoring the scope's READ Tenant-wide
Family list / detail report_cards.published READ ReportCardsFamilyPolicy (referent, student)

Presets: admin holds everything (correct included); the Director manages everything except correct; the department head manages cycles and values of its departments; the curriculum coordinator writes and generates values of its curricula; Front Office, Communications Manager, Administrative Assistant and SEN Manager read; teachers write their own values; referents and students read published cards; HR and staff hold nothing. SEN Manager's template and cycle reads come from its extra-read list. See Permissions.

Hidden targets return the resource's 404 (a teacher-only reader outside its responsibilities gets REPORT_CARD_STUDENT_NOT_IN_CYCLE); visible but non-writable slots return 403 REPORT_CARD_ENTRY_NOT_AUTHORIZED listing each slot with NOT_RESPONSIBLE or CYCLE_NOT_OPEN.

7. Values

PATCH /report-card-cycles/:cycleId/entries takes up to 500 slot writes for any number of students and is all-or-nothing. The body lists subjects[] (with criteria[]), evaluations[] (with criteria[], keyed by the snapshot's evaluation and criterion keys) and texts[]; every subject, criterion, evaluation, evaluation criterion and text write counts towards the 500. One student can carry up to 420 evaluation slots, so a whole class does not always fit one request.

  • A present field sets the value, null clears it, an absent field is untouched. notEvaluated (subjects and evaluations) keeps the stored values, so turning it off restores them. Texts are trimmed; a blank text clears the slot.
  • Failure order: a student off the roster or hidden (404), validation against the card's live structure and the grade's snapshot (422 REPORT_CARD_ENTRY_INVALID, reasons SUBJECT_NOT_IN_PLAN, CRITERION_NOT_IN_SUBJECT, VALUE_NOT_IN_SCALE, EFFORT_DISABLED, EFFORT_INVALID, CRITERIA_DISABLED, TEXTS_DISABLED, TEXT_ITEM_UNKNOWN, TEXT_ITEM_PRESET, TEXT_TOO_LONG, EVALUATION_UNKNOWN, EVALUATION_CRITERION_UNKNOWN), a published card without correct (409), slots outside authority (403). A subject grade is checked against the snapshot's scale, an evaluation grade against that evaluation's own scale.
  • Default values. Every grade scale and every Effort list of a card also accepts the report-card default values (REPORT_CARD_DEFAULT_VALUES: -, a value that cannot be given), on subject, curriculum criterion, evaluation and evaluation criterion slots. They are not frozen into the snapshot or the frozen scale (a format shared with the gradebook): withDefaultValues / withDefaultLevels append them wherever a card's scale is used — entry validation, re-apply, document labels, and the values / effortLevels the card detail and worksheets expose, where a default value has numericEq, min and max null on every scale type. A scale's own value with the same code wins. Open cycles therefore accept them without a re-apply, and evaluation scales and presets carry no such value. The list is add-only: removing a value would orphan the values stored with it. Not evaluated is a different thing: it removes a subject or evaluation from the card, while a default value is printed.
  • An Effort value, on any valued kind, is the code of one of the snapshot's Effort levels — a value of the template's Effort scale, or a fixed-list code when it has none — or a default value. A value longer than 50 characters, the length of a scale value, fails request validation (400 VALIDATION_FAILED) before the rules run; any other value that is not a level code is EFFORT_INVALID, and a value on a card without Effort is EFFORT_DISABLED.
  • A curriculum criterion write on a card whose snapshot excludes criteria is CRITERIA_DISABLED at subjects[i].criteria[j], checked before the subject and criterion lookups, as TEXTS_DISABLED is for texts; the subject's own values in the same item are validated as usual. Custom evaluation criteria are unaffected.
  • The response returns each named student's status, stale flag and missing count after the write.

8. Generation and the document

  • Generation requires a complete card (else 422 REPORT_CARD_INCOMPLETE with the missing values). Single generation also refreshes an up-to-date card. Bulk generation (POST …/generate) generates complete READY and STALE cards in the caller's reach, skips up-to-date cards unless refresh: true, and never touches published cards; cards outside the caller's reach are left out of every count.
  • Structure and attendance are computed before the card locks; entries are re-read under the lock.
  • The document is ReportCardDocumentV1 (v: 1): values with their labels, names, header, the scale and Effort tables, the subjects (each with its teachers), customEvaluations, custom texts and the attendance block, rendered in that order. A custom text with a preset text prints that text on every card, whatever per-student text is stored. Not evaluated subjects and evaluations are omitted. Each custom evaluation carries its own scale as its legend and labels its grades by it. The Effort table is effort: { levels: { code, label }[], scale: { id, name } | null } | null (null without Effort): the snapshot's levels as the legend, and the Effort scale they come from, null for the fixed list; every Effort value carries its level's { code, label }. A default value prints with its own label; the scale and Effort legends list only the scale's own values. When the snapshot excludes criteria, every subject has criteriaMode: 'NONE' and empty criteria and units. Clients render by v.
  • customEvaluations is always present on a decoded document: a v: 1 document generated before custom evaluations existed decodes with []. Likewise effort.scale is always present when Effort is on: a v: 1 document without it decodes with scale: null.
  • subjects[].teachers is { firstName, lastName, name }[]: the subject's responsibility (§3 rule 4) frozen without ids, every teacher of the student's own course on the anchor date in teaching order, [] for an unassigned subject. Custom evaluations carry no teachers (the class responsibility's teacher is the header teacher). It is always present on a decoded document: a v: 1 subject without the key decodes with [], and a key that is present must be an array of objects whose three fields are strings or the whole document is corrupt. Regenerating the card writes the list.
  • The header teacher is { firstName, lastName, name } | null. The decoder requires name and accepts the parts only as strings; a v: 1 document without them stays valid, and clients render name for it. Regenerating the card writes the parts.
  • Stored JSON (template items, snapshots, documents) is written through reportCardJson and read only through the hand-written decoders in shared/snapshots.ts; a payload that fails to decode is corrupt and is never coerced. A shape change bumps v and adds a decoder branch — frozen documents are never rewritten. The template snapshot's additive keys decode to defaults when absent, so its consumers never handle absence: customEvaluations as [], criteria as true (criteria included) and the Effort section's scale as null (the fixed list); a key that is present but malformed makes the snapshot corrupt.
  • The school logo is not frozen: previews, stored documents, the template preview structure and family responses mint the current School.logoFile at read time. The school name is frozen.
  • The preview (GET …/students/:studentId/preview) computes the document now without storing it; missing values render as empty strings so the V1 shape holds.

9. Attendance measure

Owned by attendance/attendance-tally.ts and report-card-attendance.adapter.ts, over the attendance reader of Reports and the register.

  • Window: from = cycle start, to = min(cycle end, schoolToday − 1) — completed days only. A card generated before the end date is partial; monitoring flags and filters it (attendancePartial).
  • A school day for a student is a date the register expects them (expectedCount = 1: placed, in the calendar, outside closing periods, a timetable version applies and they are on a slot roster) and on which someone in the reader row's department has a register cell (loadRegisterTakenDates). Joiners and leavers count only their placed days.
  • Measures, each in days and a one-decimal percentage of school days (null without school days): absences (every cell absent), late entries, early exits (one day can be both). Trip days count as present; suspension days count in no measure and win over a trip; a PERIOD day with only some absent periods is in no measure.
  • Days not recorded (school days without any cell of the student) stay in the denominator, count in no measure, are listed with their dates, and never block generation or publication. Management completes them in the register (POST /attendance/records) and regenerates; bulk refresh: true refreshes generated cards.
  • The figures are a snapshot. Register edits never make a card stale; the preview always shows live figures. On a published card, refreshing attendance is a correction (correct holder, single generation).
  • Generation copies attendanceNotRecordedDays and attendanceThrough onto the card row so monitoring filters without decoding documents.
  • Each command (preview, single or bulk generation) computes attendance in one reader pass narrowed to its students, then one taken-dates read. The reader resolves each timetable version's schedule once for the window and loads the students' facts per 93-day horizon and their cells per 28-day window (Reports §5), so the cost grows with the horizons, not with the dates.

10. Worksheets and family

  • Worksheets (/report-cards/worksheets) are the caller's own: one per course whose teachers include the caller's Teacher row (kind COURSE), and one class worksheet (kind CLASS) per class whose class responsibility the caller holds — homeroom teacher, or a tier 2–4 user whose session reads cards tenant-wide, so a narrowed teacher view keeps only its own. A class worksheet exists when the grade's snapshot has class items (hasClassItems); it carries the text items without a preset text ([] when texts are off), the evaluations with their own scales and criteria, and each student's texts and evaluation values. Both kinds, like the card detail (template.effortLevels), carry the snapshot's Effort levels as effortLevels: the Effort scale's values, the fixed list without one, followed by the default values; null without Effort. Every scale they expose likewise ends with the default values (§7). A course worksheet takes its criteria from the card structure, so it has no criterion rows (criteriaMode: 'NONE') when the snapshot excludes criteria. Only OPEN and CLOSED cycles are listed; a published grade stays listed read-only until the whole cycle is published. remainingCount counts students whose card still needs values (INCOMPLETE or STALE) from the worksheet: a subject value for a course worksheet, a text or evaluation value for a class worksheet (classMissingCount). Writes go through the entries route.
  • Family (/report-cards/family) uses ReportCardsFamilyPolicy (referent link, or the student's own row), evaluated at the Student root. The list returns the published cards of every accessible Student row of the same person (personUuid), so past years appear while access to those rows persists, newest publication first. beingUpdated is the stale flag of a corrected, not yet regenerated card; the stored document is served until then. Unpublished, inaccessible and unknown targets are one 404 REPORT_CARD_NOT_FOUND.

11. REST contract

Method Path Gate
GET /report-card-templates, /report-card-templates/:id, /report-card-templates/preview-structure configuration READ
POST /report-card-templates, /report-card-templates/:id/duplicate create
PATCH / DELETE /report-card-templates/:id configuration WRITE / delete
GET /report-card-cycles, /report-card-cycles/:id, …/:id/students, …/:id/outstanding cycles READ
POST /report-card-cycles create
PATCH /report-card-cycles/:id cycles WRITE
PUT / DELETE /report-card-cycles/:id/grades/:gradeId cycles WRITE
POST …/:id/close, …/:id/reopen cycles WRITE
POST …/:id/publish publish
DELETE /report-card-cycles/:id delete
POST …/:id/generate (bulk), …/:cycleId/students/:studentId/generate generate
GET …/:cycleId/students/:studentId, …/preview, …/document entries READ
PATCH /report-card-cycles/:cycleId/entries entries WRITE
GET /report-cards/worksheets, …/:cycleId/courses/:subjectGroupId, …/:cycleId/class entries READ
GET /report-cards/family/students/:studentId/cards, /report-cards/family/cards/:cardId published READ + family policy

Command POSTs answer 200; creates answer 201. Both entities are flat (FLAT_DTO_ENTITIES); no route uses @AggregateResponse(), and no top-level response key is named after a scope.

12. Errors

The REPORT_CARD_* family lives in src/common/constants/error-codes.ts with bilingual messages and Swagger examples. Structured payloads reach the wire as passthrough collections (COLLECTION_ENRICHMENTS): each leaf carries its references plus a localized messages.

Code HTTP data
REPORT_CARD_TEMPLATE_IN_USE 409 cycles[] { cycleId, cycleName, academicYearId, state }
REPORT_CARD_TEMPLATE_EVALUATIONS_INVALID 422 items[] { index, path, reason } (NAME_TAKEN / CRITERION_NAME_TAKEN / SCALE_NOT_FOUND), one per problem of the request's custom evaluations
REPORT_CARD_CYCLE_DATES_INVALID 422 grades[] { gradeId, calendarStartDate, calendarEndDate } for OUTSIDE_CALENDAR
REPORT_CARD_CYCLE_GRADE_CONFLICT 409 conflicts[] { gradeId, cycleId, cycleName, reason } (UNPUBLISHED_ELSEWHERE / DATES_OVERLAP)
REPORT_CARD_PUBLISH_BLOCKED 409 students[] { studentId, firstName, lastName, gradeId, status }
REPORT_CARD_ENTRY_INVALID 422 items[] { index, path, reason }
REPORT_CARD_ENTRY_NOT_AUTHORIZED 403 items[] { index, path, studentId, reason }
REPORT_CARD_INCOMPLETE 422 missing[] { kind, subjectId, criterionId, textKey, evaluationKey, evaluationCriterionKey }

The other codes carry no data: template not found / name taken / scale not found (with the top-level field, evaluationScaleId or effortScaleId, naming the template field whose scale the school cannot see) / texts required; cycle not found / name taken / invalid state / grade published / fields locked / has published grades / not authorized; student not in cycle; published; not generated; not found (family); preview curriculum invalid. Unknown grades, subject groups and homerooms use the generic NOT_FOUND; template writes or generation outside the qualifying reach use the generic FORBIDDEN. A template write whose custom text, evaluation or criterion key repeats, or is not one of the template's current keys at its level, is a client error (400 VALIDATION_FAILED). See Error Handling.

13. Audit

resolveActor once per transaction, one record per logical mutation (Audit Log):

Entity type Actions
report_card_template created (duplicates carry sourceTemplateId), updated, deleted; each template snapshot carries its custom evaluations (key, name, evaluationScaleId, criteria)
report_card_cycle opened, updated, grade_added, grade_template_changed, grade_removed, closed, reopened, grades_published, deleted
report_card generated (bulk uses recordMany; wasPublished marks a correction), corrected (before/after of the changed slots)

Value writes on unpublished cards are not audited; each entry keeps its last editor. Presentation is label-only.

14. Extending

  • New document field: add it to ReportCardDocumentV1 only if older documents decode without it; otherwise introduce v: 2 with its own decoder branch and keep V1 rendering. Never rewrite stored documents.
  • New template switch or template item list: add the column (or a child table when items reference other rows, as custom evaluations reference their scale), freeze it in ReportCardTemplateSnapshotV1 with a decoder that fills its default when old snapshots lack it (bump the snapshot version otherwise), derive requirements from the snapshot, never from the live template, and decide in pruneIncompatibleEntries (EntryPruneRule) what a re-apply clears. Template writes that replace a list run under the structure lock. A setting without a foreign key may be kept while its switch is off (custom text items are); a setting that holds a scale FK is cleared in the same write that turns its switch off, with a CHECK that backs the rule (the Effort scale, report_card_templates_effort_scale_requires_effort), so a hidden selection never keeps a scale in use. A new scale FK is a real Restrict FK with its own SCALE_FK_REFERENCES row.
  • New value kind: a new ReportCardEntryKind, slot-key prefix and kind CHECK branch. The CHECK ends with ELSE FALSE, so the enum value lands in one migration and the branch that admits it in the next (PostgreSQL cannot use a value in the transaction that adds it); no row of the new kind can exist before its branch. Decide its owner in missingValueOwner and its authority in ownsSlot; requirement and status logic stays in status/ (pure).
  • New responsibility tier or role: change the structure rules and loadClassFallbackHolders, keeping tiers grant-aware; authority stays in ReportCardsAccess over qualified slices.
  • Report cards import other modules only through their barrels (attendance reader and taken dates, grades scale helpers, curriculum coverage, subject-group teachers, student placement fences); no owner module imports report cards.