Skip to content

REFERENCE

Read this at the start of any non-trivial task. It is the single-file map of the project: what exists, where it lives, and which invariants you must not break. It does not explain concepts — it points at the chapter that does.

Triage rule: CLAUDE.md tells you when to read what. This file tells you where everything is. docs/NN-*.md tells you how a specific area works. docs/todo.md tells you what's in flight right now — near-term tasks + the working-tree situation (uncommitted/verified/next). Read it at session start; this file is the static map, todo.md is the live state. todo.md carries no "done" marks — a finished item is deleted from it entirely, never checked off (what shipped is recorded in commits, spec/plan docs, and memory, not in todo.md).

User stories are not authoritative. ClickUp epics/US, pasted descriptions, and frontend prototypes are starting points — not specs. Always brainstorm with the user what to keep, defer, or rework before designing or implementing; the agreed design is the spec, not the story text. Recurring pattern: see decisions logged for setup-wizard invitations, teacher-assignments curation, grace-period default, per-pair invitation tracking, US-32 v1. Full rule in CLAUDE.md § Workflow.

Unexplored space requires a design spec. New entity, new RBAC primitive, new cross-cutting concern, new external integration, or any task §6 below doesn't index → fill docs/superpowers/templates/new-feature-design.md, check it in under docs/superpowers/specs/, and get explicit sign-off before code. Full rule in CLAUDE.md § Design Gate.


1. What this is

Multi-tenant Student Information System for K-12 schools. NestJS 11 monolith, Prisma ORM, PostgreSQL, JWT auth. Modules map 1:1 to future services. Beta target: small team, ship fast, preserve migration path to scale.

Full stack and philosophy: docs/01-architecture.md.


2. Mental model — the abstractions everything rotates around

You cannot reason about a task in this repo without these eight concepts. Each bullet is one sentence; follow the link for the real chapter.

  • Tenant context — every row carries tenantId (or an FK chain to one); every query filters on it. Postgres RLS enforces isolation on all 58 tenant-bearing tables (per-request tenant transaction + GUC, app connects as non-superuser app_user; coverage registry src/prisma/rls-coverage.ts, bypass surface fenced by a drift spec); service-layer filtering stays on top as correctness + defense-in-depth. docs/02-multitenancy.md §4.
  • Academic year context — time-scoped data (enrollments, curricula) is additionally filtered by academicYearId. Reads accept an override via AcademicYearQueryDto; writes (POST/PATCH/DELETE/reorder) ignore any override and always target the tenant's ACTIVE year. docs/05-crud-patterns.md.
  • Entity-Scope-Action RBAC — permissions are (entity, scope) for visibility and (entity, action) for operations. Scopes group fields; actions group operations; read is implicit and never an action. docs/04-rbac.md.
  • BaseTenantedCrudService — generic abstract service used by students/teachers/staff. Subclasses provide entity key, Prisma delegate, and scope-field mappings. Everything else (create, findAll, findOne, update, remove, toScopedResponse, flattenDto, custom fields) is inherited. Canonical: src/common/services/base-tenanted-crud.service.ts.
  • FieldFilterInterceptor — strips unauthorized fields from responses based on the caller's scopes. Runs last in the request pipeline; response DTOs are the contract it filters against. Canonical: src/permissions/interceptors/field-filter.interceptor.ts.
  • AppException + ErrorCode — the only way to signal domain errors. Carries a machine-readable code preserved by AllExceptionsFilter. Params are typed per code at compile time. docs/06-error-handling.md.
  • queries.ts convention — every domain module ships a <domain>.queries.ts with include/select constants and named query functions. No repository classes, no inline Prisma calls in services beyond the base class. docs/05-crud-patterns.md.
  • EntityAccessPolicy — per-entity object (definePolicy) that bundles the role allowlist and the Prisma WHERE builder in one declaration; applied via @AppliesPolicy(Policy) on the controller and Policy.where(ctx) in the service. Eliminates decorator/filter drift. docs/04-rbac.md.

3. Request lifecycle

Every authenticated endpoint passes through the same chain. @ProtectedResource() composes it.

HTTP request
  ├─ JwtAuthGuard         validates token, attaches { userId, tenantId, roles }
  ├─ ScopeGuard           reads @RequireScopes(); rejects 403 INSUFFICIENT_SCOPE
  ├─ ActionGuard          reads @RequireAction(); rejects 403 ACTION_NOT_PERMITTED
  ├─ FieldWriteGuard      blocks write payloads touching unauthorized fields
  ├─ RolesGuard           enforces @RequireRoles() when present
  ├─ Controller method    @TenantId(), @Query() AcademicYearQueryDto, @Body() DTO
  │     │
  │     └─ Service method (extends BaseTenantedCrudService)
  │           │
  │           └─ Prisma (via queries.ts include/select constants)
  ├─ FieldFilterInterceptor   strips fields the caller's scopes cannot read
  └─ AllExceptionsFilter      converts AppException → JSON { code, message, data?, params? }

Canonical: src/common/decorators/protected-resource.decorator.ts, src/common/filters/all-exceptions.filter.ts.


4. Module map

Every module under src/ follows the same shape: <name>.module.ts, <name>.controller.ts, <name>.service.ts, <name>.queries.ts, <name>.swagger.ts, dto/, interfaces/, index.ts. Structure rules: docs/05-crud-patterns.md.

Module Responsibility Notes
auth/ Login (3-step state machine: credentials → tenant → profile), JWT issue/refresh, logout, /me, cookie delivery, activeProfile selection and switching Passport strategies in strategies/; cookie helper owns Set-Cookie semantics. docs/03-auth.md.
profile/ GET /auth/profile — User identity joined with the caller's full Teacher/Staff/Student/Referent snapshots Owns the route at /auth/* to stay adjacent to /me, but lives in its own module to avoid an AuthModule ⇄ InvitationsModule cycle. docs/03-auth.md.
permissions/ RBAC enforcement: guards, interceptors, @RequireScopes, @RequireAction, @RequireRoles; GET /permissions (caller's compiled grants) and GET /roles (admin-only roles & permissions catalogue: every role's scope/action grants nested by entity-group); plus admin-only role assignment: GET/POST /role-assignments + PATCH/DELETE /role-assignments/:id (assign/edit/revoke management roles, anchored on a Teacher/Staff person rowUserRole.recipientType/recipientId, nullable userId; PENDING before the person has a user, materialized to ACTIVE at invitation-accept; parametric values + optional expiry); plus admin-only role grant-editing: PATCH /roles/:key (edit a management role's scope/action grants; admin + profile-coupled roles frozen) The authoritative module for authorization. docs/04-rbac.md (incl. the GET /roles catalogue + entity-group UX under "Two read surfaces", the /role-assignments assignment surface, and the PATCH /roles/:key grant-editing surface).
prisma/ PrismaService wrapper, module export Do not import PrismaClient directly from generated/.
common/ Shared services, decorators, DTOs, filters, exceptions, validators, constants BaseTenantedCrudService and AppException live here.
students/ Student CRUD with 8 scopes (identity, contacts, assignment, school_relationships, health, documents, referents_and_guardians, curriculum_selection) + import. Also: per-student curriculum-selection sub-resource (RUS-4) at GET/PATCH /students/:id/curriculum-selection (singleton per student; PATCH-as-upsert; referent writes gated by StudentReferentLink.canWrite AND an OPEN (AY, Department) window; admin bypasses both gates), the admin-only bulk curriculum assignment at POST /students/curriculum-selections/bulk-assign (body { curriculumId, studentIds[] } → additively creates a curriculum-only StudentCurriculumSelection header for every eligible student with no selection; returns assigned[] + skipped[{ studentId, reason: NOT_FOUND \| NOT_APPLICABLE \| ALREADY_HAS_SELECTION }]; never touches an existing selection; reuses loadConsistencyStructure for isComplete; spec 2026-06-16-bulk-curriculum-assignment-design.md), and the guardians sub-resource at GET/POST /students/:id/guardians + PATCH/DELETE /students/:id/guardians/:guardianId (lightweight non-login caregiver contacts; write = admin + linked-referent via assertReferentCanWrite, read = all student-viewers). Canonical reference for the CRUD pattern. school_relationships = hasSiblingInSchool/isChildOfEmployee derived live at read from the current graph (predicates key off MOTHER/FATHER referent links, same AY, + Teacher/Staff tax-code/email match), with a *Locked admin-pin bit that overrides the derive (stored value returned verbatim). No materialized recompute — the flag value is computed in enrichSchoolRelationshipPeople alongside relatedPeople; referent has READ only (R4, spec 2026-07-02-school-relationship-flags-ay-create-contract). referents_and_guardians is a relational scope (zero field mappings): its read group is { items: referent links, guardians: Guardian rows, missingFields }, its WRITE grant gates the guardians sub-resource; renamed from referents on 2026-06-08 (Guardian design). curriculum_selection is a gate-only scope (zero field mappings; drives @RequireScope singular on the sub-resource routes); see docs/04-rbac.md §"Gate-only scopes". assignment is field-level only (the prior dual-surface convergence was split on 2026-05-27). The assignment read projection additionally embeds three read-only enrichment keys (visible to any students.assignment reader): studyPlan (curriculum-selection summary — status + chosenTrack + selectedOptions[] chosen option blocks, on list+detail — plus a detail-only selectedCurriculum grade-sliced tree of tracks/mandatory-subjects/option-blocks), homeroom, and subjectGroups (homeroom-bound + standalone). The study-plan half reuses the intra-module flattenCurriculumForStudent (extracted to students/study-plan.mapper.ts; class half in students/class-assignment.mapper.ts); see docs/superpowers/specs/2026-06-15-student-assignment-study-plan-and-classes-design.md.
teachers/ Teacher CRUD (5 scopes: identity, contacts, employment, documents, health) + import. Also: GET /teachers/hour-budgets — admin-only paginated aggregate view (per-teacher contract envelope + assigned subject-group load for the resolved AY; uses @RequireRoles('admin') + @AggregateResponse(), bypasses scope-grouped response shape). Status is PeopleStatus { ACTIVE, ON_LEAVE, ARCHIVED } (shared with staff; identity scope). Setting ARCHIVED revokes app access — deactivate the linked User (isActive=false) + revoke refresh tokens + delete any pending invite, keeping the userId binding; ARCHIVED→ACTIVE/ON_LEAVE flips isActive back. Wired in BaseTenantedCrudService.update() via InvitationConfig.revokeAccessOnArchiveInvitationsService.revokeAccessForArchivedRecipient/restoreAccessForRecipient. Contract dates are validated start <= end (DTO @IsDateNotBefore covers create + both-in-payload; TeachersService.beforeUpdate merge-check covers the sparse PATCH). See docs/superpowers/specs/2026-06-22-people-status-archive-and-contract-validation-design.md.
staff/ Staff CRUD (5 scopes: identity, contacts, employment, documents, health) + import Status is PeopleStatus { ACTIVE, ON_LEAVE, ARCHIVED } (shared with teachers); setting ARCHIVED revokes app access and un-archiving restores it, same revokeAccessOnArchive wiring as teachers. Staff has no contract dates (employment scope = roleDescription/hours/iban), so the contract-date rule is teacher-only. See docs/superpowers/specs/2026-06-22-people-status-archive-and-contract-validation-design.md.
users/ User read-only (profile scope) Identity layer; user != person. The admin-ish fields (isPlatformAdmin, isActive, lastLoginAt) live inside the single profile scope — there is no separate admin scope.
referents/ Parent/guardian contact records + PATCH profile/link flags Name fields nullable; admin-controlled canWrite on each StudentReferentLink gates referent writes on the linked student (enforced in StudentsService.updateForAccessContext).
departments/ Department + nested Grade sub-resource + per-department calendar (dates + period set) configuration scope. attendanceMode (DAILY/PERIOD) is frozen once the department's effective start (calendarStartDate ?? AY.startDate) has passed — service-level gate on the PATCH path throws DEPARTMENT_FIELD_IMMUTABLE; same-value writes pass; setup-wizard bulkSync is exempt. studentPlatformAccess toggles freely; flipping it OFF cascade-deletes the department's pending STUDENT invitation rows in the same transaction (InvitationsService.deletePendingByStudentDepartment, wired via beforeUpdate/afterUpdate hooks on PATCH and the bulkSync flag map — see docs/superpowers/specs/2026-06-03-student-invitations-design.md). Calendar (2026-06-04): calendarStartDate/calendarEndDate are MANDATORY configuration fields — each department owns its own calendar, there is no AY-level calendar to inherit (no null/clear-to-inherit). Written via the generic PATCH /departments/:id behind a DRAFT-only + period-revalidation gate in updateForAccessContext. A department's period set can be written two ways (both DRAFT-only, validated against the calendar bounds): the /departments/:id/periods sub-resource (DepartmentPeriodsController, per-period CRUD) or bundled terms/closingPeriods arrays on the generic create/PATCH configuration scope (parity with the wizard DEPARTMENTS step — create is exempt from the DRAFT gate as initial config, like the wizard; PATCH syncs them via the afterUpdate hook). create also persists calendarStartDate/calendarEndDate. GET /departments/:id returns a configuration.calendar block { startDate, endDate, terms, closingPeriods } (period set grouped by type, assembled inline in toDetailResponse). Also exposes minPresentHoursPercentage (per-department minimum-present-hours % (1-100) threshold for deferred notifications). Grace period stays AY-level (not per-department). See docs/superpowers/specs/2026-06-04-department-scoped-academic-calendar-design.md.
rooms/ Room CRUD, canteen lunch shifts, + CSV/XLSX import, + platform-global room-type presets configuration scope. First non-person import (POST /rooms/import): classification keys on name, code is the email-analog uniqueness constraint, CANTEEN types are rejected (lunch shifts stay manual), no personUuid/completionField. See 07 §8 + spec 2026-06-12-rooms-import-design.md. Room types are platform-global (RoomType.tenant_id NULL, shared by all tenants; one global row per name via the partial unique room_types_platform_name_key) ∪ per-tenant custom rows; curated via @PlatformAdminOnly GET/POST/PATCH/DELETE /room-types/presets (RoomTypesController, with a backoffice SPA tab). Globals are backoffice-managed (create/rename/delete) except PROTECTED_ROOM_TYPES = ['CANTEEN'] — the only name the code references literally — which is frozen (rename/delete or rename-to → ROOM_TYPE_LOCKED); the list DTO carries isProtected. Defaults seed from DEFAULT_ROOM_TYPES (incl. the TO_BE_DEFINED import placeholder). Same platform-preset shape as evaluation_scales/roles but shared-reference, not cloned. See specs 2026-06-15-room-types-platform-design.md + 2026-06-25-room-types-backoffice-managed-design.md.
school/ School identity — singleton per tenant, single configuration scope GET /school + PATCH /school (admin write, all roles read). Field set: legalName, operationalName, taxId, structured address (country/city/street/streetNumber/postalCode), timezone, primaryLanguage, phone (E.164), email, optional website, optional logo (base64-encoded PNG inline on JSON body — service decodes + persists via FilesService.replaceFileSlot with FileUsage.LOGO). logoFileId is system-managed and rejected on direct writes; the response embeds a SignedFileUrlDto under configuration.logoFile. Wizard still writes via bulkSync; PATCH 404s if no row exists. On every write, operationalName is synced onto Tenant.name (one-way, name only — slug untouched) inside the same $transaction, so the multi-tenant login picker shows the configured school name, not the seed default. Idempotent via a name: { not } guard. See docs/superpowers/specs/2026-06-26-school-tenant-name-sync-design.md.
academic-years/ Academic year + periods (the AY-default set) Setup wizard writes the first year via bulkSync (lands ACTIVE). Admin endpoints: POST creates a DRAFT (scalars + optional initial terms/closingPeriods arrays, validated against year bounds; gracePeriodEnding set only if supplied — no default derived, unlike the wizard's bulkSync), PATCH edits a DRAFT (scalar fields + period array sync, missing ids deleted), and per-period CRUD under :id/periods — all reject non-DRAFT years with ACADEMIC_YEAR_NOT_EDITABLE. startDate/endDate/gracePeriodEnding are the tenant default; departments may override dates + period set (see departments/). All AY period reads/writes here scope to the AY-default set (Period.departmentId IS NULL); department override periods live with departmentId set and are managed via /departments/:id/periods. The period mapper + DTO contract are exported from the AY barrel for the departments sub-resource.
curriculum/ Curricula CRUD (grid substrate); curriculum selection window (US-32 v2) Also consumed by setup wizard. Grid substrate (2026-06-04 remodel): a Curriculum owns its CurriculumSubject / OptionBlock / CurriculumTrack / CurriculumRule directly, with per-(subject, grade) CurriculumSubjectHours cells (row presence = offered; "—" otherwise). There is no StudyPlan model and no /curricula/:id/study-plans sub-resource — the whole structure is written through one family-sync PATCH /curricula/:id (omitted array = untouched, present = authoritative; id-or-key identity — a row id round-tripped from a GET renames in place, so choices + carryKey survive; without an id it falls back to name/code matching, syncEntitiesByIdOrKey) in src/curriculum/curriculum-structure-sync.ts. Structural edits run the post-mutate selection-consistency sweep in the same tx (src/curriculum/selection-consistency.ts): it is diff-based — it demotes confirmedAt when a confirmed selection is no longer complete or a CHOSEN subject's HL/SL level was swapped (captureSelectionPreImages snapshots committed substance before the sync), and emits notify-only change descriptors (logged in v1, delivered by the deferred notifier) for new/removed/level-changed mandatory subjects. A grid edit has no SG side-effect — Subject Groups are created manually only (a removed (subject, grade) cell still clears its empty standalone SGs via the in-use guard; auto-seeding on offer was removed 2026-06-30). See docs/superpowers/specs/2026-06-16-curriculum-edit-selection-invalidation-design.md. Tracks are containment (nullable trackId); Curriculum.trackSelectionMode + per-entity carryKey govern cross-grade carry. A CurriculumSubject may carry a nullable levelId (IB HL/SL → subject-levels/); CurriculumRule supports RuleScopeType.LEVEL (count subjects at a level) alongside ENTIRE_PLAN (per-block "pick N of M" is the block's own minSelections/maxSelections, not a rule — the BLOCK rule scope was dropped 2026-06-23 as redundant). The selection-consistency engine (selection-consistency.ts) evaluates all rule scopes at selection-write + the post-edit sweep (the generic CurriculumRule pass — previously authored but dormant). IB layout recipe: each group is a maxSelections=1 OptionBlock (same-subject-both-levels exclusivity is then free), and HL/SL counts are LEVEL rules. Curriculum.status is DRAFT / READY — only READY curricula are referent-eligible (RUS-4); admin flips it via the dedicated PATCH /curricula/:id/status ({ status }{ id, status }, gated by curricula.configuration write through the singular @RequireScope flat-DTO pattern): offering (DRAFT→READY) is a pure status flip (Subject Groups are created manually only — never seeded); class creation (homerooms/courses) is allowed on any status (DRAFT or READY — status no longer gates it, the READY-gate was reverted 2026-07-06); un-offering (READY→DRAFT) is refused only while selections reference it (CURRICULUM_IN_USE_BY_SELECTIONS) — any attached homerooms/courses carry over into DRAFT untouched; the subject-removal grid guard CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS is unchanged; the grid PATCH /curricula/:id no longer accepts status (it runs the consistency sweep). CurriculumSelectionWindow is one per (AcademicYear, Department): admin opens with POST /selection-windows body { departmentId, startDate } (blocked if any active grade in that department lacks a READY curriculum); end date is always AcademicYear.gracePeriodEnding, computed live and shared across departments. GET /selection-windows lists per-department state (NOT_OPENED placeholders fill in). GET /selection-windows/current?departmentId=... reads a single window. Status (NOT_OPENED / SCHEDULED / OPEN / CLOSED) is derived from (row?, today, gracePeriodEnding). Different departments can have independent windows opened on independent schedules. Referents read window state via curricula.selection_window scope.
custom-fields/ Custom field definitions CRUD + validation Plugs into BaseTenantedCrudService via pickCustomFields.
setup/ Tenant setup wizard: state machine + handler orchestration docs/08-setup-wizard.md.
mailer/ Generic outbound email facade (MailerPort) + active transport selected by MAIL_TRANSPORT env var (log default, resend wires src/mailer/resend/ submodule with Svix-signed POST /api/v1/webhooks/resend) + MemoryMailer test transport. MailPayload is dual-mode (html mode | template mode); template mode sends a Resend server-side template ref + variables (no html/subject). See docs/superpowers/specs/2026-04-23-resend-adapter-design.md and docs/superpowers/specs/2026-06-24-resend-credential-email-templates-design.md.
invitations/ Admin-side credential issuance: Invitation model + send/list/resend/reset endpoints; async webhook transitions to FAILED and COMPLAINED; invalidateByRecipient hook called from Teacher/Staff/Student mutations; revokeAccessForArchivedRecipient/restoreAccessForRecipient (2026-06-22) called from BaseTenantedCrudService.update() on a Teacher/Staff status→ARCHIVED transition (deactivate-without-detach: isActive=false + token revoke for accepted users, delete pending-invite rows, keep the binding) and the reverse See docs/superpowers/specs/2026-04-20-invitations-design.md. Iteration 2 shipped resend/reset + auto-invalidation on email change and hard delete (Teacher + Staff; Referent wiring deferred to iteration 2.5). Iteration 3 adds acceptance + delivery reports (COMPLAINED via Resend webhook). 2026-06-03: STUDENT recipient type gated live by Department.studentPlatformAccess at send/resend/verify/accept (send → failed[] ACCESS_DISABLED; resend → 409 INVITATION_ACCESS_DISABLED; verify/accept → generic 410, anti-oracle) + flag-flip cascade on pending rows; accept binds the profile-coupled student role; projection cohort = flag-ON departments ∪ already-invited. See docs/superpowers/specs/2026-06-03-student-invitations-design.md. 2026-06-24: credential email migrated to per-language Resend server-side templates (alias invitation-credentials-<lang> from School.primaryLanguage, default en); variables single-sourced in src/invitations/invitation-template.ts (recipientName/schoolName/activationUrl + optional 7-day presigned schoolLogoUrl); INVITATION_TOKEN_TTL_DAYS default 30→7 (aligned to the logo URL cap). Runbook docs/runbooks/resend-credential-email-templates.md; design docs/superpowers/specs/2026-06-24-resend-credential-email-templates-design.md.
files/ Document storage for Student/Teacher/Staff/Referent. Two cardinalities behind one FileUsage enum: single-slot (PASSPORT, IDENTITY_CARD) lives on entity FKs; collection (PERSONAL, EDUCATIONAL) lives as File rows tagged with (ownerType, ownerId) (polymorphic, same pattern Invitation uses). For single-slot files the File row also carries documentNumber + expiryDate, set via multipart form fields on upload or via a dedicated PATCH /:id/documents/by-id/:fileId/metadata route. Synchronous multipart upload; reads return a pre-signed URL JSON envelope (SignedFileUrlDto) that the FE GETs directly from the bucket — TTL via S3_SIGNED_URL_TTL_SECONDS (default 15 min). Hard delete on replace / delete / entity hard-delete. S3-protocol storage (MinIO local, Railway bucket deployed). See chapter 05 §11.
command-center/ Admin dashboard tabs: GET /dashboard/completeness (admin + referent — data-completion view, scope-restricted missingFields per row, isMe flag for referent self), GET /dashboard/onboarding (admin-only — invitation lifecycle for Teacher/Staff/Referent with isOverdue overlay), and GET /dashboard/curriculum-selections (US-31; admin-only — per-Student selection lifecycle NOT_STARTED/IN_PROGRESS/COMPLETE (derived from the materialized isComplete via deriveSelectionStatus since the 2026-06-12 split) with per-department windowStatus, isOverdue overlay, linked-referent contact set each carrying its own invitationStatus, a per-row studentInvitationStatus (the student's own credential lifecycle, null unless the department grants studentPlatformAccess), and meta.summary cohort rollups (summary unaffected by the invitation enrichment); renamed from study-plan-selections in the grid remodel), and GET /dashboard/overview (US-31 S5; admin-only — home-page rollup returning three aggregate cards: onboarding (total + per-entity-type + per-status counts), completeness (total + per-status), selection process (total + per-status + overdue overlay), each breakdown a zero-filled { status\|entityType, count }[], plus meta.{activeYearId, gracePeriodEnding}, plus nextSteps — the verbatim GET /dashboard/next-steps payload ({ steps: NextStepCardDto[] }), composed via NextStepsService.getNextStepsForYear sharing the once-resolved active year (additive 2026-07-01; no card removed, so the FE can point its existing next-steps renderer at overview.nextSteps). Counts are DB-side (iteration 2): onboarding via onboardingStatusCounts raw SQL (entity-anchored LEFT JOIN invitations GROUP BY status, 4 entities UNION ALL'd — invitation.groupBy would over-count across AY rollover), selection via computeSelectionCounts Prisma count() over curriculumSelection relation filters; completeness stays an in-memory loadItems() + summarizeCompleteness scan (per-row missingFields is arbitrary TS, not SQL-expressible)). All four anchored on the active year's gracePeriodEnding. Plus GET /dashboard/next-steps (admin-only — the "what to do next" checklist under src/command-center/next-steps/): an ordered { steps: NextStepCardDto[] } registry guiding the admin from delegating curriculum authoring → defining curricula → (optional) resolving student choices → building classes → generating timetables → inviting referents/students. Each card is completed only when the admin explicitly marks it (POST/DELETE /dashboard/next-steps/:key/complete, persisted in NextStepCompletion per (tenant, AY, taskKey)) — status is never inferred from tenant data (done-heuristics removed 2026-07-02). The optional resolve-student-choices card still appears only when curricula carry option-block/track choices (applicability, not done-ness) and never gates the roadmap; cta is semantic (FE owns copy + routing). One ErrorCode NEXT_STEP_TASK_UNKNOWN; no new RBAC. See docs/superpowers/specs/2026-05-05-command-center-design.md (tabs 1+2; tab 3 was deferred there and re-scoped on top of US-32 v1 + RUS-4) and docs/superpowers/specs/2026-06-08-command-center-overview-design.md (overview v1) + docs/superpowers/specs/2026-06-08-command-center-overview-iteration-2-design.md (DB-side counts) + docs/superpowers/specs/2026-06-25-setup-curriculum-removal-and-admin-next-steps-design.md (next-steps + curriculum-step removal). Reuses Invitation projection from invitations/, the per-scope computeMissingFields engine, and deriveSelectionWindowStatus from curriculum/. The onboarding + curriculum-selections tabs share one invitation projection — projectInvitationStatus (COMPLAINED→FAILED, null→NOT_SENT) in shared/invitation-status.ts + the findInvitationsForRecipients batch-loader in shared/invitations.queries.ts.
evaluation-scales/ Grading scale catalog (NUMERIC / LETTER / DESCRIPTIVE) + cascade onto the grid: CurriculumCurriculumGrade (per-grade default) → CurriculumSubject (subject scale + criteria scale) → CurriculumSubjectHours (per-(subject, grade) override). SCALE_FK_REFERENCES enumerates all five FK columns. Tenant-flat with three platform-owned read-only presets (tenantId IS NULL) seeded on every seed pass. Live-inherit: null at any level means "inherit from parent"; responses expose both gradingScale and effectiveGradingScale at each level. EvaluationScalesService.assertScaleVisible is the cross-module helper called by CurriculumService when a gradingScaleId arrives in a payload. See docs/superpowers/specs/2026-05-18-evaluation-scales-design.md.
curriculum-presets/ Backoffice catalog-editor surface for global curriculum presets: platform-admin GET + @PlatformAdminOnly POST/PATCH/DELETE preset CRUD (CurriculumPresetsController at /curriculum-presets). Consumes the cross-module assertPresetScaleVisible / assertPresetLevelVisible helpers. Pairs with the evaluation-scale + subject-level preset surfaces and the embedded /backoffice SPA. See 17.
subject-levels/ Subject level catalog (IB HL/SL) — SubjectLevel, tenant-flat with platform-owned presets (tenantId IS NULL), cloning the EvaluationScale shape (visibleLevelWhere = own rows ∪ presets; in-use delete guard via LEVEL_FK_REFERENCES over CurriculumSubject.levelId + CurriculumRule.levelId; HL/SL seeded every pass). No own RBAC entity/scope: routes are gated by the singular @RequireScope(CURRICULA, 'configuration', …) sub-resource gate (flat DTO bypasses field-filtering); preset writes by @PlatformAdminOnly(). assertLevelsVisible/assertPresetLevelVisible are the cross-module helpers (CurriculumService / CurriculumPresetsService). See docs/superpowers/specs/2026-06-08-ib-subject-levels-design.md.
homerooms/ US-33. Administrative grouping of students bound to (Department, Curriculum, Grade) for one AY. Single composition scope; create/delete actions. Binds exactly one CurriculumTrack when the curriculum has tracks (roster + child SGs scoped to it). Wizard cascade in one $transaction: creates 1 Homeroom + N HomeroomAssignment + N homeroom-bound child SubjectGroups (one per mandatory CurriculumSubject offered in the grade). Curriculum/Department/Grade/Track immutable post-creation (HOMEROOM_FIELD_IMMUTABLE). Creation is allowed on a curriculum in any status (DRAFT or READY — the READY gate was reverted 2026-07-06). Roster sub-resource (POST/DELETE/POST :id/students/move) cascades into the homeroom-bound child SG rosters. Record-level visibility via HomeroomsPolicy (src/homerooms/homerooms.policy.ts) — admin/teacher/staff pass-through, referent scoped via StudentReferentLink, student via own userId. GET /homerooms/grouped-homerooms (manager-only counts board for the list view, 2026-07-02 — catalog-driven dept → curriculum → grade skeleton: every visible dept, every curriculum (DRAFT or READY), every covered grade, empty nodes present; per-node placement counts + roster-free homerooms[]; no student rows; two-scope homerooms.read + inline students.read, admin/department_head). The candidate picker GET /students/eligible-for-homeroom was restored 2026-06-16 (server-bucketed {noSelection,selected}, single-sourced with the write-gate via the pure classifyHomeroomEligibility in src/homerooms/homeroom-eligibility.ts; admin/department_head; see ch.14 §5). See docs/14-homerooms-subject-groups.md + docs/superpowers/specs/2026-05-25-us33-us33_1-design.md + docs/superpowers/specs/2026-07-02-grouped-boards-iteration-2-design.md + docs/superpowers/specs/2026-06-16-eligibility-classifier-pickers-design.md.
subject-groups/ US-33.1. Curriculum-driven teaching unit anchored on one CurriculumSubject + its own gradeId (the grade it teaches; hours resolve against that grade's cell). The anchor may be a mandatory subject OR an in-block alternative — eligibility branches accordingly. Two flavours: standalone (homeroomId IS NULL, full CRUD + roster ops) and homeroom-bound (roster derived from parent Homeroom — rejects DELETE + roster ops; PATCH allows teacher swap + custom fields only). One composition scope; create/delete actions. "One SG per Subject per Student per AY" is DB-backed: SubjectGroupAssignment denormalizes curriculumSubjectId + academicYearId with @@unique([studentId, curriculumSubjectId, academicYearId]). Move endpoint requires Subject match. Creation is allowed on a curriculum in any status (DRAFT or READY — the READY gate was reverted 2026-07-06). Record-level visibility via SubjectGroupsPolicy (src/subject-groups/subject-groups.policy.ts). GET /subject-groups/grouped-courses (manager-only counts board for the list view, 2026-07-02, grade level + per-subject coverage added 2026-07-03 — catalog-driven dept → curriculum → grade tree; each grade node carries plan-coverage (numStudents / numStudentsWithMissingAssignments via per-student resolveTargetPlan), teacher-coverage (numSubjectGroups / numSubjectGroupsWithoutTeacher), subjectGroups[] incl. homeroom-bound leaves carrying a homeroom ref, and per-subject subjects[] (numStudentsExpected/numStudentsPlaced, catalog-complete via findBoardSubjectsForCurricula); curriculum keeps numMinRequiredSubjectGroups (static floor via countMinRequiredSubjectGroups) + Σ-grades counts; no student rows; two-scope subject_groups.read + inline students.read, admin/department_head). The candidate picker GET /students/eligible-for-subject-group was restored 2026-06-16 (server-bucketed {noSelection,pending,selected}, single-sourced with the write-gate via the pure classifySubjectGroupEligibility in src/subject-groups/subject-group-eligibility.ts — incl. the multi-pick option-block capacity rule; admin/department_head; see ch.14 §5 + docs/superpowers/specs/2026-06-16-eligibility-classifier-pickers-design.md).
timetable-templates/ US-35 + US-36. Tenant-flat catalog: DayTemplate (anchor startTime + ordered TimeSlot[] of type PERIOD/INTERVAL/LUNCH, slot start/end times computed at projection time) and WeekTemplate (MON-SUN map of dayTemplateId \| null). AY-scoped binding: DepartmentWeekTemplateAssignment (default for all grades) and GradeWeekTemplateAssignment (override). Resolver mirrors the eval-scales cascade shape — grade row wins, falls back to department, else null. Single RBAC entity TIMETABLE_TEMPLATES with configuration scope and create/delete/assign actions (admin-only). GET /timetable-assignments returns the dept→grade tree with computed LUNCH_CAPACITY_EXCEEDED alerts (aggregate predicate, per-grade visibility); write paths never block on capacity. See docs/superpowers/specs/2026-05-25-timetable-templates-design.md.
timetables/ Hand-managed weekly timetables + automatic generation. Timetable (DRAFT/PUBLISHED/ARCHIVED, ≤1 PUBLISHED per (tenant, AY) via partial-unique) + ScheduledLesson (coordinate (weekday, periodOrdinalPosition) resolved at read-time against the grade's effective week template; no timeSlotId). Edits are non-blocking on DRAFT; a pure computeDiagnostics(snapshot) engine returns ERROR/WARNING {category, severity, params} (i18n codes) as an always-on envelope { lesson?, diagnostics } on every lesson mutation, feeds GET …/diagnostics, and gates publish (no ERROR). 15-category window-aware catalogue (12 ERROR + 3 WARNING): teacher/room/class/student conflict, option-block sync(@maxSel=1)/clash(@>1), room-capacity, room-not-in-subject-set, teacher-availability, hour-budget, calendar-interval homeroom-gap, no-effective-template + 3 warnings. Live edits to a PUBLISHED timetable are transactionally validated-or-rolled-back (422 TIMETABLE_EDIT_WOULD_VIOLATE); publish only on an ACTIVE AY; ARCHIVED is cascade-only + read-only. ADMIN-only: entity TIMETABLES, descriptor-only configuration scope, create/delete/publish/generate actions. Six read-views (grade/homeroom/subject-group/room/teacher/student) with empty-grid slotGrids. Automatic generation: POST /timetables/generate (generate action) → buildGenerationSnapshotrunPreChecks (7 categories, 422 on failure) → SolverPort.generate (HTTP to Python CP-SAT service in solver/) → FEASIBLE: ingest DRAFT + post-ingest computeDiagnostics assert (rollback + 500 on drift); INFEASIBLE/TIMEOUT: 422 with enriched violations. Satellite module src/timetables/generation/. See docs/18-timetables.md + specs 2026-06-08-timetable-manual-management + 2026-06-11-…-iteration-2 + 2026-06-12-timetable-generation.
solver/ (repo root, not under src/) Stateless Python FastAPI + OR-Tools CP-SAT microservice. One-shot POST /v1/generate (bearer TIMETABLE_SOLVER_SECRET): receives a generation snapshot from the BE, returns { status: FEASIBLE\|INFEASIBLE\|TIMEOUT, assignments?, violations?, solveMetadata }. Fixed seed 42. Budget split: 75% main solve / 25% unsat-core minimization. 7 hard families (assumption literals → infeasibility core → {category, entityRefs}); 11-term soft objective (3 warning-mirror + 8 quality). GET /health is public. The BE talks to it via SolverPort (HttpSolverTransport prod / FakeSolverTransport tests). Env: TIMETABLE_SOLVER_URL, TIMETABLE_SOLVER_SECRET, TIMETABLE_SOLVER_TIME_LIMIT_SECONDS. See docs/18-timetables.md §8 + solver/README.md + spec 2026-06-12-timetable-generation.
audit-log/ Generic append-only "who did what when" trail. AuditLog row = { tenantId, actorUserId?+actorName+actorRole (snapshots), entityType+entityId (free-string type + **soft reference, no FK** → survives entity hard-delete), action (namespaced string), data (full value snapshot per event, typed per consumer), requestContext?, createdAt }. Written only via AuditService.record(tx, actor, params) inside the caller's transaction (atomic with the mutation); resolveActor(tx, ctx) snapshots the actor once per request. Never updated/deleted. Admin-only read GET /audit-log (filter entityType/entityId/actorUserId/from/to, paginated) — entity AUDIT_LOG, descriptor-only configuration scope, no actions, @RequireRoles('admin'). Base-service auto-emit hook is designed-but-deferred (explicit record() calls only in v1); attendance is the first consumer. See docs/superpowers/specs/2026-06-25-audit-log-design.md + plan docs/superpowers/plans/2026-06-25-audit-log.md.
attendance/ Compliance attendance register (Italian registro), first consumer of audit-log/. Keys off Department.attendanceMode (DAILY = one cell per (student, school-day), period_ordinal_position NULL; PERIOD = one cell per scheduled lesson). AttendanceRecord is an immutable compliance document: deep snapshot of the teaching context (student/cohort/lesson/period names+codes) + soft-FKs (hard FK only tenant/academicYear/student Restrict; no FK to Timetable/ScheduledLesson, no cascade from SubjectGroup) → survives SG/timetable hard-delete/archive/reorg. Two partial unique indexes (daily WHERE period NULL / period WHERE NOT NULL — hand-authored in the migration). Lazy grid (row exists only once recorded). Recordable cells derive from the single PUBLISHED timetable ∩ the department's school-days (calendar bounds − CLOSING periods − non-operative weekdays from the grade's effective week template). Write authority = in-service "Y-set" (lesson teachers + any teacher who teaches the student that day + the student's homeroom teacher + admin), broader than read; read = AttendancePolicy (admin/teacher-by-department, v1). Status AttendanceStatus {PRESENT,ABSENT,EARLY_EXIT,LATE_ENTRY,FIELD_TRIP,DAY_TRIP}; field rules (time only on transitions, note not on P/A, justification only on absence-types) enforced by pure assertValidStatusFields. Value-change history lives in audit_log (entityType="attendance_record", actions .created/.updated/.justified/.admin_override) — no bespoke history table; cell history is a typed projection over audit rows. Entity ATTENDANCE, field-level register scope, action take; v1 roles admin + teacher. 5 routes: GET /attendance/cohorts (homeroom + grade-group cards), GET /attendance/grid, POST /attendance/take (bulk per lesson), PATCH /attendance/records/:id, GET /attendance/records/:id/history. See specs 2026-06-25-attendance-design.md + plan 2026-06-25-attendance.md.
grades/ Student marks — criteria-derived, teacher-authored, family-visible (v1). GradeEntry (+ GradeCriterionMark children); the subject mark is computed-not-stored as finalOverride ?? avg(criterion marks mapped to the subject scale) via the eval-scale primitives (proposeFinalFromCriteria/retrofitVote) and the (subject, grade) cascade (resolveEffectiveCellScale). Three write modes: per-criterion, directValue (back-fills every criterion), finalOverride (pins). Anchored on SubjectGroup with soft-FK + snapshot columns (attendance shape) → survives SG/curriculum/homeroom reorg; hard FK only tenant/academicYear/student. Value-change history lives in audit_log (entityType="grade_entry", .created/.updated/.deleted/.admin_override) — grades = 2nd consumer. Write authority = the SG's assigned teacher(s) (SubjectGroupTeacher) + admin (in-service, narrower than attendance's Y-set); read = async buildGradeVisibilityWhere (admin all / teacher SGs-taught ∪ homeroom tutees / referent linked / student own). New entity grade_entries (label 'Student Grades' — grades key belongs to the academic grade-LEVEL entity), field-level record scope, record action; fixed enum GradeType {WRITTEN,ORAL,PRACTICAL}; immediate visibility (no publish gate). Routes under /grades: GET /grades/subject-groups (cards) → GET /grades/subject-group/:id (gradebook), GET /grades/homeroom/:id, GET /grades/student/:id, GET /grades/me, POST/PATCH/DELETE /grades, GET /grades/:id/history. See 21 + spec 2026-06-26-grades-criteria-based-v1-design.md + plan 2026-06-26-grades-criteria-based-v1.md.
health/ GET /api/v1/health/ready (DB ping) and GET /api/v1/health/live (process liveness) — public, no auth. GET /api/v1/health is a back-compat alias for /ready used by Railway's healthcheck.
logger/ Pino + CLS request context
config/ Typed env loading
backoffice/ (not under src/) + ServeStaticModule in app.module.ts Internal embedded Vite/React SPA served at /backoffice for editing curriculum + evaluation-scale + subject-level presets of a live tenant. Pure API client (no backend domain code); platform-admin auth; curriculum writes target the active AY. Three tabs: evaluation-scale presets, subject-level presets, curriculum presets (subject level in the ⚙ expander; LEVEL rule scope; JSON import resolves level/levelRef by name/code). 17, docs/superpowers/specs/2026-06-04-backoffice-catalog-editor-design.md

5. Cross-cutting invariants — do not break

Each item is one line. Linked chapter has the full rationale and patterns.

Tenant isolation

  • Every service method that reads or writes a tenanted table must filter by tenantId. docs/02-multitenancy.md.
  • Postgres RLS (ACTIVE, ch02 §4)FORCE tenant-isolation policies on the 8 PII tables; the GUC is set by the per-request tenant transaction (TenantTransactionInterceptor → CLS → PrismaService proxy). Pre-auth single-tenant paths use withTenantGuc; inherently cross-tenant paths use AdminPrismaService (superuser — keep its consumer list minimal). E2E fixture DB access uses getAdminPrisma() (test/helpers/admin-prisma.helper.ts). The base CRUD service still welds tenantId into its read funnel (getScopedWhere, ch02 §4.2).
  • Tenant id comes from the request via @TenantId(), never from the body.
  • Single named exception: EvaluationScale rows with tenantId IS NULL are platform-owned presets visible to every tenant. Reads use the visibleScaleWhere(tenantId) helper (tenantId = :tenantId OR tenantId IS NULL); writes always use the strict tenantId = :tenantId filter. See docs/02-multitenancy.md.

RBAC

  • read is never an action. Visibility is governed by scopes + FieldFilterInterceptor. Actions are create, update, delete, and domain-specific verbs.
  • A route that writes to scoped fields must declare @RequireAction(entity, action); FieldWriteGuard rejects payloads touching fields outside granted scopes.
  • New scopes require a PermissionScope + ScopeFieldMapping seed entry, an array in src/common/constants/scope-fields.ts, and a matching DTO under dto/scopes/. docs/04-rbac.md.

Data access

  • No repository classes. Queries live in <domain>.queries.ts as named functions or include/select constants. Services call them directly or through the base class.
  • Prisma.XGetPayload<{ include: typeof K }> is reserved for contract boundaries (base service TRecord generic, cross-module interfaces/ payloads). Do not sprinkle it on intermediate query results — let inference work. docs/13-typing-conventions.md.
  • Build Prisma data payloads from optional DTO fields with pickDefined (src/common/utils/); for BaseTenantedCrudService subclasses use the inherited flattenDto. Do not accumulate via Record<string, unknown> or hand-rolled if (x !== undefined) chains. docs/13-typing-conventions.md.

Ordered sets (ordinals)

  • Internal ordinals are 0-based: ordinalPosition starts at 0, and a submitted ordered list must be a contiguous 0..N-1 sequence (no gaps, no dups, declared order matters). Enforced by assertSequentialPositions (validation-helpers), reorderInScope, the evaluation-scale value check, and the day-template slot check. Auto-assigned ordinals use (max ?? -1) + 1; index-derived sync uses the array index directly. docs/08-setup-wizard.md.
  • Exempt (different conventions, not internal array ordering): pagination page numbers and import row numbers are 1-based; SubjectGroupTeacher.ordinalPosition (0 = primary teacher) and all sortOrder columns were already 0-based.

DTO contracts

  • Scope sub-DTOs live in dto/scopes/, one file per scope, with Create*Dto / Update*Dto (via PartialType) / *ResponseDto.
  • Response DTO fields must reference real DTO classes. unknown[] and Swagger type: 'object' on a response field are bugs.
  • Swagger decorators live in a sibling <domain>.swagger.ts, not inline on the controller. Scalar autoinspects DTO types and class-validator constraints — description strings that only restate the field name or a validator are noise. See docs/05-crud-patterns.md §5.6.
  • NestJS Swagger autogenerates the OpenAPI surface (operations, request/response schemas, examples) from the controller signature + <domain>.swagger.ts. Treat JSDoc on a controller method as public API copy — it is rendered verbatim into the Swagger UI and consumed by FE. Never put backend-internal commentary (Prisma calls, transaction order, guard semantics, lifecycle hook notes, TODOs) in the JSDoc block of a controller endpoint. Backend-internal notes belong inside the method body as inline comments, or in the service. Same rule for DTO class/field JSDoc: only describe the contract, never the implementation.

Errors

  • Throw AppException(code, message, status, { params, data }). Never throw HttpException directly from domain code.
  • Adding an error code: extend ErrorCode in src/common/constants/error-codes.ts, add a params shape if needed, and add a Swagger example in error-examples.ts. docs/06-error-handling.md.

Audit trail

  • Audit-significant mutations record via AuditService.record(tx, actor, params) inside the caller's transaction — atomic with the mutation, never fail-soft. The actor is snapshotted once per request with resolveActor; audit_log rows are append-only (never updated/deleted) and use soft entityType/entityId references so they survive entity hard-delete. historyFor projects per-cell value history. Attendance is the first consumer. See docs/20-audit-log.md.

Imports and cross-module boundaries

  • Import a module from its folder barrel ('../students'), never from internal files.
  • Do not call a foreign service directly — import the module in AppModule and expose functionality through its public DTO/interface contract.
  • interfaces/ holds Prisma payloads, lookup structures, and service contracts. DTOs stay in dto/.

Migrations

  • Before npx prisma migrate dev: read docs/12-migrations.md. Check for an uncommitted migration and fold if present; audit generated SQL against the hazard checklist. Never commit a migration without the safety audit.

Verification discipline

  • Do not run npm run build, npm test, npm run lint, or any verification command unless the user explicitly asks. User runs these before committing.

6. File index — "if you're touching X, open Y first"

Task-type → minimal file set. Open these before anything else; they give you the shape of the solution.

Task Files to open Chapter
Design a new feature in unexplored space docs/superpowers/templates/new-feature-design.md → fill and check in under docs/superpowers/specs/YYYY-MM-DD-<slug>-design.md CLAUDE.md § Design Gate
Add a field to an existing scope dto/scopes/<scope>.dto.ts, src/common/constants/scope-fields.ts, <domain>.service.ts (getQueryInclude if FK), <domain>.service.spec.ts 11 §1
Manage the student school-relationship flags (US-13) src/students/school-relationships.queries.ts (batched sibling/employee lookups), src/students/school-relationship-people.mapper.ts (buildRelatedPeople), src/students/students.service.ts (enrichSchoolRelationshipPeople live derive + lock override, normalizeForPersistence lock, scope mapping), prisma/schema.prisma (Student.hasSiblingInSchool*/isChildOfEmployee*) docs/superpowers/specs/2026-06-08-student-school-relationship-flags-design.md + docs/superpowers/specs/2026-07-02-school-relationship-flags-ay-create-contract-design.md (R4 live-derive)
Add a new scope to an entity dto/scopes/, dto/create-<domain>.dto.ts, dto/update-<domain>.dto.ts, dto/<domain>-response.dto.ts, scope-fields.ts, <domain>.service.ts (getScopeFieldMappings), prisma/seed/rbac-catalogue.ts, prisma/seed/roles.ts 11 §2, 04
Add a new domain entity prisma/schema.prisma, new src/<domain>/ folder (module/controller/service/queries/swagger/dto/interfaces/index), src/common/constants/entity-keys.ts, src/app.module.ts, prisma/seed/rbac-catalogue.ts 11 §3, 05
Add an endpoint to an existing module <domain>.controller.ts, <domain>.swagger.ts, <domain>.service.ts, <domain>.queries.ts if new query 05
Add a list endpoint with filters/sort dto/list-<domain>-query.dto.ts (extends PaginatedListQueryDto or BasicPaginatedListQueryDto), <domain>.queries.ts (buildListArgsForX), <domain>.controller.ts, <domain>.service.ts, src/common/utils/transform-to-array.ts for multi-value 05 §List endpoint query DTOs
Upload / mint signed URL / list / edit metadata on a file src/files/files.service.ts (helpers: replaceFileSlot / appendCollectionFile / getSignedUrlByOwner / deleteFileById / listFilesForEntity / cleanupCollectionForOwner / updateFileMetadataById), src/files/storage/file-storage.port.ts (getSignedReadUrl), src/<entity>/<entity>.service.ts (the uploadDocument / getDocumentUrl / deleteDocument / *ById / listDocuments / updateDocumentMetadata methods), src/<entity>/<entity>.controller.ts, prisma/schema.prisma (File model with documentNumber + expiryDate for single-slot + per-entity FK + ownerType/ownerId) 05 §11
Add an error code src/common/constants/error-codes.ts, error-examples.ts, optional params shape in error-codes.ts, throw site 06
Touch auth flow src/auth/auth.service.ts, src/auth/cookie-helper.service.ts, src/auth/strategies/, src/auth/guards/ 03
Touch active profile / login state machine src/auth/auth.service.ts, src/auth/auth.controller.ts, src/auth/dto/select-profile.dto.ts, src/auth/dto/switch-profile.dto.ts, src/auth/dto/profile-selection-response.dto.ts, src/common/constants/person-profiles.ts 03, 04
Touch the role-narrowing rule src/common/utils/narrow-roles.ts, src/common/constants/person-profiles.ts 04, 03
Touch RBAC enforcement src/permissions/guards/, src/permissions/interceptors/field-filter.interceptor.ts, src/permissions/decorators/, src/permissions/permissions.service.ts 04
Add or modify record-level access for an entity src/<domain>/<entity>.policy.ts (role allowlist + WHERE builder via definePolicy), src/<domain>/<entity>.policy.spec.ts (per-branch + platform-admin + sentinel coverage), src/<domain>/<domain>.controller.ts (@AppliesPolicy), src/<domain>/<domain>.service.ts (Policy.where(ctx)) 04, 11 §13
Add a field to the completion-required list src/common/constants/completion-required-fields.ts 04 §Profile completeness
Add a setup step src/setup/ (state machine + handler), src/setup/handlers/, relevant domain service 08
Manage a department's calendar (dates + period set) src/departments/departments.service.ts (create calendar + initial periods, updateForAccessContext date+period gate, normalizeForPersistence omit + afterUpdate period sync, toDetailResponse calendar block — assembled inline, bulkSync calendar), src/departments/department-periods.{service,controller,swagger}.ts (period sub-resource), src/departments/dto/scopes/department-configuration.dto.ts (mandatory date fields + bundled terms/closingPeriods arrays + calendar block DTO), src/common/utils/periods.ts (shared validatePeriods / mergePeriodSets / syncPeriods — the period typing/merge + write+diff helpers used by AY + Dept CRUD and wizard), src/common/constants/scope-fields.ts (DEPARTMENT_SCOPES.configuration), prisma/schema.prisma (Department.calendarStartDate/EndDate, Period.departmentId) 05, 12, docs/superpowers/specs/2026-06-04-department-scoped-academic-calendar-design.md (canonical)
Add or modify a grading scale src/evaluation-scales/..., prisma/seed/evaluation-scale-presets.ts (platform presets) 05, docs/superpowers/specs/2026-05-18-evaluation-scales-design.md
Add or modify a subject level (IB HL/SL) src/subject-levels/... (gated by singular @RequireScope(CURRICULA,'configuration',…) — NO own scope), prisma/seed/subject-level-presets.ts (platform presets), src/common/constants/error-codes.ts + error-examples.ts (SUBJECT_LEVEL_*). To enforce a level constraint also touch src/curriculum/selection-consistency.ts (the generic CurriculumRule engine) and src/curriculum/curriculum.validation.ts (LEVEL-rule shape) 05, docs/superpowers/specs/2026-06-08-ib-subject-levels-design.md (canonical)
Add or modify a day/week template or its dept/grade assignment src/timetable-templates/... (one module, three controllers, single service + queries), prisma/schema.prisma (DayTemplate, TimeSlot, WeekTemplate, WeekTemplateDay, DepartmentWeekTemplateAssignment, GradeWeekTemplateAssignment, SlotType/DayOfWeek enums), prisma/seed/rbac-catalogue.ts (timetable_templates.configuration scope + create/delete/assign actions) 05, docs/superpowers/specs/2026-05-25-timetable-templates-design.md (canonical)
Place a lesson / add a timetable diagnostics check / change the publish gate src/timetables/timetables.diagnostics.ts (pure engine — add a check* fn + unit test in timetables.diagnostics.spec.ts), src/timetables/timetables.queries.ts (buildDiagnosticsSnapshot + effective-template/window/response loaders — thread any new snapshot field here), src/timetables/timetables.service.ts (structural floor assertResolvableSlot, mutateLessonAndDiagnose live-edit, setStatus publish gate, getLessonsView), src/timetables/timetables.policy.ts, prisma/schema.prisma (Timetable, ScheduledLesson, TimetableStatus), src/common/constants/{entity-keys,scope-fields,error-codes,error-examples}.ts + src/permissions/interfaces/decorators.interfaces.ts (publish action verb) + prisma/seed/rbac-catalogue.ts (timetables.configuration scope + create/delete/publish/generate actions) 18, docs/superpowers/specs/2026-06-08-timetable-manual-management-design.md + 2026-06-11-…-iteration-2-design.md (canonical)
Trigger / debug automatic timetable generation src/timetables/generation/generation.queries.ts (buildGenerationSnapshot: segment computation, groups, cohorts, clash pairs), src/timetables/generation/generation.prechecks.ts (runPreChecks: 7 pure pre-check categories), src/timetables/generation/solver.port.ts (SolverPort + HttpSolverTransport + FakeSolverTransport + toSolverRequest), src/timetables/generation/generation.service.ts (pipeline: snapshot → pre-checks → solver → ingest → verify), solver/app/model.py (CP-SAT vars + hard families), solver/app/objective.py (soft terms), solver/app/solve.py (budget split + unsat-core minimization) 18 §8, docs/superpowers/specs/2026-06-12-timetable-generation-design.md (canonical), solver/README.md
Add or modify curriculum structure (subjects / option blocks / tracks / hour cells / subject levels / rules) src/curriculum/curriculum-structure-sync.ts (family-sync diff engine, id-or-key identity via src/common/utils/sync-entities.ts syncEntitiesByIdOrKey; persists subject + rule levelId), src/curriculum/curriculum.validation.ts (pre-tx grid validation incl. LEVEL-rule shape), src/curriculum/dto/scopes/curriculum-configuration.dto.ts (grid DTOs; optional row id in, levelId in/level out), src/curriculum/curriculum.queries.ts (grid include/shape; level projection), src/curriculum/selection-consistency.ts (diff-based post-mutate sweep: captureSelectionPreImages → committed-substance diff → demote on chosen-level swap / notify-only on mandatory changes; + generic CurriculumRule engine ENTIRE_PLAN/LEVEL), src/subject-levels/ (assertLevelsVisible on the write paths), prisma/schema.prisma (CurriculumSubject.levelId, CurriculumSubjectHours, OptionBlock, CurriculumTrack, CurriculumRule.levelId, RuleScopeType.LEVEL) 14, docs/superpowers/specs/2026-06-03-curriculum-grid-remodel-design.md + docs/superpowers/specs/2026-06-08-ib-subject-levels-design.md (levels/rules) + docs/superpowers/specs/2026-06-16-curriculum-edit-selection-invalidation-design.md (rename-safety + invalidation)
Add or modify per-grade criteria on a curriculum subject src/curriculum/curriculum-structure-sync.ts (criteria ride the grid family-sync — no separate controller), src/curriculum/dto/scopes/curriculum-configuration.dto.ts (cell criteria/criteriaGradingScaleId + default on the subject), src/curriculum/curriculum.queries.ts (per-grade criteria include + cell criteria-scale select), prisma/schema.prisma (CurriculumSubjectCriterion keyed (subjectId, gradeId) with gradeId NULL = default; CurriculumSubjectHours.criteriaGradingScaleId) 14, docs/superpowers/specs/2026-06-05-evaluation-criteria-iteration-2-design.md (per-grade, canonical) + docs/superpowers/specs/2026-05-18-evaluation-criteria-design.md (original substrate)
Manage a curriculum selection window src/curriculum/selection-window.*, src/curriculum/dto/open-selection-window.dto.ts / update-selection-window.dto.ts / selection-window-response.dto.ts / selection-window-list-response.dto.ts / selection-window-prerequisites.dto.ts / selection-window-query.dto.ts, prisma/schema.prisma (CurriculumSelectionWindow per (academicYearId, departmentId), CurriculumStatus), prisma/seed/rbac-catalogue.ts (curricula.selection_window scope + open_selection_window / edit_selection_window actions) 05, docs/superpowers/plans/2026-05-20-us32-v2-window-per-department.md (canonical)
Switch a curriculum's status (offer / un-offer) src/curriculum/curriculum.controller.ts (PATCH :id/status), src/curriculum/curriculum.service.ts (switchCurriculumStatus — offer is a pure flip, no SG seeding; + the shared throwIfSubjectGroupInUse P2003 belt), src/curriculum/dto/switch-curriculum-status.dto.ts + dto/curriculum-status-response.dto.ts, src/curriculum/curriculum-structure-sync.ts (guardAndClearSubjectGroupsForCurriculum demote clear) docs/superpowers/specs/2026-06-16-curriculum-status-switch-endpoint-design.md
Add or modify the curriculum-selections command-center tab (US-31) src/command-center/curriculum-selections.* (controller / service / queries / swagger / specs), src/command-center/dto/curriculum-selection-item.dto.ts / curriculum-selection-response.dto.ts / curriculum-selection-query.dto.ts, src/command-center/shared/invitation-status.ts + shared/invitations.queries.ts (shared invitation projection + batch-loader, also consumed by the onboarding tab), src/curriculum/selection-window.service.ts (deriveSelectionWindowStatus shared pure helper), src/curriculum/index.ts (re-export) 05, docs/superpowers/specs/2026-05-05-command-center-design.md (parent spec; tab 3 was deferred there and re-scoped on US-32 v1 + RUS-4)
Add or modify the admin next-steps to-do src/command-center/next-steps/ (next-steps.constants.ts registry — NextStepKey/NextStepCta/NEXT_STEP_REGISTRY; next-steps.queries.ts = hasCurriculumChoices applicability gate + NextStepCompletion CRUD (findCompletions/upsert/delete); next-steps.service.ts maps the registry to completed-flagged cards straight from NextStepCompletionno done-inference (done-heuristics removed 2026-07-02); next-steps.controller.ts GET + :key/complete POST/DELETE; next-steps.policy.ts admin allowlist; dto/next-steps-response.dto.ts — card = { key, completed, optional, blockedBy, order, cta }), src/command-center/command-center.module.ts (registration), src/command-center/overview.service.ts (embeds the same payload verbatim via getNextStepsForYear — a DTO change here also changes overview.nextSteps), prisma/schema.prisma (NextStepCompletion), src/common/constants/error-codes.ts (NEXT_STEP_TASK_UNKNOWN). The optional RESOLVE_STUDENT_CHOICES card is still omitted unless curricula carry option-block/track choices (hasCurriculumChoices — applicability, not done-ness). docs/superpowers/specs/2026-07-02-next-steps-remove-heuristics-design.md (done-heuristics rollback — current), docs/superpowers/specs/2026-06-25-setup-curriculum-removal-and-admin-next-steps-design.md, docs/superpowers/specs/2026-07-01-next-steps-invite-referents-students-design.md (historic; its coverage heuristic was removed), 08
Add or modify a Homeroom (US-33) or a Subject Group (US-33.1) src/homerooms/*, src/subject-groups/*, the grouped counts boards src/homerooms/grouped-homerooms.controller.ts + src/subject-groups/grouped-courses.controller.ts (GET /homerooms/grouped-homerooms + GET /subject-groups/grouped-courses — catalog-driven list-view counts trees, skeleton via findBoardDepartments + findGroupedBoardCurricula), the restored server-bucketed candidate pickers src/homerooms/eligible-students.controller.ts + src/subject-groups/eligible-students.controller.ts (GET /students/eligible-for-{homeroom,subject-group}, 2026-06-16), src/students/dto/student-summary.dto.ts (canonical roster row), the single-source eligibility classifiers src/homerooms/homeroom-eligibility.ts + src/subject-groups/subject-group-eligibility.ts (one classify<Entity>Eligibility(row,target) per entity, shared by the write-gate and the picker — WRONG_AY/WRONG_CURRICULUM/WRONG_GRADE/WRONG_TRACK/WRONG_OPTION_BLOCK_CHOICE/OPTION_BLOCK_FULL, allowPending), src/students/class-selection-sync.ts (applyClassAssignmentToSelection forward sync on every roster write; in-block maxSelections capacity → OPTION_BLOCK_FULL/WRONG_OPTION_BLOCK_CHOICE is gated in subject-groups.service.ts), src/curriculum/resolve-target-plan.ts (grouped-courses missing-assignment denominator, reuses selection-consistency.ts predicates) + src/curriculum/count-min-required-subject-groups.ts (static SG-creation floor), src/teachers/declared-subjects.helper.ts (shared "record this choice" side-effect), prisma/schema.prisma (Homeroom, HomeroomAssignment, SubjectGroup, SubjectGroupAssignment), prisma/seed/rbac-catalogue.ts (homerooms.composition + subject_groups.composition scopes + create/delete actions) 14, docs/superpowers/specs/2026-05-25-us33-us33_1-design.md + docs/superpowers/specs/2026-07-02-grouped-boards-iteration-2-design.md (grouped counts boards) + docs/superpowers/specs/2026-07-03-grouped-courses-iteration-3-design.md (grade level + per-subject coverage)
Manage a student's guardians (caregiver contacts) src/students/guardians.{controller,service,queries,swagger,policy}.ts (+ specs), src/students/dto/guardian.dto.ts, src/students/guardian-summary.mapper.ts, src/students/dto/scopes/student-referents-and-guardians.dto.ts (embedded read group), src/students/dto/create-student.dto.ts (inline referents_and_guardians.guardians[]), prisma/schema.prisma (Guardian + GuardianRelationship), src/common/constants/scope-fields.ts + prisma/seed/rbac-catalogue.ts + prisma/seed/roles.ts (the referents_and_guardians scope) 05, docs/superpowers/specs/2026-06-08-guardians-design.md (canonical)
Manage a student's curriculum selection (RUS-4) src/students/curriculum-selection.* (controller / service / queries / swagger / specs), src/students/bulk-curriculum-assignment.{service,swagger}.ts (+ spec) + src/students/dto/bulk-assign-curriculum*.dto.ts (admin bulk-assign POST /students/curriculum-selections/bulk-assign; route lives on CurriculumSelectionController; queries in curriculum-selection.queries.ts), src/students/dto/write-selection.dto.ts + src/students/dto/selection-read-response.dto.ts, prisma/schema.prisma (StudentCurriculumSelection singleton per Student; StudentOptionBlockChoice children referencing in-block CurriculumSubject; SelectionEditorRole enum), src/curriculum/curriculum.service.ts US-29 in-use guard at updateCurriculum/removeCurriculum (lock-backed) + src/curriculum/selection-consistency.ts post-mutate sweep wired through the grid family-sync, src/curriculum/selection-window.service.ts findWindowAndStatus public helper, src/curriculum/selection-state.ts (deriveSelectionStatus — shared NOT_STARTED/IN_PROGRESS/COMPLETE from isComplete), prisma/schema.prisma (StudentCurriculumSelection.isComplete — completeness materialized, split from the confirmedAt lock), src/common/utils/assert-referent-can-write.ts (lifted helper), src/students/class-selection-sync.ts (pruneIncompatibleClassMemberships — the backward prune runs inside the PATCH $transaction, surfaced as removedMemberships on the response) 05, docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md + docs/superpowers/specs/2026-06-12-student-read-surface-dedup-design.md (isComplete split) + docs/superpowers/plans/2026-05-20-rus4-curriculum-selection.md (canonical)
Add or modify a preset / custom parametric role prisma/seed/roles.ts (per-tenant preset upsert + permission-rule sets — pass parameterDim on the identity payload for parametric roles), prisma/seed/helpers/seed-role.ts (forwards parameterDim into the upsert), prisma/seed/rbac-catalogue.ts (scopes + actions consumed by each preset), prisma/schema.prisma (ParameterDim enum + Role.parameterDim + UserRoleParameter(userRoleId, valueId) polymorphic join), src/common/constants/parameter-dim.ts (ParameterDim mirror + DIMENSION_TARGETS config), src/permissions/permissions.queries.ts (fetchParametricAssignments + fetchTeacherDepartmentIds), src/permissions/permissions.service.ts (getAccessContextSlice), src/common/interfaces/record-access-context.interface.ts (ParametricContext, parameters, teacherDepartmentIds, parameterDimensions), src/common/utils/access-context-helpers.ts (ParametricBranch, OR + pass-through short-circuit resolver), src/common/utils/entity-access-policy.ts (definePolicy.parametricBranches, policy.parametricDimensions), src/permissions/guards/roles.guard.ts (parametric-dimension overlap admission), per-entity <entity>.policy.ts files under src/<module>/, docs/04-rbac.md §Preset Role Permissions Matrix + §Cross-Entity Preset Role Matrix. Two dimensions exist: DEPARTMENT (department_head) and CURRICULUM (curriculum_coordinator, 2026-07-02 — also split hr into personnel-only hr + office secretary). Grant-set changes must update the expectedPresetGrants mirror (prisma/seed/helpers/); grant removals need a hand DELETE migration (seed upserts never delete) 04, docs/superpowers/specs/2026-05-27-parametric-roles-design.md (canonical — supersedes 2026-05-25-preset-management-roles-dept-scoped-assignment-design.md for the storage + runtime shape) + docs/superpowers/specs/2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md (second dimension + split)
Inspect or extend the roles catalogue / entity groups src/permissions/roles.controller.ts, src/permissions/roles.swagger.ts, src/permissions/dto/roles-response.dto.ts, PermissionsService.getTenantRolesCatalog, src/permissions/permissions.queries.ts (fetchTenantRoles), src/common/constants/entity-groups.ts (+ spec) 04 §"Two read surfaces — GET /permissions vs GET /roles" (canonical)
Assign / revoke a management role to a Teacher/Staff person (UserRole, person-anchored, materialized at accept) src/permissions/role-assignments.{controller,service,queries,mapper,swagger}.ts, src/permissions/dto/{assign-roles,update-role-assignment,list-role-assignments-query,role-assignment-response}.dto.ts, src/common/constants/person-profiles.ts (PROFILE_COUPLED_ROLE_KEYS), src/invitations/invitations.service.ts (acceptToken materialization + invalidateByRecipient cleanup) 04, docs/superpowers/specs/2026-07-01-role-preassignment-on-person-design.md (iter 4), docs/superpowers/specs/2026-06-08-role-assignment-design.md (iter 1)
Edit a role's scope/action grants src/permissions/role-grants.{service,queries,coherence}.ts, src/permissions/roles.controller.ts (PATCH /roles/:key), src/permissions/dto/patch-role-grants.dto.ts, PermissionsService.buildRoleCatalogItem 04, docs/superpowers/specs/2026-06-09-role-grant-editing-design.md
Edit global role presets (backoffice) / provision a tenant's roles src/permissions/roles.controller.ts (GET/PATCH /roles/presets, @PlatformAdminOnly), src/permissions/role-clone.ts (cloneRolesIntoTenant + additive-only backfillMissingPresetRoles for tenants that predate a preset), PermissionsService.getGlobalPresetRolesCatalog, src/permissions/role-grants.queries.ts (findGlobalPresetRoleWithGrants, fetchGlobalPresetRoles), prisma/seed/roles.ts (seedGlobalRolePresets) 04 §"Global preset roles", docs/superpowers/specs/2026-06-09-backoffice-role-preset-editing-design.md
Add a custom field type src/custom-fields/ 05
Build an import flow src/common/services/import-pipeline.ts, <domain>.service.ts (getImportPreviewConfig), <domain> import spec. Non-person import (rooms): src/rooms/constants/ (descriptors + row-map), src/rooms/dto/rooms-import-summary.dto.ts, src/rooms/rooms.queries.ts (import loaders), src/rooms/rooms.service.ts (import methods keyed on name); the shared generalizations live in import-summary.dto.ts (ImportSummaryDtoOf 3rd param), import-duplicate-checker.ts (ClassifyConfig.rowKeyOf/recordKeyOf), base-tenanted-crud.service.ts (optional completionField) 07 (incl. §8)
Record or read a student grade src/grades/* (grades.compute.ts finalOverride ?? avg + resolveWriteMarks; grades.scale-context.ts cascade + validators; grades.visibility.ts read where-builder; grades.service.ts record/update/remove/reads/history; grades.queries.ts; grades.controller.ts/grades.swagger.ts), prisma/schema.prisma (GradeEntry/GradeCriterionMark/GradeType), entity-key GRADE_ENTRIES='grade_entries' + scope-fields.ts GRADE_ENTRY_SCOPES + rbac-catalogue.ts/roles.ts + decorators.interfaces.ts ACTION_NAMES (record) + entity-groups.ts + the rbac-catalogue.drift.spec.ts mirror 21
Run or write a migration prisma/schema.prisma, prisma/migrations/ 12 required read before migrating
Write a test <target>.spec.ts, src/common/testing/ helpers 09
Deploy or CI change .github/, Railway config, env handling in src/config/ 10
Size a refactor / decide if a task is on-axis / answer a stack-change question docs/16-maintainability.md (§3 pressure points · §4 on-axis recipes · §5 off-axis seams · §6 stack-change matrix) 16

7. Glossary — project-specific terms

Short definitions. Where a term maps to a chapter, that chapter is authoritative.

  • Tenant — a school instance. Every business row has tenantId. Not a user.
  • Entity — a protected resource key (STUDENTS, TEACHERS, ...). Defined in src/common/constants/entity-keys.ts.
  • Scope — a named subset of fields on an entity (e.g. identity, health). Grants visibility and write access to those fields.
  • Action — a named operation on an entity (e.g. create, delete, import). Never read.
  • Identity — basic person data (name, DoB, ID). Renamed from the prior Italian convention anagraphic (2026-05-18).
  • Health scope — medical and dietary information. Renamed from sensitive (2026-05-18) on Student/Teacher/Staff/Referent.
  • Assignment scope — Student department/grade placement. Renamed from enrollment (2026-05-18).
  • School relationships scope — Student hasSiblingInSchool/isChildOfEmployee flags (US-13). Derived live at read (R4 — not materialized) from MOTHER/FATHER referent links in the same AY (Guardians excluded) + a Teacher/Staff tax-code/email match; a per-flag *Locked admin-pin bit overrides the derive (the stored column is returned verbatim only when locked — otherwise it is dead data). No write-triggered recompute — the flag value and the read-only relatedPeople list ({relation: sibling|parent_employee, type: student|teacher|staff, id, firstName, lastName}, the people behind the flags) are computed together, batched, list + detail via enrichSchoolRelationshipPeople, so a non-locked flag can never drift from current data. See docs/superpowers/specs/2026-06-22-student-school-relationship-flags-iteration-2-design.md + docs/superpowers/specs/2026-07-02-school-relationship-flags-ay-create-contract-design.md (R4).
  • Person profile / Role distinction — a profile (PersonProfileKey) identifies which person-entity row a user is acting as in a session (teacher, staff, referent, student). A role is a named RBAC key (e.g. CLASS_TEACHER, HEAD_OF_YEAR) granting specific permissions. Profiles and roles are orthogonal: employee profiles (teacher/staff) carry the full role set; non-RBAC profiles (referent/student) receive only [activeProfile] as their effective roles for that session. See src/common/utils/narrow-roles.ts and docs/04-rbac.md.
  • Referent — parent/guardian attached to a student. Separate entity; names nullable, populated post-login. Intentionally not academic-year-scoped (flat per (tenantId, email)); year context comes via StudentReferentLink → Student.academicYearId. See docs/01-architecture.md §6.
  • Guardian — a lightweight, non-login caregiver contact (GuardianRelationship = NANNY / AUNT / GRANDFATHER / GRANDMOTHER / UNCLE / OTHER) owned by exactly one student via a direct studentId FK. No User, no Invitation, no platform access — distinct from Referent. Gated by the student referents_and_guardians scope (not its own RBAC entity); managed via the /students/:id/guardians sub-resource. Write = admin + linked-referent (canWrite); read = every student-viewer. Added 2026-06-08 (docs/superpowers/specs/2026-06-08-guardians-design.md).
  • referents_and_guardians scope — relational student scope (renamed from referents on 2026-06-08). Zero native columns; the read group bundles { items: referent links, guardians: Guardian rows, missingFields } and the WRITE grant gates the guardians sub-resource (referent writes additionally canWrite-gated). The student create body nests both arrays under this key (referents_and_guardians: { referents[], guardians? }) because FieldWriteGuard requires top-level body keys to be scope names.
  • Academic year — top-level time partition; most student/teacher/staff data is year-scoped (Referent is the notable exception — see above). The year carries no calendar of its own — start/end dates and the period set live per-department (Department.calendarStartDate/calendarEndDate + Period.departmentId, all mandatory). It remains the tenant-wide partition every year-scoped row FKs to, plus name/status/gracePeriodEnding/the active-year-unique guard/rollover. gracePeriodEnding stays a single AY-level date (never per-department), set explicitly (no derivation).
  • Period — a Period row belongs to exactly one department (Period.departmentId, mandatory) and carries academicYearId denormalized for year-scoped queries (Grade precedent). Each department owns its own terms/closing periods; name-uniqueness is the declarative @@unique([departmentId, name]). A department's calendar (its dates + period set) is read on GET /departments/:id (configuration.calendar) and written via PATCH /departments/:id + the /departments/:id/periods sub-resource. There is no AY-level period set and no /academic-years/:id/periods CRUD.
  • Active profile — the profile key a user has selected for the current session. Typed as ActiveProfileKey = PersonProfileKey | 'platform': the four person profiles (teacher, staff, referent, student) plus the synthetic 'platform' for vendor/superadmin sessions. Stored as activeProfile on the JWT payload and on the RefreshToken row; consumed by narrowRolesForActiveProfile to restrict roles[] to only the permissions relevant to that profile. Set at login (or via POST /auth/switch-profile) and immutable until explicitly switched or the session ends. See docs/03-auth.md §9.
  • Study planhistorical alias (removed 2026-06-04 by the curriculum grid remodel). What used to be a per-grade StudyPlan row is now just a grade column of the curriculum grid: a curriculum owns its subjects/blocks/tracks once, and each subject's presence-in-a-grade is an Hours cell. No StudyPlan* models, no /curricula/:id/study-plans routes. See docs/14-homerooms-subject-groups.md and the grid remodel spec.
  • Curriculum grid — the matrix the FE renders: subject rows × grade columns. A CurriculumSubject is owned by the Curriculum (curriculum-level, not per-grade); an OptionBlock/CurriculumTrack likewise. Whether a subject is offered in a grade is the presence of an Hours cell. Structure is written through one family-sync PATCH /curricula/:id. Canonical: src/curriculum/curriculum-structure-sync.ts. A subject row also carries an optional free-text code (CurriculumSubject.code, nullable, no uniqueness, not a sync identity — rows still match id → name) for external/institutional references (e.g. Ministero); the same field rides through preset definitions (PresetGridDto reuses CurriculumSubjectInputDto) so the backoffice can author it on presets and expandPreset carries it onto the tenant grid.
  • Hours cellCurriculumSubjectHours row, one per (subject, grade) the subject is offered in (@@unique([subjectId, gradeId])). Row presence = offered (the FE renders "—" where there is no cell); carries weeklyHours and an optional per-(subject, grade) gradingScaleId override. The teacher hour budget and SG eligibility resolve hours against the SG's gradeId cell.
  • Track (indirizzo)CurriculumTrack: a named sub-curriculum (e.g. "Informatics") inside one Curriculum. Containment, not M2M — a CurriculumSubject/OptionBlock has a nullable trackId (one track, or null = common to all tracks). Renamed from the superseded CurriculumAddress design before execution. A Homeroom binds exactly one track when the curriculum has tracks; its child SGs and roster are scoped to it.
  • Selection mode / carried choiceCurriculum.trackSelectionMode (PER_GRADE default) governs how a student's track/choices carry across the grade hop; carryKey is the autogenerated immutable cross-year identity on grid entities (rename = delete+create = fresh key). A StudentOptionBlockChoice.carried = true (or StudentCurriculumSelection.trackCarried) marks a pick carried forward by the (dormant) ONCE carry executor — locked for referents (SELECTION_CHOICE_LOCKED), admin can override.
  • Setup wizard — one-shot flow that initializes a tenant: school identity → academic year → departments/grades/rooms → people import → invitations (send credentials to imported teachers + staff). Curricula are NOT a setup step — they are built post-setup on the live /curricula surface by delegated dept-heads/principals, driven by the command-center next-steps to-do. State machine under src/setup/.
  • Scope field mapping — runtime map from scope name → fields present on the Prisma record. Used by FieldFilterInterceptor and FieldWriteGuard.
  • Canonical example — a file path called out in docs or this reference as the authoritative implementation of a pattern. Read it; do not duplicate it.
  • Invitation — credential-issuance state for a Teacher/Staff/Referent/Student. One row per invitable role entity; NOT_SENT is absence-of-row at the projection layer, not a stored enum. STUDENT is the only conditionally invitable type: the live Department.studentPlatformAccess gate runs at send/resend/verify/accept, and flipping the flag OFF cascade-deletes the department's pending (non-ACCESSED) STUDENT rows in the flag-write transaction; department reassignment is gate-only (rows survive, tokens die at accept). Accept binds the profile-coupled student role (mirrors referent). See the invitations design doc + 2026-06-03-student-invitations-design.md.
  • Curriculum statusDRAFT (admin-editable, hidden from referents) or READY (referent-eligible during an open selection window). Lives on Curriculum.status; admin flips via the dedicated PATCH /curricula/:id/status (the grid PATCH /curricula/:id no longer accepts status). Demoting READY → DRAFT while confirmed selections reference the curriculum is blocked with CURRICULUM_IN_USE_BY_SELECTIONS (lock-backed FOR UPDATE count-then-mutate, shared with delete); structural edits run the consistency sweep rather than a status-transition hook.
  • Curriculum selection windowCurriculumSelectionWindow row scoped to (AcademicYear, Department) (composite UNIQUE). Admin opens with a startDate; end date is always AcademicYear.gracePeriodEnding (shared across departments, never stored, never accepted on input). Lifecycle status (NOT_OPENED / SCHEDULED / OPEN / CLOSED) is computed live from (row?, today, gracePeriodEnding). Each department gets one window per AY; different departments can run independent windows on independent schedules. To revive multi-round later: drop the composite UNIQUE and add a roundType enum. See docs/superpowers/plans/2026-05-20-us32-v2-window-per-department.md (canonical) and 2026-05-19-us32-selection-window.md (v1, predates the per-department refactor).
  • Student curriculum selection (RUS-4) — StudentCurriculumSelection singleton row per Student (@unique studentId) carrying curriculumId + optional trackId/trackCarried + isComplete (materialized structural completeness) + confirmedAt (referent lock) + lastEditedBy ∈ {REFERENT, ADMIN} + lastEditedByUserId (SetNull). Children StudentOptionBlockChoice rows hold the picks — each {optionBlockId, curriculumSubjectId} referencing an in-block CurriculumSubject (@@unique([selectionId, curriculumSubjectId]); both FKs ON DELETE CASCADE). Materializes lazily on first PATCH. Completeness vs lock are split (2026-06-12): isComplete = zero findSelectionInconsistencies (the display/count source of truth, read everywhere); confirmedAt = the referent lock, stamped only when a write completes the selection and demoted on break — admin-partial / auto-sync-partial leave both false/null. Status (NOT_STARTED / IN_PROGRESS / COMPLETE) derives from isComplete via the shared deriveSelectionStatus (src/curriculum/selection-state.ts). PATCH-as-upsert — no POST. Referent writes gated by the OPEN window AND StudentReferentLink.canWrite = true AND carried-choice locks; admin bypasses the window + canWrite gates via JWT role. The read tree is grade-sliced for the student's Student.gradeId (applicableCurricula[i] = {subjects, optionBlocks, tracks, rules} — no studyPlan wrapper); rules are info-only transport. Canonical: docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md + docs/superpowers/specs/2026-06-12-student-read-surface-dedup-design.md (isComplete split).
  • Selection consistency sweep (RUS-4, grid remodel) — replaces the old snapshot-then-mutate cascade. Structural curriculum edits already cascade-wipe choice rows referencing deleted structure (Postgres ON DELETE CASCADE); revalidateConfirmedSelectionsForCurriculum then runs after the structure sync in the same $transaction, replays every selection for the curriculum against the post-edit structure, and (a) maintains isComplete bidirectionally — promoting a now-complete partial and demoting a broken one — and (b) demotes confirmedAt = null only on previously-confirmed selections that broke (the lock is demote-only; a loosening edit never re-locks). Wired once per mutation path (PATCH / demote / delete). Canonical: src/curriculum/selection-consistency.ts.
  • Class ↔ selection sync (US-33, 2026-06-11) — the other sync direction (don't confuse with the consistency sweep above): bidirectional reconciliation between class memberships (Homeroom / SubjectGroup rosters) and the authoritative StudentCurriculumSelection. Forward (applyClassAssignmentToSelection) — every roster write (wizard create, POST :id/students, move-in) fills any absent curriculum/track/in-block choice on the assigned student's selection (append-only, never overwrites; an in-block choice is appended per alternative so a multi-pick block accumulates up to its maxSelections), then recomputes isComplete/confirmedAt. Backward (pruneIncompatibleClassMemberships) — every selection PATCH drops now-incompatible memberships (a mismatched-curriculum/track Homeroom + its child SGs, mandatory/in-block standalone SGs no longer chosen), returned to the caller as removedMemberships. Both run inside the existing roster/selection $transaction (required side-effects, not fail-soft like the declared-subjects one). Canonical: src/students/class-selection-sync.ts, docs/14-homerooms-subject-groups.md §6.1.

8. Where to go deeper

Open the specific chapter only when your task lands in its area. The table below is the same as docs/README.md but re-sorted by task frequency.

You are about to... Chapter
Build or modify any CRUD feature 05 - CRUD Patterns
Add or change permissions 04 - Permissions
Follow a recipe step-by-step 11 - Workflows
Type a Prisma result, build a data payload, or fight any 13 - Typing Conventions
Throw or handle an error 06 - Error Handling
Run a migration 12 - Migrations
Understand seed modes or fixtures 15 - Seeding
Write tests 09 - Testing
Touch auth 03 - Authentication
Touch multitenancy 02 - Multitenancy
Build an import flow 07 - Import Pipeline
Add a setup step 08 - Setup Wizard
Understand system design 01 - Architecture
Set up locally / onboard 00 - Getting Started
Work on infra/CI/deploy 10 - Infrastructure
Audit codebase health / size a refactor / answer "how hard is X to change" 16 - Maintainability
Take or read the attendance register 19 - Attendance Register
Record or read a student grade 21 - Grades
Record or read the who-did-what audit trail 20 - Audit Log

9. Meta — how to keep this file useful

  • Update this file whenever a new module is added, a cross-cutting invariant changes, or a file path in section 6 moves. It is a map; stale maps mislead.
  • Never copy prose from a chapter into this file. If a section needs more than two sentences of explanation, it belongs in a chapter, and this file points to it.
  • Keep under ~500 lines. If it grows past that, the entries are too detailed — compress them back into pointers.