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.

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 every tenant-bearing table (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. Required-tenantId models are additionally mirrored in src/prisma/tenanted-models.ts (STRICTLY_TENANTED_MODELS — strict-tenancy drift oracle + misroute tripwire; tenanted-models.drift.spec.ts fails on any schema mismatch). 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.
  • Qualified role slices — sensitive selection, placement, and attendance fences bind scope/action authority to the active assignment and its own parameters via PermissionsService.resolveAuthorizedRoleSlice; never combine unioned grants with flattened ids on these paths. 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.

The auth/ module also owns the password-reset/ satellite: public recovery commands plus the durable credential-email sender. See chapter 03 §11.

| Module | Responsibility | Notes | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | auth/ | Login (3-step state machine: credentials → tenant → view; chooser fires on ≥2 views, picks land narrowed), JWT issue/refresh, logout, /me, cookie delivery, activeProfile selection/switching + session view switch (/auth/switch-view, role-level narrowing) | Passport strategies in strategies/; cookie helper owns Set-Cookie semantics. docs/03-auth.md (§9 login chooser, §10 views). | | 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 platform-admin-only preset grant-editing: PATCH /roles/presets/:key (the tenant-facing PATCH /roles/:key was REMOVED 2026-08-12 — the permission matrix is read-only for schools; RoleGrantsService keeps the tenant target for a future re-enable) | 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 "Editing role grants — tenant surface REMOVED"). | | prisma/ | PrismaService wrapper, module export | Do not import PrismaClient directly from generated/. | | post-commit/ | PostCommitCoordinator — the interceptor-owned, CLS-scoped "run after the request's transaction commits" seam. Request-path services register(job) deferred side effects (S3 blob deletes, invitation emails); TenantTransactionInterceptor wraps tenant-transaction handlers in runScoped(...) and drains the jobs only after the real outer commit, discarding them on rollback. The OUTERMOST scope owns the drain: nested runScoped joins it (no early drain); @NoTenantTx routes get NO scope — register() there fails loudly, and an exempt flow brackets its own explicit tx with runScoped instead (reference: timetable generation brackets only phase W). | @Global module (PrismaModule pattern). Replaces per-service AfterCommitQueue.flush() on the request path — under the ambient-join proxy a nested $transaction joins the request tx and does NOT commit, so a local flush fired the side effect mid-tx and a later rollback restored rows whose email/blob-delete already happened (audit TXN-1). Background sweepers own their own real tx and keep their local AfterCommitQueue. Unit specs use createDeferredPostCommit() from src/common/testing (never an eager register → job() stub — that re-creates the timing bug in tests). Spec docs/superpowers/specs/2026-08-09-post-commit-coordinator-design.md. | | common/ | Shared services, decorators, DTOs, filters, exceptions, validators, constants | BaseTenantedCrudService and AppException live here. | | common/temporal/ | Shared valid-time primitives, introduced by the attendance temporal program and consumed across it. Spec A seeded the folder with school-clock.ts (loadSchoolTimezone, schoolToday, toIsoDate, addCalendarDays, isoDayOfWeek — the tenant's wall clock, which is not UTC) and the AY-day advisory-lock helpers lockAyDayShared / lockAyDayExclusive. C1 added the effective-date machinery: resolveEffectiveDate (one place decides when a command starts applying — command-specific and prospective, never a blanket “today”), nextSchoolDay (per-(department, grade): calendar bounds − CLOSING periods − non-operative weekdays under the timetable version governing each candidate day), schoolTodayFor, and rethrowIfExclusionOverlap mapping Postgres 23P01 to TEMPORAL_INTERVAL_OVERLAP. | The folder is A's to seed but no one spec's to fill. C2 added interval.ts (effectiveOn, OPEN_ROW, pendingAfter, toDbDate, closeBoundaryOn) and E added temporal-delete-guard.ts (assertRowDeletable, closeInsteadOfDelete, assertParentDeletable — the hard-delete guard consumed by six domain modules) as siblings, each appending its own barrel lines. A narrowing branch that needs the school date and cannot find it fails closed rather than falling back to head columns; RecordAccessContext.schoolToday is optional because @AccessContext() is synchronous and DB-free, so services derive it and pass { ...ctx, schoolToday }. Program contract docs/superpowers/specs/2026-07-26-attendance-temporal-program-contract.md §3/§7/§2b. | | 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/C2) at GET/PATCH /students/:id/curriculum-selection (effective-dated whole-aggregate versions; PATCH-as-versioned-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 opens a curriculum-only StudentCurriculumSelection version for every eligible student with no current or pending 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 write (generic PATCH /students/:id) now accepts gradeId/departmentId to re-place an existing student (correcting a wrong import/create): gradeId is authoritative, departmentId is a cross-check (rejected alone), the new grade must be in the student's AY, and a resolved (department, grade) change triggers a silent total placement reset in afterUpdate (the membership helper closes/cancels the student's homeroom, subject-group, and curriculum-selection intervals at the same boundary; no sideEffects block — the FE reads the cleared state off the re-rendered assignment block). Grade→dept resolution is shared with create via resolvePlacementDepartmentId (src/students/student-placement.ts); spec docs/superpowers/specs/2026-07-07-student-placement-correction-design.md. 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. Placement is valid time since 2026-07-27 (temporal program, spec C1). StudentPlacement rows [validFrom, validUntil) (half-open, @db.Date, EXCLUDE student_placements_concurrent_excl on (student_id, daterange)) are the enrolment episodes; Student.departmentId/gradeId are demoted to forward-head metadata — the planned terminal placement, never “where is this student now” — and are re-synced idempotently by every mutation inside the same tx. src/students/student-placement.queries.ts is the single write path: nothing outside it may write student_placements, the head pair or Student.exitDate. Each command resolves ONE date via resolveEffectiveDate (against the cohort governing today, not the head) and gives both boundaries that date, emits one structural audit envelope on the caller's tx (a move is .moved, never .closed+.opened), and writes through a compare-and-swap on the whole captured row — captured in beforeUpdate, OUTSIDE the update transaction, because the base service's head updateMany has already serialized concurrent requests by the time afterUpdate runs (EXCLUDE prevents overlap; only the CAS sees a lost update). Reads come in three tiers, and the split is security-relevant: strict (studentPlacedOnWhere / studentPlacedInDepartmentsOn) for authorization and the attendance register — no head fallback in either direction; display (studentPlacementDisplayWhere, studentPlacementDepartmentDisplayWhere) for lists/filters/pickers/boards — a two-limb OR whose head limb is guarded by placements: { none: … }, required because every pre-year episode opens at a future calendarStartDate; overlay (resolveDisplayCohorts, attendance's loadCohortsOn) for batched post-load replacement. Because both display fragments are top-level ORs they compose under AND and never by spreading into an object that also declares OR — that silently drops the scope. Same-day changes are gated under lockAyDayExclusive in afterUpdate (lock THEN probe); beforeUpdate's probe is a courtesy fast-fail with no authority. At most one outstanding scheduled change per student, kept so by replacement, not refusal (contract §20, 2026-07-30): a move dated after a booked change, or an exit while one is booked, erases the booked row to [d, d) and reports it in the assignment response's superseded[] — a second future episode is representable in the table but not in the singular pendingPlacement. Delete is guarded by hasEverEffectivePlacement re-decided under SELECT … FOR UPDATE in the base hook beforeRemoveWithinTx (409 TEMPORAL_ROW_IMMUTABLE); deleteRecordWithinTx deliberately skips it so the setup-import sweep stays ungated. assignment read gains exitDate / appliedFrom / pendingPlacement; assignment write gains validFrom (command input, never a column — in omitFromNative). Spec docs/superpowers/specs/2026-07-26-attendance-temporal-c1-enrolment-placement-design.md, plan docs/superpowers/plans/2026-07-27-attendance-temporal-c1-enrolment-placement.md, FE guide docs/fe-guides/2026-07-27-student-placement-temporal-BREAKING.md. C2 membership valid time: membership and selection reads resolve as-of one captured school day; group details expose authorized pendingChanges[], selection exposes a complete pendingSelection, and leaving closes all membership families at the placement boundary. Since 2026-07-30 the homeroom/SG roster commands and the placement PATCH replace a booked change by default — no flag, no TEMPORAL_PENDING_CHANGE_EXISTS on any admin route (superseded[] on write responses; ch14 §1.0, contract §20); the forward sync tolerates a compatible rival-dated pending selection and is otherwise admin-wins (cancel whole + fill at the command's date, reported as entity: 'curriculum_selection'). Teacher visibility = the taught set (2026-08-02, was dept-wide): StudentsReadPolicy's teacher branch uses the exported studentRelatedToTeacherOn builder — dated SG teaching episode ∪ combined-class sibling roster ∪ tutored homeroom ∪ supervised-activity audience (all seven selector kinds) — and CurriculumSelectionReadPolicy follows it; see ch04 §Policy inventory + spec 2026-08-02-teacher-people-visibility-narrowing-design.md. Participation (2026-09-03): constants/participation-status.ts owns the two fences (participatingStudentWhere live, participationHistoryStatusWhere dated); an exit (LEFT/GRADUATED) revokes platform access in the PATCH tx and readmission restores it; the rollover bulk exit closes memberships like the PATCH exit. | | 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. The teacher role holds teachers.identity READ only (2026-08-04; zero scopes 2026-08-02→04) — tenant-wide identity-only colleague directory (TeachersPolicy teacher pass-through, field-masked to the identity block); own record via the scope-free /teachers/me + /auth/profile. | | 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. The teacher role holds zero staff.* scopes (2026-08-02) — the 2026-07-10 contacts-only surface is gone; StaffPolicy has no teacher branch. | | 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). Teacher resolution on GET /referents/:id is narrowed to referents linked to a taught student (2026-08-02, was tenant-wide pass-through) — the service threads schoolToday into ReferentsPolicy for the dated taught subtree. QA batch 2026-08-07 (spec 2026-08-07-referent-qa-bugs-design.md): email is normalized (lower+trim) on every write path incl. student-side ingestion (migration referent_qa_batch normalized storage); a PATCH email collision answers 409 REFERENT_EMAIL_IN_USE, with opt-in contacts.mergeIntoExisting collapsing an unclaimed placeholder into the email's owner (links union, null-only profile fill, invitation inherited, referent.merged audit); StudentReferentLink.isAuthorizedPickup defaults true (opt-out; guardians stay opt-in). Referent self-only writes 2026-08-10 (spec 2026-08-10-referent-self-only-writes-design.md): the QA-batch's unclaimed-co-parent read/write reach and family-session merge are removed — a referent session resolves and writes only its own row (ReferentsPolicy's referent branch is { tenantId, userId }; unclaimedCoParentWhere deleted); a non-privileged caller submitting a contacts.email that differs from the stored address is rejected 403 REFERENT_EMAIL_READ_ONLY (re-submitting the current address is a tolerated no-op) — only the school (admin) can change a referent's email; contacts.mergeIntoExisting is admin-only now (double-gated: the email fence stops non-privileged callers upstream, and an explicit privileged check guards eligibility), keeping the links-union / null-only-fill / inherited-invitation / referent.merged audit semantics above. The /referents/:id/documents* sub-routes compose the self-only ReferentsDocumentsPolicy (no teacher branch — a non-self row stays a hidden 404). | | 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 period-revalidation gate in updateForAccessContext. Editable while the year is DRAFT or ACTIVE (calendar thaw 2026-07-30 — setup lands the year ACTIVE, so the old DRAFT-only gate froze every post-setup calendar forever; only ARCHIVED refuses ACADEMIC_YEAR_NOT_EDITABLE). The temporal rule is spec E's retro-calendar guard (src/departments/retro-calendar-guard.ts): a bound may move only if no day before the school-clock today changes meaning — so the whole calendar is free until the year starts, the start freezes automatically once lived, and a running year's end stays movable while both old and new end are today-or-future; CLOSING periods may not be created, moved or deleted touching a past day; TERM periods are exempt. The department-side grace-period check fires only when the start actually moves (a legacy year already in violation can still edit its end/periods). A department's period set can be written two ways (same rules, 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; 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. Participation (2026-09-03): studentCount on list/detail/grades is a filtered relation count over ENROLLED students; the delete guard stays broad. | | 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; every other preset reads — the referent's READ was dropped 2026-08-06 and re-granted 2026-08-10; only the student preset holds no grant, its sessions get 403). 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 lifecycle (scalars only: name + gracePeriodEnding + status) | Setup wizard writes the first year via bulkSync (lands ACTIVE immediately — the wizard-created year never passes through DRAFT). The year carries no calendar of its own — dates + period sets live per-department (see departments/); this module has no period endpoints but re-exports the shared period DTO/mapper contract from its barrel for the departments sub-resource. Admin endpoints: POST creates a DRAFT (name + optional gracePeriodEnding, set only if supplied — no derivation); PATCH edits scalars behind a field-aware lifecycle gate (2026-07-21): name is DRAFT-only, gracePeriodEnding is editable while DRAFT or ACTIVE (it is the tenant-wide deadline behind Command Center overdue flags and selection-window end dates, and PATCH is the only write path once the wizard activates the year) subject to the pre-year completeness invariant — it must fall strictly before the earliest Department.calendarStartDate of that year, else ACADEMIC_YEAR_GRACE_PERIOD_INVALID (422); a year with no departments yet accepts anything, and only the incoming value is judged so a violating year can always be repaired downward (ch08 §Pre-year completeness; mirror check on the department side) — disallowed edits reject ACADEMIC_YEAR_NOT_EDITABLE wholesale; DELETE hard-deletes a DRAFT (cascade clears its structural config). The hidden __bootstrap__ sentinel year is filtered out of reads/writes. | | 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 = taught with positive weeklyHours; "—" otherwise — a cell submitted with 0/null hours is the "not taught" idiom and is silently discarded at write, equivalent to omitting it). 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. Period-duration cascade (SPD 2026-07-07, root required since 2026-07-09): periodDuration (any positive integer minutes — the 20–240/step 5 DTO bounds were removed 2026-07-15, leaving only @Min(1)/@IsInt) at four levels — Curriculum (required: NOT NULL, create DTO demands it, PATCH rejects explicit null — the cascade terminal, so lesson-duration resolution is total) → CurriculumGradeCurriculumSubjectCurriculumSubjectHours cell (the three overrides stay nullable) — first non-null wins; responses project non-null effectivePeriodDuration per level; the write-time divisibility gate (CURRICULUM_HOURS_NOT_DIVISIBLE) was removed 2026-07-15 — per-slot slotDuration overrides let a custom slot cover any non-tiling remainder, so weekly hours no longer need to divide evenly into the period duration (docs/superpowers/specs/2026-07-15-timetable-slot-duration-override-design.md); weeklyHours stays Float HOURS with the @IsWholeMinutes() DTO guard. 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, and (2026-08-06) read the grid itself via curricula.configuration READ record-narrowed by the CurriculaPolicy referent branch to curricula a linked child holds a current-or-pending selection on (the read paths thread withSchoolToday for the dated hop). | | custom-fields/ | Custom field definitions CRUD + validation | HIDDEN PLATFORM-WIDE since 2026-08-17 (product, same motion as the roles & permissions matrix): no role holds any others scope grant (seed othersScopes: 'NONE' everywhere + migration 20260817130000_revoke_custom_field_grants) and GET /permissions never advertises customFieldDefinitions (CUSTOM_FIELDS_HIDDEN in PermissionsService), so the FE renders no custom-field UI. Definitions, stored values, the catalogue's others scopes, and everything below stay intact for the feature's return. Explicit RBAC entity since 2026-08-18: the /custom-fields routes are gated on custom_fields.configuration (+ create/delete actions) — granted to NOBODY at seed time, admin included — LAYERED ON TOP of the service's per-target entity+scope checks (both gates); /permissions carries the custom_fields key for every session (every catalogue entity always emits since 2026-08-18) — an empty { scopes: {}, actions: all-false } shell while ungranted; the FE hides the surface on present-but-empty content. Re-enable = backoffice grant + flag flip + others re-grants. Plugs into BaseTenantedCrudService via pickCustomFields. Always-surface contract (2026-07-15): reads emit every defined field (unset → value: null) via the shared pure helper build-custom-field-values.ts (buildCustomFieldValues), so entity reads are self-describing (FE merges nothing; /permissions is form-building metadata only). homerooms/subject_groups (bespoke, aggregate/flat) wire in via the same helper + entity-wide validateCustomFieldsFlat. Spec docs/superpowers/specs/2026-07-15-custom-fields-always-surface-design.md; FE guide docs/fe-guides/2026-07-15-custom-fields-always-surface-BREAKING.md. | | 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). Optional tags override (2026-08-04): explicit provider tags skip the correlationId → invitation_id/send_count derivation — non-invitation producers (the notification engine's email channel) use it so their sends don't masquerade as invitations. Communications extension (2026-08-05): replyTo + fromDisplayName (transport renders "<name>" <MAIL_FROM address> — the on-behalf-of From), base64 attachments (single sends only), sendBatch/sendEmailBatch (≤100/call, no attachments), MailSendResult.providerMessageId, and DeliveryReport.kind: 'invitation' | 'communication'(mapper dispatches on thecommunication_recipient_id tag; outcomes now include DELIVERED/BOUNCED). | See docs/superpowers/specs/2026-04-23-resend-adapter-design.md and docs/superpowers/specs/2026-06-24-resend-credential-email-templates-design.md. | | notifications/ | The platform user-notification seam + the notification center's backend (2026-08-04). NOTIFICATION_PORT.send({tenantId, kind, recipientUserIds, payload}) — post-commit, never rejects, per-leg fail-soft — fans out to the always-on inbox (Notification rows, written under withTenantGuc) + email (per-kind Resend template ${prefix}-${lang} on the school language, engine-resolved User.id → email, VALUE_LABELS-localized variables) + a NOTIFICATION_TRANSPORT-selected log/memory observability leg. Typed kind registry (NotificationPayloads: attendance.event/attendance.reminder tightened, discipline.note placeholder) + NOTIFICATION_EMAIL_SPECS. Read API auth-only self-scoped (profile precedent, NO RBAC): GET /notifications (+unread=true), GET /notifications/unread-count (FE poll target), PATCH /notifications/:id/read (idempotent; hidden 404 NOTIFICATION_NOT_FOUND), POST /notifications/read-all. Broadcast routing v1; push channels + outbox + preferences deferred. First consumer: the attendance sweeper. | See 23 + spec 2026-07-23-notification-engine-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 handles archive lifecycle. Since 2026-08-10, send/resend atomically writes an InvitationDelivery outbox row and persists Invitation.status=QUEUED; src/invitations/sender/ drains it with CAS claims, stale reclaim, claim-generation completion fencing, and a five-claim terminal reaper. Tokens are minted only after a claim (raw token is never committed); the request path registers only a post-commit wake hint. The batch response field is queued[] (formerly sent[]). | Base design: docs/superpowers/specs/2026-04-20-invitations-design.md. Student gate: docs/superpowers/specs/2026-06-03-student-invitations-design.md. Durable outbox: docs/superpowers/specs/2026-08-09-invitation-outbox-design.md; FE contract: docs/fe-guides/2026-08-09-invitations-FE-guide.md. Credential templates remain invitation-credentials-<lang> with text-only recipientName/schoolName/activationUrl variables from src/invitations/invitation-template.ts. Participation (2026-09-03): a STUDENT is invitable iff department flag ∧ ENROLLED; a REFERENT iff linked to ≥1 ENROLLED student (one link witness); studentCohortWhere and the REFERENT list carry the same fences → INVITATION_ACCESS_DISABLED otherwise. | | 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 endpoints: GET /dashboard/completeness, GET /dashboard/onboarding, GET /dashboard/curriculum-selections, GET /dashboard/overview, and GET /dashboard/next-steps plus its completion writes. Onboarding accepts repeatable departmentId / gradeId / homeroomId cohort filters: values union within a dimension and dimensions intersect; students use their dated display cohort, referents match through linked active-year students, teachers match department/effective teaching/homeroom leadership, and staff contribute no rows under cohort filtering. The first three retain their documented lifecycle/status behavior. overview.selectionProcess is the placement-readiness contract { byDepartment }: each display department reports distinct ENROLLED students missing a forward curriculum, forward homeroom, structurally required mandatory-course assignments, or structurally required optional-course assignments. The four predicates overlap, so there is no total; the old total, byStatus, and overdue keys are removed, while lifecycle detail remains available from the unchanged curriculum-selections drill-down. Overview preserves onboarding, completeness, nextSteps, and meta. | See command-center designs 2026-05-05, 2026-06-08, 2026-06-08-iteration-2, 2026-06-25, and the approved docs/superpowers/specs/2026-08-21-command-center-overview-iteration-3-design.md; placement-readiness behavior is canonical in 14 §5.5. Participation (2026-09-03): completeness and onboarding (list + raw counts + overview folds) count ENROLLED students only and referents with ≥1 ENROLLED active-year child; the selection-lifecycle tab and milestones keep {ENROLLED, PRE_ENROLLED} on purpose. | | 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. | | tenants/ | Platform-admin tenant onboarding (backoffice Tenants tab): POST /tenants provisions a tenant + its first admin (the 6 coupled seed rows) in one withTenantGuc(mintedId) transaction, GET /tenants is the cross-tenant directory via AdminPrismaService. @PlatformAdminOnly + @NoTenantTx; no schema. Reuses cloneRolesIntoTenant. v1 = create + read-only list, plus dev-only POST /tenants/:id/reset (app-side port of tools/reset-tenant.sql, env-gated to local/development/test, coverage drift-pinned) + GET /tenants/capabilities. 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 (decoupled from courses 2026-07-08; optional section link + roster cascade 2026-08-06). Registration/form group of students bound to (Department, Curriculum, Grade) for one AY — roster + optional tutor + optional base room. Single composition scope; create/delete actions. Binds no track and seeds no Subject Groups — a homeroom owns no courses; Curriculum + Grade are kept only for curriculum-match eligibility of the roster (students of different tracks share one homeroom). Create runs one $transaction: 1 Homeroom + N effective-dated HomeroomAssignment intervals (+ forward selection sync); no SG cascade. Curriculum/Department/Grade immutable post-creation (HOMEROOM_FIELD_IMMUTABLE); rename is a pure header update. 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) opens, closes, or amends intervals at one boundary; reads/counts resolve as-of the captured school day and detail responses expose authorized pendingChanges[]. Since 2026-08-06 each roster command also executes the linked-course cascade (ch14 §1.5) in the same tx at the same boundary: leave-side closes in courses LINKED to the exited class, enter-side explicitly-belonging opens in the target's linked courses (skip-don't-block with per-course reasons; pure planner src/homerooms/linked-course-cascade.ts, shared predicate src/homerooms/explicit-belonging.ts); responses carry courseCascade[], homeroom detail carries linkedCourses[], displaced booked SG transitions land in superseded[]. Record-level visibility via HomeroomsReadPolicy (src/homerooms/homerooms.policy.ts) — admin/staff pass-through, teacher → own assignments (2026-07-10, was dept-wide: tutored homerooms ∪ homerooms containing a student in an SG they teach), referent scoped via StudentReferentLink, student via own userId. GET /homerooms/grouped-homerooms (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/departmenthead/teacher — teacher read-only, leaves self-narrow to own assignments, no BE skeleton pruning). GET /homerooms/table (2026-07-12) — flat paginated classes table {data, meta} for the list page (rows = HomeroomListItemDto + timestamps, no roster; unioned uuid filters departmentId/curriculumId/gradeId/homeroomId/homeroomTeacherId/studentId — chips since 2026-08-07: within-field OR _and cross-field OR, search narrows the union; whitelisted sortBy (name/department/grade/homeroomTeacher/numStudents/createdAt) + default composite + id asc tiebreak; same HomeroomsReadPolicy); the grouped tree is swagger-deprecated — deleted once the FE migrates, /table then renames onto the bare path. Dropdowns come from filters/. 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; no track dimension; admin/department_head; see ch.14 §5). GET /homerooms/:id/course-blueprint (2026-07-20) — one-call read for the create-courses-from-class wizard (src/homerooms/course-blueprint.*): homeroom + full roster, common mandatory subjects / option blocks / per-track sections offered in the homeroom's grade, each leaf with existingSubjectGroups[] (merge targets; array — multiple sections legal) + selected: number (2026-07-31, replaced eligibleCounts — SELECTION-based from ONE grade cohort load, not the eligibility classifier: explicit block choice / explicit track / homeroom roster for common mandatory, minus already-in-a-course-for-the-subject); gated subject_groups.read + admin/department_head + @AppliesPolicyDimensions(HomeroomsReadPolicy); see ch.14 §5.4 + spec 2026-07-20-create-courses-from-homeroom-design.md. See docs/14-homerooms-subject-groups.md + docs/superpowers/specs/2026-07-08-homeroom-course-decoupling-design.md (decoupling) + 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 (decoupled from homerooms 2026-07-08). Standalone teaching unit anchored on one CurriculumSubject + its own gradeId (the grade it teaches; hours resolve against that grade's cell), with its own manual roster. The anchor may be a mandatory subject OR an in-block alternative — eligibility branches accordingly. There is no homeroom-bound flavour — every SG has full CRUD + roster ops + mutable name. Since 2026-08-06 a mandatory-anchor course MAY carry an optional section link (homeroomId, affinity not fence — validated by assertHomeroomLinkValid (SUBJECT_GROUP_LINK_ANCHOR_IN_BLOCK/SUBJECT_GROUP_LINK_MISMATCH), DTOs carry homeroom {id,name} | null, the from-homeroom wizard auto-stamps its mandatory creates, PATCH sets/clears it metadata-only; the ONLY behavioral consumer is the homeroom roster cascade — ch14 §1.5). One compositionscope;create/deleteactions. "One concurrent SG per Subject per Student per AY" is DB-backed by the interval exclusion constraint over the denormalized subject/year identity. Reads and counts use the membership effective on the captured school day; detail responses expose authorizedpendingChanges[]. 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 SubjectGroupsReadPolicy (src/subject-groups/subject-groups.policy.ts) — teacher → own assigned SGs via SubjectGroupTeacher (2026-07-10, was dept-wide). GET /subject-groups/grouped-courses(counts board for the list view, 2026-07-02; grade level + per-subject coverage 2026-07-03; restructured 2026-07-10 to a catalog-drivendept → grade → curriculum → track tree — the per-grade curriculum node carries plan-coverage (numStudents/numStudentsWithMissingAssignmentsvia per-studentresolveTargetPlan), teacher-coverage (numSubjectGroups/numSubjectGroupsWithoutTeacher), and numOfferedSubjectGroups(per-grade floor: mandatory taught in the grade + Σ blockminSelections); track nodes (common track: nullfirst, then cv tracks, all catalog-emitted viafindBoardSubjectsForCurricula) hold mandatorySubjects[]+optionBlocks[].subjects[] coverage rows (numStudentsExpected/numStudentsPlaced, weeklyHours, ordinalPosition) with the SG leaves nested per row ([]= defined-but-empty group; leafsubjectcarriesoptionBlock {id,name}); no student rows; two-scope subject_groups.read+ inlinestudents.read, admin/department_head/teacher — teacher read-only, leaves self-narrow to own assigned SGs, no BE skeleton pruning). GET /subject-groups/table(2026-07-12) — flat paginated courses table{data, meta}for the list page (rows =SubjectGroupListItemDto+track {id,name} | null(effective =subject.track ?? optionBlock.track) + subject.code+ timestamps, no roster; unioned uuid filters — chips since 2026-08-07: within-field OR and cross-field OR,searchnarrows the union —departmentId/curriculumId/gradeId/trackId (strict — excludes common)/teacherId/curriculumSubjectId/optionBlockId (in-block courses only — mandatory never match)/homeroomId(2026-07-20; union since 2026-08-06 — stored section link ∪ derived roster overlap (≥1 rostered student currently in the homeroom), combined rows match via any member; ignored bymissing-courses); whitelisted sortBy (name/department/grade/subject/numStudents/createdAt— track NOT sortable) + default composite +id asctiebreak; sameSubjectGroupsReadPolicy); since combined-classes iteration 3 the table mixes COMBINED_CLASS rows in (kinddiscriminator on every row; combined rows carry name + derived department + deduped union teachers + distinct unionnumStudents+sharedWeeklyHours, null curriculum/grade/subject/track; filters match via any member; two-source page merge — SG page window + in-memory-sorted combined rows — under the same sort chain, combined visibility = SG policy through ≥1 member); the grouped tree is swagger-deprecated— deleted once the FE migrates,/table then renames onto the bare path. GET /subject-groups/missing-courses(2026-07-13) — courses-page coverage-gap widget: a pruneddept → curriculum → grade → missingSubjects[] tree of every subject the curriculum offers in a grade (positive-hours cell) with zero courses (isOptionalflags option-block alternatives;numMissingMandatory/numMissingOptionalrollups per level; empty branches pruned). Catalog-driven, per-slot ("at least one course" clears a subject — extra sections never count); noSTUDENTSread. Reuses the/tablequery object (honours dept/curriculum/grade/track+includeCommonTrack/curriculumSubject/optionBlock/AY; ignores teacherId + paging/sort). Gate diverges: admin/department_head only, no teacher (a teacher's self-narrowing policy would surface false gaps); route onGroupedCoursesController. New query findCoveredSubjectGradePairs+ puresubjectMatchesTrackFilter(mirrorsbuildEffectiveTrackWhere). See ch.14 §5.3 + spec 2026-07-13-subject-groups-missing-courses-design.md. Teacher assignment became valid-time (temporal program, spec C3, 2026-07-28): SubjectGroupTeachergained half-openvalidFrom/validUntil @db.Datecolumns and lost@@unique([subjectGroupId, teacherId]) — replaced by subject_group_teachers_concurrent_excl(EXCLUDE USING gist, non-deferrable), so a teacher may leave and later be re-assigned (two non-overlapping episodes, previously impossible) while genuine overlap is refused by the DB as409 TEMPORAL_INTERVAL_OVERLAP, never by write-path discipline. teacherIdsstays set-semantics on the wire but the service writes a diff: closes-then-opens on ONE command-wideX— explicit dates through C1'sresolveEffectiveDate, omitted resolved in-tx by earliestAcceptableDate(contract §7/§20, 2026-07-30: today while the group's register is clean — always today pre-year — else the next school day);schoolToday→ whole-day change allowed only while no affected attendance cell exists that day, gated by A's exclusive AY-day advisory lock taken before the group-keyedhasGroupAttendanceCellsOnprobe; future → scheduled; past →422 TEMPORAL_BACKDATE_FORBIDDEN), echoes the applied date as appliedFromand the not-yet-effective ones aspendingTeacherChanges[]. A boundary-free edit (pure ordinalPositionreorder) moves no interval, so a suppliedvalidFromis400 VALIDATION_FAILED, never ignored; the reorder itself stays immediate. Every close is a compare-and-swap (validUntil: nullin the predicate — EXCLUDE prevents overlap, only CAS prevents a lost update) and clamps through C2'scloseBoundaryOn, since an inverted daterangeraises SQLSTATE 22000, which the 23P01 overlap mapper never sees. Each open/close emits onerecordStructuralChangeenvelope inside the caller's tx. Current reads — detail/list/table includes, the table'steacherIdfilter, andSubjectGroupsReadPolicy's teacher hop — are all effectiveOn(schoolToday), never bare {validUntil: null}; SubjectGroupListResponseDto.combinedClassId likewise resolves as-of-schoolTodayrather than from the forward head. As-of-D readers shipped for the resolver (E wires them):loadSubjectGroupTeacherIdsOn(db, ids, D). Deletes are untouched until E's TEMPORAL_ROW_IMMUTABLEguard. See ch.14 §6.2 + spec2026-07-26-attendance-temporal-c3-dependency-closure-design.md. Dropdowns come from filters/. The candidate picker GET /students/eligible-for-subject-groupwas restored 2026-06-16 (server-bucketed{noSelection,pending,selected}, single-sourced with the write-gate via the pure classifySubjectGroupEligibilityinsrc/subject-groups/subject-group-eligibility.ts — incl. the multi-pick option-block capacity rule; admin-driven since 2026-07-31: only an EXPLICIT conflicting track hides (WRONG_TRACK), a null-track selection stays placeable on track-scoped anchors (mandatory AND in-block) and the forward sync fills the track; admin/department_head; rows carry homeroomId+homeroomNamesince 2026-07-20 for client-side class filtering; see ch.14 §5 +docs/superpowers/specs/2026-06-16-eligibility-classifier-pickers-design.md). POST /subject-groups/from-homeroom (2026-07-20) — atomic batch write for the create-courses-from-class wizard (src/subject-groups/from-homeroom/): grade+curriculum derive from homeroomId(ACTIVE year only); item withoutsubjectGroupIdcreates (aname is required — no subject-name default), with it appends the roster to that course (merge-only, append-only — never touches teachers/room/name); ALL validation pre-tx via the single-endpoint gates, writes compose the shared tx bodies (subject-group-write-ops.ts) in ONE $transaction— all-or-nothing, except the ONE softening pinned by the parity spec: already-in-an-SG-for-the-subject students become per-itemskippedStudentIds(idempotent re-runs); a submitted-empty create roster creates the course as an EMPTY SHELL (2026-07-31 — theEMPTY_ROSTERskip is gone); response{created, updated}; gated @RequireAction(create)+ singular@RequireScope(composition, write)stacked; new codeSUBJECT_GROUP_BATCH_TARGET_MISMATCH; see ch.14 §5.4 + spec 2026-07-20-create-courses-from-homeroom-design.md. | | filters/ | Dropdown-population lists for table surfaces — no RBAC entity of its own | Ten GETs under /filters/*. Every request requires an endpoint-supported surface=students|homerooms|subject-groups|teachers|timetables. The service applies the source's dated record-read policy first, then projects reachable ids. Teachers uses the same employment/taught/led four-dimension predicate as GET /teachers. Timetables is supported on seven filters and requires timetableId: six project exact surviving lesson/activity/break cohorts, rooms, and teachers, while /filters/students returns policy-visible ENROLLED authoring candidates in the anchor year. Structural routes return {data}; people routes return paginated {data,meta} with one data/count predicate. Cascade algebra remains OR within a dimension and AND across dimensions. See ch.04 record access, ch.14 §5.2, ch.18 §6, the approved iteration-3 and 2026-08-28 timetable-participation designs, and docs/fe-guides/2026-08-26-filter-surface-visibility-BREAKING.md. | | timetable-templates/ | US-35 + US-36 (+ SPD 2026-07-07). Generation-only input catalog since the 2026-07-09 template decouple — the manual timetable flow, the diagnostics engine, and attendance never read templates. Tenant-flat catalog: DayTemplate is an ENVELOPEstartTime/endTime, a default periodDuration, and fixed DayTemplateBreak[] bands (BreakType {INTERVAL, LUNCH}; LUNCH requires lunchShiftId, INTERVAL has optional roomId) — no slot list (TimeSlot/SlotType deleted). Teaching bands = envelope minus breaks (envelopeOf → common DayEnvelope geometry); create/update validate DAY_TEMPLATE_ENVELOPE_INVALID/DAY_TEMPLATE_BREAK_INVALID and surface non-blocking BAND_NOT_DIVISIBLE_BY_DEFAULT warnings. WeekTemplate (MON-SUN map of dayTemplateId \| null) unchanged. 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 capacity alerts (LUNCH_CAPACITY_EXCEEDED/INTERVAL_CAPACITY_EXCEEDED) plus the demand/supply fit report (TEMPLATE_MINUTES_MISMATCH, HOURS_NOT_DIVISIBLE, BAND_NOT_TILEABLE — curriculum weekly minutes vs template teaching minutes, per-subject tiling, per-band tiling); write paths never block on any alert. See specs 2026-05-25-timetable-templates-design.md + 2026-07-07-subject-period-durations-design.md. | | timetables/ | Hand-managed weekly timetables (template-free manual flow, iteration-3 2026-07-09) + dormant automatic generation. Timetable (DRAFT/PUBLISHED/ARCHIVED, ≤1 PUBLISHED per (tenant, AY) via partial-unique) + ScheduledLessondual-anchored on subjectGroupId XOR combinedClassId (combined-classes iteration 3: a co-taught shared meeting is one combined-anchored row; DB CHECK + per-anchor coordinate uniques; responses carry unit: TeachingUnitRefDto {kind, id, displayName, subjectName}) — coordinate (weekday, startTick) where the position is a start tick in 5-minute steps from midnight (96 = 08:00; absolute axis, free 5-minute grid, no slots/envelopes) in an optional room (schedule-first-room-later, 2026-07-10: roomId nullable; a roomless lesson fires LESSON_ROOM_MISSING (ERROR) and blocks publish; the other room-keyed checks skip roomless lessons). Lesson duration derives at read time from the pure curriculum periodDuration cascade (cell → subject → grade → curriculum, root NOT NULL → resolution total, one value per SG; resolveLessonDuration); wall-clock = tick × 5. Two stored columns short-circuit that derivation and are not interchangeable (2026-07-27, ch18 §5.0): slotDuration = the admin's authored override (request field; its NULL is the only marker of "derived"), resolvedDuration = the cascade value frozen on every transition into PUBLISHED (stampResolvedDurations, system-written, internal — never on the wire, always NULL on a DRAFT; clients get the effective durationMinutes plus slotDuration for provenance, which is all they need). Read order slotDuration ?? resolvedDuration ?? cascade; revision drafts drop the freeze so they track curriculum edits, the ARCHIVED retention copy carries it. Never stamp into slotDuration — the ambiguity is irreversible. Break/lunch slots (ScheduledBreak + audience selector rows + duty teachers, kind BREAK/LUNCH, stored duration, optional room): audience = live-resolved union of dept/curriculum/grade/homeroom selectors (never materialized); duties are real teacher occupancy that consume the hour budget. Activity slots (ScheduledActivity + kind-discriminated audience rows EVERYONE\|CURRICULUM\|GRADE\|TRACK\|HOMEROOM\|STUDENT + supervisors, 2026-07-13; single-department since 2026-08-12 — required departmentId, EVERYONE = the department, every ref validated in-department with SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT, DEPARTMENT kind retired): named non-curriculum slots (morning meet, Friday activities) with a weekdays[] repeat set + stored duration + optional room; unlike a break, an obligation for its roster (fills student occupancy in the conflict/gap checks) and an attendance take anchor (ch19); CRUD POST/PATCH/DELETE /timetables/:id/activities, same mutateAndDiagnose envelope + admin gate as breaks (zero new RBAC); one new WARNING ACTIVITY_SCOPE_EMPTY. Structural floor: in-tenant refs, lesson coordinate unique, interval-inside-the-day (tick×5 + duration ≤ 1440SCHEDULED_LESSON_INVALID_SLOT/SCHEDULED_BREAK_INVALID_SLOT), audience non-empty/one-ref-per-row. Everything else is soft: a pure computeDiagnostics(snapshot) engine (minutes-based interval math) returns ERROR/WARNING {category, severity, params} (i18n codes) as an always-on envelope (incl. canPublish) on every mutation, feeds GET …/diagnostics, and gates publish (no ERROR). Lesson/break create is a "repeats on" fan-out (iteration-4 2026-07-13): POST …/lessons | breakstakesweekdays: DayOfWeek[]and writes one row per weekday (all-or-nothing on lesson coordinate conflict — 409 rolls back the batch), returning the plural{ lessons[]\|breaks[], diagnostics }; PATCH/DELETE keep the singular { lesson?\|break?, diagnostics }. Catalogue (10 ERROR + 6 WARNING + combined checks): teacher/room/student conflict (breaks + duties participate), option-block sync/clash, room-capacity (break clusters count the union roster once), room-not-in-subject-set, base-room, LESSON_ROOM_MISSING (roomless lesson — publish forces full rooming), teacher-availability (duties too); WARNINGs: minutes hour-budget (per teaching unit — combined unit → shared minutes, active member → cell − shared; advisory both directions since 2026-07-23 — never blocks publish, so a "2h" cell can be scheduled as 2×55′ or 2×65′), STUDENT_GAP (demoted; bridged by breaks the student participates in), TEACHER_BUDGET_EXCEEDED (teaching+duty vs weeklyHoursToMinutes(totalHoursPerWeek+extraHours)— both whole-minute Floats since 2026-08-21; params carry the split), daily-cap, same-day-contiguity (bridged only by whole-roster breaks), same-room-double. A PUBLISHED timetable's content is frozen (2026-07-15): direct edits409 TIMETABLE_EDIT_REQUIRES_REVISIONand go through a revisionPOST …/revision deep-copies it into a linked DRAFT (revisionOfId, idempotent, ≤1 open via the partial unique timetables_open_revision_unique WHERE status='DRAFT'), POST …/publish-revisionis one tx: retain → clear → copy → hard-delete the draft → stamppublishedAt+revisionNumber, on a stable published id. Republish retains the version it replaces (2026-07-26, ahead of a budget module that will diff/cost versions): an ARCHIVEDcopy on the samerevisionOfId, carrying its live window (publishedAt+ newsupersededAt), its revisionNumber, and its lessons' publish-time resolvedDurationfreeze —slotDurationremains authored-only, and without the freeze a retained version would re-cost differently after a curriculum edit.ARCHIVEDtherefore has two producers, told apart byrevisionOfId(set = superseded version, null = archived with its AY); it stays read-only + terminal, so direct deletion is denied; once the lineage has governed, the published parent is immutable too, while deleting a never-governed parent may still cascade its empty evidence.findAll's existing revisionOfId: nullfilter hides both drafts and retained versions ⇒ zero API change;findOpenRevisionandtimetableSummaryIncludemust filterstatus: 'DRAFT' or a retained version reads as a pending change. No read/diff surface yet (supersededAt/revisionNumberare in no DTO); teacher attribution is NOT frozen (it lives on the SubjectGroup). Publish only on an ACTIVE AY. ADMIN-only: entityTIMETABLES, descriptor-only configurationscope,create/delete/publish/generateactions (breaks + activities ride the same entity — zero new RBAC). Eight read-views (department/curriculum/grade/homeroom/subject-group/room/teacher/student; both params omitted = whole-school) returning{lessons, breaks, activities}nosequenceGrids (deleted, FE-breaking) — with per-view break/activity filtering (teacher = duty/supervisor match, student-anchored = roster ∩ cohort) and combined lessons resolving through any-member matches + GET …/placement-plan (minutes-first per-unit progress: a combined class is its own kind: COMBINED_CLASSrow at shared minutes; contributor rows shrink to the solo remainder and carrycombinedClassId). Automatic generation (dormant; templates are generation-only inputs — effective-template resolution relocated to generation/effective-templates.ts): POST /timetables/generate (generateaction) →buildGenerationSnapshot(day envelopes + weeklyMinutes + durationByWeekday) →runPreChecks(9 categories, 422 on failure) →SolverPort.generate(HTTP to Python CP-SAT service insolver/) → FEASIBLE: assignments carry startMinute(a combined class solves under its real id as a first-classCOMBINEDgroup), ingest storesstartMinute / TICK_MINUTESmapping each assignment directly to one row (combined id → combined-anchored lesson, no fan-out) + post-ingestcomputeDiagnosticsassert (rollback + 500 on drift); INFEASIBLE/TIMEOUT: 422 with enriched violations. Satellite modulesrc/timetables/generation/. Publication is a DATED act since 2026-07-27 (temporal program, spec A): Timetablecarries valid timeeffectiveFrom/effectiveUntil (@db.Date, half-open [from, until)) alongside the transaction-time publishedAt/supersededAt— never collapse the pairs. Resolution-by-date isfindTimetableVersionEffectiveOn(public barrel), and it never consultsstatus: DRAFTs self-exclude on NULL effectiveFrom, empty [X, X) archives self-exclude on the upper bound. Two raw CHECKs tie interval shape to status (timetables_effective_shape_chk) and boundary order with = legal (timetables_effective_order_chk). Publishing defaults to school-tz tomorrow (the AY's very first publication: today), clamps forward to the frontier, refuses the past (422 TIMETABLE_EFFECTIVE_DATE_INVALID/BEFORE_MINIMUM) and refuses today once a cell exists (CELLS_EXIST_TODAY), probing under an exclusive AY-day advisory lock that attendance's writes take shared. Unpublish retains and closes at max(from, tomorrow) instead of flipping status; delete is refused once the lineage governed a day (409 TEMPORAL_ROW_IMMUTABLE); resolvedDurationis stamped on every transition into PUBLISHED andslotDuration is never system-written. Publish/republish/unpublish each emit ONE structural audit event. See docs/18-timetables.md + specs 2026-07-26-attendance-temporal-a-timetable-valid-time+2026-06-08-timetable-manual-management+2026-06-11-…-iteration-2+2026-06-12-timetable-generation+2026-07-07-subject-period-durations+2026-07-09-timetable-manual-management-iteration-3+2026-07-13-timetable-activities+2026-07-13-timetable-manual-management-iteration-4. | | solver/ (repo root, not under src/) | Stateless Python FastAPI + OR-Tools CP-SAT microservice — tick-grid packing: variable-length lessons (per-group durationByWeekday) packed into per-weekday day envelopes on the absolute minute axis; feasibility from per-group HOUR_BUDGET (each group places exactly its weekly minutes) + per-student-cohort compactness (STUDENT_GAP = each coverage cohort's day is one gap-free run — replaced homeroom tiling in the 2026-07-08 decoupling). One-shot POST /v1/generate (bearer TIMETABLE_SOLVER_SECRET): receives a generation snapshot from the BE, returns { status: FEASIBLE\|INFEASIBLE\|TIMEOUT, assignments?, violations?, solveMetadata } with assignments[*].startMinute. Fixed seed 42. Budget split: 75% main solve / 25% unsat-core minimization. 7 hard families (assumption literals → infeasibility core → {category, entityRefs}); groups carry kind: STANDALONE\|BLOCK_CHILD\|COMBINED + combined_group_id — a combined class is an ordinary group at its shared minutes, and pair-tiling (x_child + x_cc == b over the block domain) lets a maxSel=1 block child (full-cell budget) tile its block unit together with its combination; 12-term soft objective (3 warning-mirror + 9 quality, run-counting adjacency). Tests verify output through an independent mirror oracle (solver/tests/mirror.py, combined pair rules included). 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 + specs 2026-06-12-timetable-generation + 2026-07-07-subject-period-durations. | | 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. Grant-gated read surface (iteration 2, 2026-08-04 — no role gate: the audit_log.configuration grant alone governs, seeded admin-only via the five preset exclusion sets, per-role widenable by grant edit — a platform-side operation since the tenant PATCH /roles/:key was removed 2026-08-12; the FE keys the view on GET /permissions): GET /audit-log (filters entityType/entityId/actorUserId/action/search/from/to, paginated) + GET /audit-log/filters (data-derived (entityType, actions[]) dropdown vocabulary via groupBy). Human-readable layer (iteration 3, 2026-08-05): entries + /filters keys serve {en_US, it_IT} labels from ENTITY_LABELS + the module-owned AUDIT_ACTION_LABELS catalog (audit-event-labels.catalog.ts — structural side drift-pinned, value side soft registry), each entry carries target {kind, id, label} (navigable link, row-mediated types resolve to the studentId; resolveEntityTargets) plus a generic rendering of data (changes[] structural diff xor details[] value snapshot, FIELD_LABELS field labels, effectiveOn hoisted) — all pure mapping via buildAuditEntryReadable, zero extra queries, fail-soft null labels/target — entity AUDIT_LOG, descriptor-only configuration scope, no actions; stale non-admin preset grants revoked by data migration 20260804120000_revoke_stale_audit_log_grants. Second write primitive since 2026-07-27recordStructuralChange / recordStructuralChanges(tx, input), the transaction-time half of the attendance temporal program: same caller's-tx discipline, but data is the fixed versioned envelope {v:1, before, after, effectiveOn} from one internal producer (data.v === 1 tells envelope rows from the legacy value-audit rows, which are not migrated), entityId anchors on the stable domain fact (studentId / subjectGroupId / combinedClassId / timetable lineage id) rather than the churning interval row so (tenantId, entityType, entityId, createdAt) is the exact history key, and the entityType/action vocabulary is closed by src/audit-log/structural-audit.constants.ts (STRUCTURAL_ENTITY_TYPES / STRUCTURAL_ACTIONS / STRUCTURAL_SYSTEM_ACTOR) with a runtime <entityType>. prefix assert; snapshot rows with the barrel-exported toJsonSnapshot. One event per logical command (a move is .moved, never .closed+.opened). No migration, no RBAC, no API delta — no call sites either: consumers (specs A, C1–C3) land their own emission points. 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 + docs/superpowers/specs/2026-07-26-attendance-temporal-d-structural-audit-contract-design.md. | | attendance/ | Compliance attendance register (Italian registro), first consumer of audit-log/. Every cell is period-anchored — one AttendanceRecord per (student, date, startTick), single unique attendance_cell_unique (the v1 null-tick daily cell and its two partial uniques are gone). Department.attendanceMode decides how many cells a day needs, not their shape: PERIOD authors one per lesson; DAILY authors sparsely and reads project both ways — forward carryForward (LATE_ENTRYPRESENT, EARLY_EXITABSENT) and, for slots before the earliest authored cell, the inverse carryBackward (LATE_ENTRYABSENT, EARLY_EXITPRESENT) — items carrying source: RECORDED\|PROJECTED\|null (null = not taken ≠ absent). A projected slot is the student's actual state at that tick; the day summary is a separate thing (register[]). The row stays an immutable compliance document: deep snapshot of the teaching context (student/cohort/lesson/period names+codes, labels + wall bounds frozen in BOTH modes) + soft-FKs (hard FK only tenant/academicYear/student Restrict) → survives SG/timetable hard-delete/reorg. Recordable days = the timetable version whose valid-time interval governs D ∩ the department's school-days (calendar bounds − CLOSING − non-operative weekdays, operative = ≥1 placed lesson or attendance-bearing activity for the grade). One read pipeline (AttendanceBoardService.assembleGroupDays) backs every surface, so they agree by construction: two-tier board GET /attendance/groups (one card per class + one per grade for the classless, seven OVERLAPPING counters — classifyStudentDay(authored) returns every matching bucket, so a late-AND-early student counts twice and the seven do not sum to numStudents; numStudents is the denominator, only notTaken is disjoint (numStudents - notTaken = students with a register), absent = never appeared (every authored cell ABSENT), trip/suspension stay exclusive whole-day dispositions — toRegister = owed cells for slots already elapsed in school-local time (School.timezone), null0) → GET /attendance/rows on demand (per-student items[] = the timetable strip, classifications[] + register[] = the day summary, the latter projected from the records so a cell whose lesson has since moved survives — it appears in no item); GET /attendance/groups/pending (who owes what); GET /attendance/inconsistencies (day-state machine IN_SCHOOL/OUT_ABSENT/OUT_LEFT, 6 codes, localized {en_US,it_IT} messages, both modes, cells named from their OWN snapshot); GET /attendance/follow-ups[/summary] (family comms worklist — live since family-loop slice A; open = NOTIFIED/FOLLOW_UP or armed UNDER_REVIEW, one builder openFollowUpWhere(now)). Filter contract: structural ids + search select whole groups (counts cover the full roster), curriculumId narrows students inside a group, cohortType+cohortId = the single-group version of any widget. Writes: POST /attendance/records is a single-group, multi-slot batch — each entry carries its own BE-minted anchor {subjectGroupId ⊕ combinedClassId ⊕ activityId, startTick}, every student must be in the declared cohort (422 ATTENDANCE_ENTRIES_SPAN_GROUPS), all gates run before any write, one tx + audit.recordMany; both POST and PATCH /attendance/records/:id return the envelope {records, group, inconsistencies, followUps}. Write authority = in-service Y-set (lesson teachers/activity supervisors of the targeted slot + the student's homeroom tutor — the third teaches-the-student-same-day clause was DELETED 2026-08-02 with loadTeachesStudentOnDateMap; a same-day colleague no longer writes other slots' cells), bypassed by a grant-shaped school-wide check (AttendanceAccessService.isSchoolWideRegister(ctx, 'READ' | 'WRITE')— permissions recompiled from the caller's NON-teacher, non-parameterized roles;AttendanceService.isSchoolWideWriterdelegates) — broader than read; read =AttendancePolicy (admin/secretary/principal pass-through, teacher by department, DEPARTMENTparametric; referent/student read through their own family surface below). No@RequireRolesanywhere on the module — the gate is the grant. StatusAttendanceStatus {PRESENT,ABSENT,EARLY_EXIT,LATE_ENTRY,FIELD_TRIP,DAY_TRIP,SUSPENDED}; field rules (timeonly on transitions,notenot on P/A) in pureassertValidStatusFields; justification deleted (it belongs to the comms loop). Combined lessons: union roster, contributor-stamped cells. Activities: anchor triple, per-student mode/department resolution, supervisors as the Y-set clause. Value-change history lives in audit_log (entityType="attendance_record", .created/.updated/.admin_override). Family loop slice A (2026-08-04): AttendanceLoopServicemints/re-evaluatesAttendanceDayEvent rows INSIDE both write txs (attendance-loop.service.ts+attendance-loop.queries.ts— day classification → at most one ofABSENT/LATE_ENTRY; correction grace default 15m, env-configurable via attendance-timing.config.ts; in-grace correction → CANCELLED, armed → SUPERSEDED, resolved states immutable, re-open clears timing/ack facts); visibility is read-time armAt <= now (the slice-D sweeper acts on the same clock — see the Notifications block below); office ack PATCH /attendance/follow-ups/:id/ack (register:write+manage_communications, audit-logged, allowed in-grace; records a full justification on the family's behalf — single-day row, channelPHONE/IN_PERSON, event stamped, ch19 §11.1 (2026-08-10);APPchannel = slice-C justifications;ATTENDANCE_EVENT_NOT_FOUND404 /ATTENDANCE_EVENT_ALREADY_RESOLVED409). Spec2026-08-04-attendance-family-loop-design.md. Family read surface, slice B (2026-08-04): AttendanceFamilyController (/attendance/family/*, three read-only GETs) + AttendanceFamilyService+attendance-family.queries.ts— referent reads linked students, student reads self (grades-visibility idiomfamilyStudentAccessWhere; role gate = AttendanceFamilyPolicy, referent+student pass-through, everyone else fails closed — load-bearing because a teacher's register WRITE passes the readscope gate).to-justify(cross-children open events, slice-A rule, paginated,surfacedByNamefrom the earliest matching cell's recorder snapshot),students/:id/overview(record-derived day stats excl. suspended/trip + authored-cellpresentHoursapproximation vsDepartment.minPresentHoursPercentage+ family-visible history),students/:id/days/:date(the admin row via the sharedbuildStudentRowCore, events filtered to familyVisibleEventWhere(now)and reduced — noackReason/ackByName/lastContactAt). CANCELLED/SUPERSEDED/in-grace rows never reach a family response; unlinked target = generic 404 (existence-hiding). RBAC delta: referent+student gained attendance.registerREAD (prod reseed). Spec2026-08-04-attendance-family-read-surface-design.md, ch19 §11.2. Justifications, slice C (2026-08-04): AttendanceJustification(immutable — no PATCH/DELETE/review; kindABSENCE/LATE_ENTRY/EARLY_EXITwith kind-conditional shape; the EARLY_EXIT pickup-person selector was removed by product decision 2026-08-10 — columns dropped,isAuthorizedPickupflags survive unconsulted) + nullable provenance FKAttendanceDayEvent.justificationId (SetNull; cleared on re-open, re-stamped when still covered). The covering rule (ABSENCE covers ABSENT+LATE_ENTRY; LATE_ENTRY covers itself; EARLY_EXIT nothing) applies at exactly two event-driven seams — mint time (covered event born NOT_REQUIRED, armAt NULL, stamped; findCoveringJustificationis real) and submit time (in-tx sweep of covered live events, in-grace included, →ACKNOWLEDGED/APP/name-snapshot/ackReason NULL/stamp) — never at read time; coverage removes events from the open set at both seams, so slice D's sweeper can never send for a covered event (the no-notify invariant, zero D-coordination). Row surfacing once: justifications[] (RowJustificationDto) nests on every student-day surface via the one shared AttendanceJustificationsReadService.getRowBlocks (buildStudentRows→ group rows / admin day register / write envelope; family day view attaches the identical block); officedayEvents[].justificationId rides along. Family routes on the family controller (POST/GET /family/justifications, POST …/:id/files, GET …/:id/files/:fileId): read scope + AttendanceFamilyPolicy + — writes — the new attendance.justifyaction (referent preset only,REFERENT_ACTION_KEYSinprisma/seed/roles.ts, self-applies via frozen-preset reconciliation; student reads, never submits) + per-link canWrite (assertReferentCanWrite); deliberately NOT register WRITE. Attachments = the shared collection-file path with the first non-person owner (FileUsage.JUSTIFICATION+FileOwnerType.JUSTIFICATION, cap 5, ABSENCE/LATE_ENTRY only); school download GET /attendance/justifications/:id/files/:fileId(insights controller) =AttendanceCohortReadPolicy (fails closed for families, who hold register READ) + in-service fence: school-wide outright, teacher taught-set only (studentRelatedToTeacherOn, product ruling 2026-08-04), miss = hidden 404 JUSTIFICATION_NOT_FOUND. Audit: one attendance_justification.createdrow withresolvedEventIds(or.replacedon an in-place overwrite;supersededIdswhen an absence swallowed rows). Consistency matrix + office list (QA batch 2026-08-07, spec2026-08-07-referent-qa-bugs-design.md): the create path enforces a matrix inside its tx — a day covered by an ABSENCE (or an overlapping ABSENCE) refuses 409 JUSTIFICATION_CONFLICT(params name the blocking kind+range); a same-kind same-day resubmission overwrites in place (same row id — provenance + attachments survive); an ABSENCE supersede-deletes the single-day rows in its range (+attachment cleanup +restampEventProvenancere-points danglingACKNOWLEDGED/APPandNOT_REQUIREDevents); LATE_ENTRY + EARLY_EXIT coexist. Plus the admin mirrorGET /attendance/justifications (AdminJustificationRowDto= family DTO minuscanEdit— dropped 2026-08-10;date-covering/studentId/kindfilters; follow-ups gate — board policy +manage_communications) making advance announcements office-visible. Harmonized surface (2026-08-10): to-justify gains studentId/fromfilters + per-itemsuggestedKind(the covering rule's third reading, published so no client re-derivesABSENT→ABSENCE); the family list gains kind+studentName; canEdit has ONE definition (canEditJustification, attendance-justifications.shared.ts) shared by the list and both mutation responses; the 1217-line service split three ways — writes+matrix / read projections (toFamilyDto/toAdminRowover onebaseProjection) / attachments — writes → reads the only cross-dependency. Specs 2026-08-04-attendance-justifications-design.md+2026-08-10-family-attendance-surface-harmonization-design.md, ch19 §11.3. Entity ATTENDANCE, field-level registerscope, actionstake+manage_communications(both require register write) +justify(referent, register READ). The day is frozen too (2026-07-26):AttendanceRecordfreezes the student's fact andAttendanceDaySlotfreezes the slot shape used by a write. Since spec E the manifest is write-path evidence onlyfreezeDaystill writes it, nothing authoritative reads it, and disposal is F's call. B's reason for keeping it in the read path (a retained version freezes geometry but not rosters) was closed by C1–C3 giving memberships their own valid time. Dated resolution landed 2026-07-27 (spec B); expected ATTENDANCE landed 2026-07-29 (spec E). Thestatus: 'PUBLISHED'oracle is gone from every attendance path, and so is every live-row read on the register. Satelliteattendance/expected/(no own module/barrel, nothing outsidesrc/attendance/may import it): B'sExpectedScheduletype +ExpectedScheduleResolver.resolveExpectedSchedule(db, tenant, ay, date, schoolToday)composing A'sfindTimetableVersionEffectiveOn(via thesrc/timetablespublic barrel) withloadDaySlots— 7 queries per date; plus E'sExpectedAttendanceResolver.resolveExpectedAttendance(…)returning{date, timetableId, slots, byStudent, schedule, indexes, contributorBySlot}, expected-attendance.queries.ts(six AY-scoped as-of-D loaders) and the pureexpected-audience.ts (expandAudienceOn). The equation: geometry from the version effective on D × rosters/teachers/audiences from the intervals effective on D. ≤ 8 queries beyond B's 7 (spec §5.1 budgeted seven; the eighth is the activity audience-selector read, since DaySlotRow carries expanded rosters but not the selectors that produced them). Replace, never merge — every roster/teacher array overwrites B's live value wholesale, or the leakage E removes comes straight back; teacher names are the one cache (seeded from the live hydration, residual ids only). Placement is the candidate universe for EVERY audience kind, not just STUDENT: an exited student with a stale-open homeroom or selection row must not resurface through HOMEROOM/CURRICULUM. Every fact loader is AY-scoped (a tenant-wide loader let an overlapping year inflate EVERYONE). One handle per resolution, threaded explicitly; passed a root client it opens one RepeatableRead tx, passed a tx it reuses it and opens nothing (the write's tx already holds A's shared AY-day lock — note PrismaService.$transaction joins the ambient tenant tx, so under a request the ambient tx and its isolation level govern). Swapped onto it: loadGroupCatalog(…, date), loadCohortRosterIds(…, date), loadStudentSnapshots(…, date), loadOperativeWeekdaysForGrade(db, tenant, timetableId, gradeId, indexes). (The Y-set's teaches-clause and its loadTeachesStudentOnDateMap were deleted 2026-08-02 with the teacher surface.) The pre-resolver stratum (AttendanceDayShapeService.resolveDaySlots, loadRegisterRoster, loadLessonForTake, loadActivityForTake, loadPeriodDurationCascade, resolvePeriodSlot, buildStudentTeacherMapFromDaySlots, loadStudentTeacherMapForTimetableDay, enumerateSchoolDays) was deleted 2026-07-29freezeDay + loadFrozenDaySlots are the day-shape service's only remainder. The live-vs-frozen fork in planAnchors/planLessonAnchor/planActivityAnchor collapses: one path for every date, and ATTENDANCE_DAY_SHAPE_UNAVAILABLE was retired 2026-07-29 (enum + i18n + examples deleted; a day resolving with no slot at the anchor answers the ordinary 404). One tick holds one fact whatever the anchor (2026-07-29): entries are unique per (student, startTick) (a two-anchor batch on one cell 400s), and a sequential cross-anchor re-assert supersedes — the update re-stamps the full snapshot (same-anchor updates stay value-only; recordedBy*never moves) and the auditdatacarries{anchorKey, previousAnchorKey}. School-day derivation is one branch (the board keeps a timetableId === nullarm the write path answers with its 409 gate instead). Two guards ship with E.src/common/temporal/temporal-delete-guard.ts(barrel-exported):isEmptyInterval, hasEverBeenLived, assertRowDeletable, closeInsteadOfDelete, assertParentDeletableclosed+non-empty ⇒ immutable at row level, empty[X,X)erased, open closed; wired at six anchor-delete sites (homeroom / subject-group / combined-class / teacher / student / curriculum-sweep), each guard+delete in ONE tx with the root rowFOR UPDATEfirst (§2b #21c), each judging both a lived child interval and A'shasEverEffectiveContentReferencing— either alone refuses withTEMPORAL_ROW_IMMUTABLE. Lived, not merely effective (2026-07-30): both arms additionally require overlap with [the year's earliest department calendar start, today] (loadCalendarFloors), so pre-year structures — episodes effective today included — delete freely, and pre-year every omitted validFrom defaults to today (SETUP_PRE_YEARretired; contract §17 addendum).src/departments/retro-calendar-guard.ts(module-internal): calendar bounds andCLOSINGperiods may not change what a past day meant →TEMPORAL_BACKDATE_FORBIDDEN; the rule is the affected-day set, half-open on the moved boundary's own side (start → [min,max), end → (min,max]), so an END moved yesterday→tomorrow passes while last-week→yesterday refuses. TERM periods exempt. Reachable only on a DRAFT-year calendar PATCH and the setup bulkSync— every other route is already DRAFT-gated. Thecompletenessmarker andSchool.temporalEpochare RETIRED (2026-07-29, migration20260729140000_drop_temporal_epoch): ExpectationCompletenessand its field on five DTOs are deleted (FE-visible). E closed the window it existed for, and attendance was never released, so no date predates a tenant's schedule history. The epoch was never load-bearing — resolution never branched on it. E adds no schema, route or RBAC delta.Homeroom.homeroomTeacherIdstays live by explicit v1 ruling (contract §2b #8) — identity live, membership dated; accepted consequence: a tutor change re-scopes past-day write authority. Teacher surface (2026-08-02):GET /attendance/teacher-day (AttendanceTeacherService, teacher-only AttendanceTeacherPolicy) — the caller's whole day for the date (iteration 3 2026-08-12: LESSON and supervised ACTIVITY cards, kind-discriminated, activity mode = its single department's; combined meetings as one slot; per-slot register-duty flag needsAttendance— PERIOD every slot, DAILY the day-opener per student over all slot kinds — and mode-shapedrecordedcounter;duties[]= the caller's break/lunch supervision slots resolved from the version effective on the date, informational only;otherGroups[]= every other taught SG with dated roster counts; empty fail-soft answer without a teacher profile);cohortTypewidened toSUBJECT_GROUP/COMBINED_CLASS(and 2026-08-12 ACTIVITY — supervisor-gated, as-of-D audience roster) on every surface it appears (reads via the syntheticloadSubjectCohortGroupgroup in the same pipeline; writes vialoadCohortRosterIdsbranches; PATCH gains an optional cohort echo),/rows+/inconsistenciesteacher-reachable behindAttendanceCohortReadPolicy + the in-service own-group gate (AttendanceAccessService.assertTeacherCohortRead403 ATTENDANCE_NOT_AUTHORIZED_FOR_COHORT). Notifications, slice D (2026-08-04): AttendanceNotifierService — the platform's first scheduled job (@nestjs/schedule, dynamic SchedulerRegistryinterval; timer off underNODE_ENV=test, sweep()e2e-driven): per tenant underwithTenantGuc, per-row CAS claims flip armed UNDER_REVIEWNOTIFIED (+send('attendance.event')) at armAtandNOTIFIEDFOLLOW_UP (+send('attendance.reminder')) at escalateAt; no third strike; audience = linked referents with accounts; no audit rows (the sweeper is not a who — notifiedAt/remindedAt are the evidence); cadence env-configurable (ATTENDANCE_ARM_GRACE_MINUTES/ATTENDANCE_REMINDER_DELAY_MINUTES/ATTENDANCE_SWEEP_INTERVAL_SECONDS, resolver attendance-timing.config.ts— the loop's grace reads the same file). Zero migration/RBAC/routes — every column and state was reserved in slice A;openFollowUpWherealready countedNOTIFIED/FOLLOW_UP, so no reader changed. Engine: notifications/+ 23. Materialization (2026-08-10): a family communication about today or a day ahead pre-fills the register —AttendanceMaterializationServicewrites real cells inside the submit tx, stampedrecordedBy*= the submitting parent + the new soft columnAttendanceRecord.justificationId(nullable +[tenantId, justificationId]index; migration, no reseed, no RBAC delta, no config). Create-only by construction: onecreateMany({skipDuplicates:true})per date againstattendance_cell_uniqueis the never-overwrite rule (not an upsert — a parent must not flip aPRESENT a teacher recorded, which is what makes including today* safe), and the transition kinds write exactly one cell with no fabricated PRESENT around it (ABSENCE fills the day: PERIOD every slot, DAILY the opener). Every date-level obstacle (no version governs D, no slots, not a school day, tick taken, past MATERIALIZE_MAX_DAYS = 30) is a silent skip — the submission never fails. Per date, ascending (deterministic lockAyDayShared order or overlapping ranges deadlock): freeze → insert → audit.recordMany (attendance_record.family_announced) → mintForStudentDays, so the no-notify invariant holds with zero coordination (the row already exists → event born NOT_REQUIRED, armAt NULL). Withdrawal is the module's only cell-deletion path — hard, bounded to justificationId = X AND lastModifiedByUserId IS NULL (a human-edited cell is adopted and survives), then re-mint, then the existing reopenForJustification; PATCH dematerializes totally (a time edit moves the transition slot, so an outsideRange-shaped delete would strand the old cell). Past-dated submissions are answers: gated on a family-visible event of a covered type in the past portion (hasFlaggedDayInRange; one flagged day suffices; EARLY_EXIT can never qualify) else 422 JUSTIFICATION_DAY_NOT_FLAGGED, and no deadline ever — a configurable window was designed and deleted as the wrong axis (opening vs answering). planAnchors + the school-day resolver moved to the plain-function module attendance-planning.ts purely to break the cycle AttendanceService → AttendanceBoardService → AttendanceJustificationsService; the materializer must never inject AttendanceService. Reads surface justificationId on items[].record + register[] (null on staff-authored and on projected items). See 19 + specs 2026-07-25-attendance-admin-day-register-design.md + 2026-07-26-attendance-day-shape-snapshot-design.md + 2026-07-26-attendance-temporal-c3-dependency-closure-design.md + 2026-07-26-attendance-temporal-e-expected-attendance-resolver-design.md + 2026-08-02-teacher-attendance-surface-design.md + 2026-08-04-attendance-loop-notifications-design.md. | | subject-groups/combined-classes/ | Combined (co-taught) classes — first-class teaching units since iteration 3 (2026-07-12): a CombinedClass links 2+ same-department SGs taught together for sharedWeeklyHours, and the shared meeting is ONE ScheduledLesson anchored on the combination (subjectGroupId XOR combinedClassId, DB CHECK scheduled_lessons_anchor_xor; combination delete cascades its lessons, members unlink via SetNull). Members keep their own roster/grading/per-subject reporting; solo hours derived (cell − shared). No gradeId (cross-grade combination), department derived. RBAC reuses SUBJECT_GROUPS; admin/department_head only. CRUD POST/GET/GET:id/PATCH/DELETE /combined-classes with an ordered validation envelope (validateMembers); admissible members = any SG including option-block children (the v1 fence + COMBINED_CLASS_MEMBER_IS_OPTION_BLOCK_CHILD are deleted — the solver pair-tiles). Cross-module integration: dual-anchor lessons + TeachingUnitRefDto views + per-unit HOUR_BUDGET + expanded option-block occupancy (18 — the v1 co-location machinery / COMBINED_SHARED_MISCOUNT / ROOM_SPLIT are deleted), fit-report alerts (timetable-templatesCOMBINED_DURATION_MISMATCH/SHARED_NOT_DIVISIBLE/CLASS_DEGENERATE), generation first-class COMBINED group under the real id + direct-anchor ingest (generation/combined-rewrite.ts), union attendance register with write-time contributor attribution (19), and kind rows on GET /subject-groups/table (14). Membership became valid-time (spec C3, 2026-07-28): the mutable SubjectGroup.combinedClassId pointer is now the forward head (terminal planned state — it still carries the ≤1 combination per section guarantee and the COMBINED_CLASS_MEMBER_ALREADY_COMBINED check) paired with the new CombinedClassMembership episode table (combined_class_memberships_concurrent_excl, RLS Class-S, re-synced in the same tx by setCombinedClassMembers and nowhere else). subjectGroupIds stays set-semantics; the write is a diff on ONE command-wide X (one resolveEffectiveDate per distinct grade, then the chronological max), echoed as appliedFrom with pendingChanges[] ({subjectGroupId, kind: ADDITION | REMOVAL, on}); a name/hours-only or no-op-member edit rejects validFromwith400. membersand the courses table's combined side readeffectiveOn(schoolToday). Because a scheduled leave releases the head immediately, a mid-year move is two commands sharing one X (leave CC-1, join CC-2) and both sides report it as pending until X; loadCombinedClassMembersOn(db, ids, D)answers CC-1 forD < Xand CC-2 from X. See ch.14 §6.2 + spec2026-07-12-combined-classes-iteration-3-design.md(supersedes the representation layer of2026-07-07-combined-classes-design.md) + 2026-07-26-attendance-temporal-c3-dependency-closure-design.md. | | grades/ | Student marks — criteria-derived, teacher-authored, family-visible immutable ledger. GradeEntry is the stable identity/head; GradeEntryRevision + GradeCriterionMarkRevision are append-only full snapshots with frozen dated teaching/student/curriculum context, scale JSON, the complete criterion-definition set, optional assessment title, authored values, stored effectiveMark/0–100 scalar/level, actor display provenance, reason, operation, and calculation version. The fixed authoring taxonomy is WRITTEN/ORAL/PRACTICAL/ASSIGNMENT/PROJECT; the SG gradebook exposes compact ordered live authoring scales while every grade DTO exposes its revision-frozen scales. assignmentDate is assessment valid time, contextDate selects dated roster/course headers, and asOf selects transaction-time state; every aggregate surface can read a non-active academicYearId. PATCH/withdraw require optimistic expectedRevision + reason; DELETE is removed and POST /grades/:id/withdraw appends a tombstone. Ordinary views exclude a withdrawn selected revision; history is revision-native, newest-first, and remains authoritative after withdrawal. Pre-ledger rows are honestly LEGACY_INCOMPLETE; no invented evidence and no scalar contribution. Forced RLS, Restrict identity FKs, immutable-revision/root-identity triggers, head consistency, and no normal purge path protect the operational record. Audit is only the cross-cutting projection (.created/.updated/.admin_override/.withdrawn), not the grade-history source. Active-year writes admit dated SG teachers and authorized office/parameter roles; post-year corrections are admin/platform/principal only. No grade notifications and no claim of qualified preservation/conservazione a norma. See 21 + iteration-2 spec/plan + iteration-3 spec 2026-08-13-grades-surface-iteration-3-design.md. Participation (2026-09-03): every roster/counter is membership-on-contextDatestatus ≠ PRE_ENROLLED (exits stay dated by the membership close); a mark for a pre-enrolled member fails GRADE_STUDENT_NOT_IN_SUBJECT_GROUP. | | disciplinary-notes/ | Student behaviour notes: student-centric CRUD gated by disciplinary_notes.record, armed family delivery on the shared attendance cadence (second scheduled job), one shared referent ACK that locks the note; withdrawal is a tombstone (410), never a row delete | Current-day relationship policy: school-wide office/head, scoped department/curriculum managers, canonical teacher taught set, and currently linked referents. 25. Participation (2026-09-03): cohort lists carry the live ENROLLED fence; the writable-date gate adds status ≠ PRE_ENROLLED beside the placement interval (DISCIPLINARY_NOTE_STUDENT_NOT_PLACED, copy widened). | | communications/ | Email blasts sent on-behalf-of through the school's verified Resend identity (2026-08-05, ch24). Sending rides communications.send (teacher, referent, and every management preset); the mailing-group surface (mailing-groups/) independently rides the gate-only communications.mailing_groups scope (READ browse/use, WRITE own private groups; withheld from referent and student by the 2026-08-11 split), while shared-catalog curation additionally requires service-side communications.management WRITE (admin). Groups store person references, not addresses, and private members must clear the classifier. The contactable-set classifier (recipient-policy.ts) remains THE single authority on sender reach: ADMIN/TEACHER/STAFF/REFERENT tiers and the dated four-leg taught set. POST /communications resolves groups ∪ inline refs → classifier → QUEUED snapshot + audit; group ids require communications.mailing_groups READ before hidden-404 resolution. Reply-To is backend-derived per tier; filteredOut is a count only; attachments are ≤10 × 10 MB / 25 MB total with the extension-validated 12-format whitelist and canonical MIME normalization in FilesService.uploadBlob (2026-08-07, ch24 §4). The sweeper (sender/) uses per-tenant withTenantGuc, CAS claims, batch-100 or single-with-attachments, and send-then-mark at-least-once. The Svix webhook feeds the per-recipient delivery ledger (communications-delivery.service.ts, monotonic transitions on AdminPrismaService); Resend templates use published communication-<kind>-<lang> aliases. CC audience (iteration 2) carries kind TO | CCon ledger + group snapshots with To-wins dedupe and one classifier pass. Recipient inbox (iteration 3) is an auth-only, self-scoped/communications/inbox, view-agnostic and visible at status != PENDING; readAt is never shown to the sender. Archive is own-only for every role, admin included. | Ch. 24; specs 2026-08-05-communications-module-design.md + 2026-08-11-communications-mailing-groups-rbac-split-design.md + -cc-iteration-2- + -inbox-iteration-3-; binding guide docs/guida-notifiche-email-resend.pdf. Participation (2026-09-03):* STUDENT candidates are ENROLLED-only and the REFERENT link witness always exists and requires ≥1 ENROLLED child (also without cohort filters); partitionContactable rides the same WHEREs, sweeper untouched. | | 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 | | assignments/ | Admin/Teacher CRUD for homework/tests plus the shared course-day lesson summary used beside attendance. School/family routes preserve the temporal late-join archive and non-temporal referent-link rule for retained rows; explicit delete removes a row from every projection. | Canonical behavior, visibility, authorization, and audit vocabulary: chapter 26. |


Audit-log read contract (iteration 4, 2026-08-14): the iteration-3 target/query-free projection described in the dense module row above is now compatibility-only. Current entries add a backend-authored localized summary, complete typed references[], and an exact semantic primaryLink { destination, section, params, query }. The page-scoped resolveAuditEntryPresentations resolver uses bounded tenant-scoped grouped lookups and fails soft for deleted/legacy references; target remains but is deprecated. Canonical behavior and the destination matrix are in chapter 20 and the approved docs/superpowers/specs/2026-08-14-audit-log-navigable-summaries-iteration-4-design.md.

Attendance coverage correction (2026-08-11): justification matching is exact: ABSENCE → ABSENT, LATE_ENTRY → LATE_ENTRY; EARLY_EXIT covers no event. A different recorded follow-up state creates its own to-justify item after the arm window. This supersedes the older ABSENCE covers ABSENT+LATE_ENTRY phrase in the dense attendance row above; the existing justification-conflict matrix is unchanged.

Filter algebra correction (2026-08-24): this supersedes the older “union chips” wording in the dense homerooms/, subject-groups/, and task index rows. Multi-values OR within one dimension (IN); different filter dimensions AND on tables, student lists, rollover, placement-readiness companions, and the communications recipient picker. /filters/* cascades left-to-right as department → grade → curriculum → homeroom; clearing stale selections to the right is a frontend state responsibility. Canonical behavior: chapter 14 §5.2. Visibility-projected filters (2026-08-26): the module has ten GET routes (including rooms), and every filter request now requires surface=students|homerooms|subject-groups; the service first applies that surface's canonical record-read policy and then derives only values reachable from the visible rows. Search, pagination data, and meta.total share that projected predicate. rooms is valid for homeroom and subject-group surfaces and returns rooms in visible source departments plus shared (departmentId = null) rooms when the projection contains at least one department. Canonical behavior: chapter 14 §5.2 and the 2026-08-26 visibility design. Timetable student participation (2026-08-28): /filters/students?surface=timetables&timetableId=... is the canonical timetable participant picker. It composes StudentsReadPolicy with the anchored timetable AY, Student.status = ENROLLED, and display-cohort department/grade filters; unlike the other timetable filters, it returns authoring candidates rather than ids already represented by surviving cells. Both live activity/break resolvers apply the same ENROLLED + AY fence outside every selector OR. Direct selector writes reject wrong-year/non-enrolled students with SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE; audience-derived record authorization remains stricter and requires effective placement on schoolToday. Bulk curriculum assignment deliberately keeps ENROLLED | PRE_ENROLLED and skips terminal rows as NOT_APPLICABLE. Canonical: chapter 18, chapter 04, and the approved 2026-08-28 design.

Attendance participation fences (2026-09-03): every attendance roster read (loadPlacedStudentsOn, the board catalogue, the write fences, countSubjectGroupRostersOn) carries participationHistoryStatusWhere() beside its dated predicate, so PRE_ENROLLED students are expected on no date while exits stay interval-dated; the follow-up worklist, the admin justification table, the notifier due passes and the family lists carry the live participatingStudentWhere(). loadAttendanceCohortOwnershipOn stays status-neutral on purpose. See chapter 19 §1.

Attendance rows occurrence selector (2026-08-12): GET /attendance/rows uses the route-specific AttendanceRowsQueryDto; optional startTick narrows items[] to one occurrence (teaching-unit cohorts match anchor + tick), while register[], classifications, events, and the group card remain whole-day facts. See docs/19-attendance.md §5.1.

Notifications push extension (2026-08-13)

The notifications/ module-map row above is extended by the approved FID push design: notification-push.registry.ts owns localized copy; push-notification.transport.ts is the third always-on delivery leg; push-sender.interfaces.ts plus log-push.sender.ts, memory-push.sender.ts, and fcm-push.sender.ts form the module-internal sender seam; and push-registrations.controller.ts / .service.ts expose the auth-only, self-scoped body-based registration API. Canonical behavior is chapter 23 and docs/superpowers/specs/2026-08-12-push-notifications-design.md; the web/Expo handoff is docs/fe-guides/2026-08-04-notifications-FE-guide.md. Older text in the module-map row that calls push “deferred” is superseded by this extension.

Notification catalogue extension (2026-08-25)

The Product-approved timetable, teacher-assignment, roster-movement, curriculum-choice, and profile-completion kinds are governed by chapter 23 §5 and docs/superpowers/specs/2026-08-25-notification-catalogue-implementation-design.md. Command consumers live at the timetable lifecycle and homeroom/subject-group episode seams and defer sends with PostCommitCoordinator. Automatic family milestones live in src/command-center/notification-milestones.{service,queries,config}.ts; ScheduledNotificationClaim is their Class-S, strictly-tenanted, tenant-reset-covered at-most-once ledger. The exact payload and route-neutral targetType contracts are in docs/fe-guides/2026-08-04-notifications-FE-guide.md; concrete FE routes and every manual reminder action remain deferred.

Assignment attachments extension (2026-08-25)

The assignments/ module-map row above now includes mixed FILE/LINK resources. assignments-attachments.service.ts owns limits, staged-blob compensation, locked post-create CRUD, audit, cleanup, and signed URL minting; assignments-attachments.queries.ts owns the tenant-bound wrapper access and batch projection. Direct JSON create remains valid, while the same POST also accepts multipart files plus JSON-encoded links. Canonical behavior is chapter 26, the approved iteration-3 design, and the FE contract is docs/fe-guides/2026-08-25-class-register-assignment-attachments-FE-guide.md.

Role-matrix v3 correction (2026-08-20). For attendance authorization, the legacy isSchoolWideRegister wording in the module synopsis above is superseded by resolveManagementAttendanceScope: qualified non-parametric management grants are tenant-wide, while qualified DEPARTMENT and CURRICULUM grants resolve to an allowed-department fence. Teacher authority remains a separately qualified alternative limb. See the attendance task-map row below and 19 §7–§8. The same v3 matrix supersedes the older module-map wording that calls audit-log and timetable-template grants admin-only: Director and Department Principal read the tenant-wide audit log; Department Principal writes and Curriculum Coordinator reads the tenant-wide template catalog. Selection-window actions are also granted to qualified Curriculum Coordinators. Curriculum-selection reads are tenant-wide for Director, HR Manager, Front Office, and Administrative Assistant. Front Office holds WRITE (and therefore READ) on every native students.* scope; only the platform-hidden dynamic students.others scope remains ungranted. Selection and placement writes use qualified role slices: Front Office and Administrative Assistant are school-wide, Department Principal is department-bound, and Curriculum Coordinator is curriculum-bound; referent selection self-service remains a separate linked-student limb. Referent directory/document wording in the older module row is also superseded: HR, Front Office, and Administrative Assistant receive tenant-wide profile and document reads, the Front Office function may edit contacts but not email, and Curriculum Coordinators see only referents linked to students selected into their qualified curricula (with no referent-document reach).

Timetable base-room ruling (2026-08-26): SubjectGroup.baseRoomId is a new-lesson default only. A scheduled lesson may use any other in-tenant room; base-room mismatch no longer emits SUBJECT_GROUP_NOT_IN_BASE_ROOM or blocks publish, generation does not collapse compatible rooms or run SUBJECT_GROUP_BASE_ROOM_INCOMPATIBLE, and combined-class members may have different defaults. runPreChecks now has 8 categories. This supersedes the older hard-force/count wording elsewhere in this reference; canonical detail is in chapter 18 §1/§8 and chapter 14 §5.

Teacher list contract note: GET /teachers accepts opt-in multi-value status filtering over PeopleStatus; omitting it remains status-neutral. Active-only pickers must request status=ACTIVE explicitly so the predicate applies before pagination and counting.

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 across every classified tenant-bearing table (80 of 88 Prisma models carry a policy; the remaining 8 are excluded by written design reason — src/prisma/rls-coverage.ts); 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).
  • Transaction exceptions and budgets: named route budgets use @TenantTxBudget + TX_BUDGETS; a nested ambient join may not carry timeout/maxWait. Tenant-authenticated @NoTenantTx external-I/O routes must put every DB access in explicit withTenantGuc phases and scope only their write transaction for post-commit effects. Timetable generation is the reference implementation. ch02 §4.0.
  • 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.

Effective-dated student membership (temporal C2)

  • HomeroomAssignment, SubjectGroupAssignment, and StudentCurriculumSelection are half-open [validFrom, validUntil) histories, not singleton current-state rows. Operational reads and counts use effectiveOn(capturedSchoolToday); command gates normalize the current row plus at most one pending transition. Roster DELETE closes/cancels an interval rather than deleting it, group details expose policy-filtered pendingChanges[], and selection reads expose the complete pendingSelection. Canonical implementation: src/common/temporal/interval.ts, the three domain query files, src/students/class-selection-sync.ts, and docs/14-homerooms-subject-groups.md. C2 design/plan: docs/superpowers/specs/2026-07-26-attendance-temporal-c2-membership-valid-time-design.md and docs/superpowers/plans/2026-07-27-attendance-temporal-c2-membership-valid-time.md.

Student participation (2026-09-03)

  • Only ENROLLED students take part in the rolling year. Two builders in src/students/constants/participation-status.ts, never merged: participatingStudentWhere() (status = ENROLLED) on every read anchored on today — worklists, pickers, recipients, invitations, dashboards, counts, notification audiences; participationHistoryStatusWhere() (status ≠ PRE_ENROLLED) beside an effectiveOn(D) interval predicate on register-style reads (attendance rosters, gradebook rosters, the disciplinary create gate), so exits stay interval-dated and history is intact. Deliberate exceptions keep {ENROLLED, PRE_ENROLLED} and must not adopt the builders: selection-window.queries.ts, bulk curriculum assignment, the command-center selection-lifecycle tab, notification milestones, rollover eligibility; the students directory, its filter surface and by-id history reads stay status-neutral. Exits (LEFT, GRADUATED, one set) also revoke platform access in the students PATCH tx; readmission restores it. Design: 2026-09-03 spec.
  • Teacher–department links follow teaching: assigning a teacher to a subject group or homeroom tutorship materializes the TeacherDepartment link in the same tx (ensureTeacherDepartmentLinks, called from syncSubjectGroupTeachersInTx + the homeroom tutor writes); a department required by active/future ties cannot be removed from the teacher (409 TEACHER_DEPARTMENT_REQUIRED); the import auto-unions; ending a tie never deletes a link. docs/14-homerooms-subject-groups.md §7b.

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, and the evaluation-scale value 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; ScheduledLesson.startTick is not an ordinal at all — it's a start tick (5-minute steps from midnight, sparse by construction; day-template slots no longer exist).

Timetable durations & templates

  • A lesson's duration is the pure curriculum periodDuration cascade (cell → subject → grade → curriculum) and nothing else. Curriculum.periodDuration is NOT NULL (the cascade terminal), so resolution is total — never re-introduce an envelope/template fallback or a nullable duration. Break/lunch slots store their duration on the row instead. docs/18-timetables.md.
  • Day/week templates are generation-only inputs: nothing outside src/timetables/generation/ may import the effective-template machinery (generation/effective-templates.ts) or otherwise couple to template envelopes. Attendance derives day shape from the published timetable's placed lessons; the manual flow has no day-shape gate (day bounds were removed 2026-07-13).

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.
  • Every new validated identifier needs an i18n label — a class-validator-decorated request-DTO property or import-schema column: key needs a FIELD_LABELS entry, an @IsEnum-referenced enum's members need VALUE_LABELS, and a new Prisma model or entity: literal needs ENTITY_LABELS (all in src/common/i18n/labels/, both languages). label-coverage.drift.spec.ts scans the source mechanically and fails on any gap or dead label. docs/06-error-handling.md.

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. Structural changes (valid-time interval commands — who moved what, effective from when) use the sibling primitive AuditService.recordStructuralChange(tx, input) instead: same transaction rule, but a fixed {v:1, before, after, effectiveOn} envelope, a module-owned vocabulary (structural-audit.constants.ts — never inline the strings), and entityId anchored on the stable domain fact rather than the mutated row. See docs/20-audit-log.md.
  • A family-visible disciplinary note becomes immutable after the first explicit referent acknowledgement. Withdrawal is a tombstone: an authorized current referent who was previously notified gets 410, while an unlinked or never-notified caller stays behind 404. 25.

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.
  • A new tenant-bearing model needs three drift-guarded registrations: an RLS policy in the migration + a class entry in src/prisma/rls-coverage.ts, the Prisma model name in src/prisma/tenanted-models.ts (STRICTLY_TENANTED_MODELS, required-tenantId models only), and a ch12 raw-SQL-constraints row + name in test/db-constraints.e2e-spec.ts. Missing any one fails tenanted-models.drift.spec.ts / rls-coverage.drift.spec.ts / db-constraints e2e. docs/02-multitenancy.md §4.0.1 + 12.

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.

Roles-catalogue invariant: GET /roles and GET /roles/presets keep the profile-coupled role filter, but each returned management role's groups matrix is a full inventory of every current catalogue entity, scope (NONE included), and action. Each entity entry includes backend-owned localized label, scopeLabels, and actionLabels; stable grant maps remain primitive and backward-compatible. Canonical builder/query: PermissionsService.buildRoleCatalogItem + src/permissions/permissions.queries.ts; grouping and catalogue drift are pinned by src/common/constants/entity-groups.spec.ts and src/common/i18n/permission-catalogue-labels.drift.spec.ts. The caller-facing roles.management grant is admin-only, matching @RequireRoles('admin') on GET /roles and /role-assignments; every non-admin preset receives the present-but-empty roles shell from GET /permissions. Communications and attendance grants are code-authored in prisma/seed/roles.ts; their dated data migrations remain the historical initial rollout, while the uniform Railway pre-deploy now runs migration → seed → db:reconcile-rbac. That final transaction adds, updates, and prunes every recognized global/tenant preset to expectedPresetGrants, verifies the result, and blocks promotion on drift. AttendanceAccessService.resolveManagementAttendanceScope keeps Admin/Front Office/Administrative Assistant school-wide and HR/Director read-only, while Department Principal/Curriculum Coordinator register-taking writes remain parameter- or related-Teacher-scoped. Migration 20260826123000_grant_management_attendance_followup_actions grants manage_communications and justify to the then-current management writers; the action grants do not widen the attendance record policies or the family-only justification route fence.

The 2026-08-28 management migration originally downgraded every Director preset to attendance READ/no actions and granted students.health READ to Curriculum Coordinator; Tier-1 backfill created administrative_assistant (the exact HR + Front Office union). Ongoing convergence no longer needs a bespoke grant migration: the final reconciler owns all preset metadata and grants in every existing tenant. Custom roles and assignments stay untouched. Canonical: railway.toml, tools/reconcile-preset-roles.ts, docs/15-seeding.md, and docs/superpowers/specs/2026-08-28-authoritative-preset-role-reconciliation-iteration-1-design.md.

Placement-readiness routing (part of this section's task index):

Task Files to open Chapter
Change the homeroom unassigned-students companion src/homerooms/{homerooms.controller,homerooms.service,homerooms.queries,homerooms.swagger}.ts, src/students/curriculum-selection.queries.ts, src/students/dto/student-cohort-row.dto.ts 14 §5.5, docs/superpowers/specs/2026-08-20-homerooms-unassigned-students-design.md
Change the subject-group placement-gaps companion src/subject-groups/{subject-groups.controller,subject-groups.service,subject-groups.queries,subject-groups.swagger}.ts, src/curriculum/{resolve-target-plan,selection-consistency}.ts, src/students/curriculum-selection.queries.ts 14 §5.5, docs/superpowers/specs/2026-08-24-subject-groups-incomplete-plans-iteration-2-design.md
Change a student-first assignable homeroom or course picker src/homerooms/assignable-homerooms.controller.ts, src/subject-groups/assignable-subject-groups.controller.ts, both domain services/queries, and the existing *-eligibility.ts classifier shared with roster writes 14 §5.5, the two placement-readiness designs above
Change dashboard placement-readiness counters src/command-center/{selection-process.queries,selection-process.service,overview.service}.ts, src/command-center/dto/overview-response.dto.ts, src/curriculum/{resolve-target-plan,selection-consistency}.ts 14 §5.5, docs/superpowers/specs/2026-08-21-command-center-overview-iteration-3-design.md

Notification-navigation routing (part of this section's task index):

Task Files to open Chapter
Change notification target mapping or add a target family src/notifications/notification-target.registry.ts, src/notifications/dto/notification-response.dto.ts, src/notifications/notifications-read.service.ts, src/notifications/push-notification.transport.ts; actual routes remain FE-owned 23, docs/superpowers/specs/2026-08-25-notification-catalogue-navigation-iteration-1-design.md

Password-recovery routing (part of this section's task index):

Task Files to open Chapter
Change password recovery or reset-token delivery src/auth/password-reset/ (controller, service, sender, both query files), prisma/schema.prisma (PasswordResetRequest), src/config/env.validation.ts 03 §11, 02, 12, docs/superpowers/specs/2026-08-17-password-reset-design.md
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
Change person-list free-text search src/common/utils/person-search-where.ts + .spec.ts, then each caller's explicit searchable-field list 05 § Person free-text search
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 a disciplinary-note filter or change who sees notes src/disciplinary-notes/disciplinary-notes.queries.ts, src/disciplinary-notes/disciplinary-notes.policy.ts, the list DTO and focused specs 25 §2–§4
Add an endpoint to an existing module <domain>.controller.ts, <domain>.swagger.ts, <domain>.service.ts, <domain>.queries.ts if new query 05
Change invitation delivery/outbox behavior src/invitations/sender/*, src/invitations/invitations.service.ts (queue transaction), src/invitations/invitations.queries.ts, prisma/schema.prisma (InvitationDelivery) 02, 12, docs/superpowers/specs/2026-08-09-invitation-outbox-design.md
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 self-service (/me) own-record surface src/common/constants/self-editable-scopes.ts (whitelist), src/common/utils/assert-self-editable-scopes.ts, src/common/services/base-tenanted-crud.service.ts (getOwnRecord/updateOwnRecord/ownRecordWhere/resolveOwnRecordId), src/<domain>/<domain>.controller.ts (scope-decorator-free /me routes), src/<domain>/<domain>.service.ts (ownRecordWhere override) 04 §Self-service, docs/superpowers/specs/2026-07-17-self-service-profile-editing-design.md
Add self-service own-document file routes (/me/documents) src/<domain>/<domain>.service.ts (selfDocConfig with policy.where: () => ({}) + own-doc forwarders resolving resolveOwnRecordId), src/<domain>/<domain>.controller.ts (scope-decorator-free /me/documents/* mirroring /:id/documents/*, no @AggregateResponse), src/<domain>/<domain>.swagger.ts (Api*Own*Document*); reuses PersonDocumentService 04 §Self-service, docs/superpowers/specs/2026-07-20-self-service-profile-editing-iteration-1-design.md
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, DayTemplateBreak, WeekTemplate, WeekTemplateDay, DepartmentWeekTemplateAssignment, GradeWeekTemplateAssignment, BreakType/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/diagnostics-cards.ts (renderViolationCards + one CARD_RENDERERS row per category — a new check also needs its row + diagnostic.* catalog copy in src/common/i18n/diagnostic-messages.catalog.ts; diagnostics-cards.drift.spec.ts enforces the lockstep), src/timetables/timetables.queries.ts (buildDiagnosticsSnapshot + cascade/window/break-graph/response/placement-plan loaders — thread any new snapshot field here), src/timetables/timetables.service.ts (structural floor assertLessonOnDayAxis/assertBreakOnDayAxis, shared mutateAndDiagnose live-edit, setStatus publish gate, getLessonsView + getPlacementPlan), 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 + 2026-07-09-timetable-manual-management-iteration-3-design.md (canonical) + 2026-07-12-timetable-diagnostics-localized-cards-design.md (cards)
Place a break/lunch slot or change its audience selectors src/timetables/dto/scheduled-break.dto.ts (create/update/response + six selector refs), src/timetables/timetables.service.ts (normalization, tenant validation, direct-student AY/status eligibility), src/timetables/timetables.queries.ts (breakGraphInclude, ENROLLED/AY-fenced live-union resolution, snapshot projection), prisma/schema.prisma (ScheduledBreak, ScheduledBreakAudience, ScheduledBreakDuty, BreakKind) — all riding the TIMETABLES entity, no new RBAC 18 §1/§6, docs/superpowers/specs/2026-07-09-timetable-manual-management-iteration-3-design.md + 2026-08-26-timetable-two-step-roster-iteration-2-design.md + 2026-08-28-timetable-student-participation-eligibility-design.md (canonical)
Place a named activity / change weekday-specific supervisors src/timetables/dto/scheduled-activity.dto.ts (new teachers[] + legacy teacherIds[]), src/timetables/timetables.service.ts (full replace, parent-weekday reconciliation, direct-student AY/status/display-cohort validation), src/timetables/timetables.queries.ts (ENROLLED/AY-fenced live audience, copy fidelity + per-occurrence projection), src/timetables/activity-teacher-coverage.ts (global/subset semantics), src/attendance/attendance-day.queries.ts + attendance-access.service.ts (dated teacher projection + weekday-qualified ACTIVITY cohort gate), prisma/schema.prisma + migration 20260826130000_timetable_roster_weekday_supervisors 18 §Activities + 19 §7.1/§Activities, docs/superpowers/specs/2026-08-26-timetable-two-step-roster-iteration-2-design.md + 2026-08-28-timetable-student-participation-eligibility-design.md (canonical)
Trigger / debug automatic timetable generation src/timetables/generation/generation.queries.ts (buildGenerationSnapshot: segment computation, groups, cohorts, clash pairs), src/timetables/generation/combined-rewrite.ts (applyCombinedClassRewrite: first-class COMBINED group + member reduction), src/timetables/generation/generation.prechecks.ts (runPreChecks: 9 pure pre-check categories), src/timetables/generation/effective-templates.ts (generation-only effective-template resolution), src/timetables/generation/solver.port.ts (SolverPort + HttpSolverTransport + FakeSolverTransport + toSolverRequest + canonical snapshotFingerprint), src/timetables/generation/generation.service.ts (@NoTenantTx three-phase pipeline: read tx → solve with no tx → AY/precheck/fingerprint revalidation + persist tx), solver/app/model.py (CP-SAT vars + hard families + combined pair-tiling), 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) + 2026-07-12-combined-classes-iteration-3-design.md (COMBINED groups + pair-tiling) + 2026-08-09-txn2-external-io-tx-boundary-design.md (transaction shape), solver/README.md
Combine sections (co-taught) / touch combined-class behavior src/subject-groups/combined-classes/* (CRUD + validateMembers envelope), src/timetables/dto/create-scheduled-lesson.dto.ts + dto/teaching-unit-ref.dto.ts (dual anchor + unit ref), src/timetables/timetables.{service,queries,diagnostics}.ts (anchor-aware CRUD/views/plan; per-unit HOUR_BUDGET, expanded option-block occupancy, memberToCombined snapshot), src/timetables/generation/combined-rewrite.ts + solver/app/model.py (pair-tiling), src/attendance/attendance.{service,queries}.ts (union register + contributor stamping), src/subject-groups/subject-groups.{service,queries}.ts (kind table rows), prisma/schema.prisma (CombinedClass, ScheduledLesson XOR anchor, AttendanceRecord.combinedClass*) 14 §Combined classes, 18 §Combined classes, 19 §Combined classes, docs/superpowers/specs/2026-07-12-combined-classes-iteration-3-design.md (canonical)
Gate an attendance route / touch register authorization src/attendance/attendance.policy.ts (route-level policy admission), src/permissions/permissions.service.ts (resolveAuthorizedRoleSlice, which retains only independently scope/action-qualified role assignments and parameters), src/attendance/attendance-access.service.ts (resolveManagementAttendanceScope, mapping non-parameterized management grants to tenant-wide access and DEPARTMENT/CURRICULUM grants to department ids while preserving an independently qualified teacher limb), src/attendance/attendance-authority.ts (student-cell authority), and the service-local hidden-resource fences in board, insights, justifications, loop, and register services 04 §7, 19 §7–§8, §10 gate 1
Touch attendance justifications (family CRUD / covering seams / attachments) Three services + a pure shared module since the 2026-08-10 harmonization split — writes: src/attendance/attendance-justifications.service.ts (create/update/remove behind the strictly-future window, the consistency matrix + in-tx covering sweep + supersede + provenance re-stamp, kind-shape validation, mergeSubmission); reads: src/attendance/attendance-justifications.read.service.ts (family list, office list, getRowBlocks row loader — one baseProjection under two mappers, toFamilyDto with canEdit / toAdminRow without the key); attachments: src/attendance/attendance-justification-files.service.ts (upload/remove + both signed-URL fences, deliberately outside the mutability window); shared ground: src/attendance/attendance-justifications.shared.ts (isMutable, canEditJustification — the ONE canEdit definition, constants, hidden-404 thrower; writes → reads is the only cross-dependency); register pre-filling: src/attendance/attendance-materialization.service.ts (materializeForJustification / dematerializeForJustification — the §4.2 mapping, the skipDuplicates never-overwrite rule, the silent skips, the 30-day cap, the two attendance_record.* verbs) over the shared plain-function planners src/attendance/attendance-planning.ts (planAnchors, isSchoolDayCached — extracted from AttendanceService solely to break the dependency cycle). Plus src/attendance/attendance-justifications.queries.ts (covering rule both directions + suggestedKindFor, findFamilyReachableJustification reachability, excludeId consistency probe, from/kind filters, hasFlaggedDayInRange past-day gate, findMaterializedCells/deleteMaterializedCells sharing one where builder, all data access), src/attendance/attendance-loop.service.ts (buildReopenData + the mint-time seam + reopenForJustification, the withdrawal inverse) and attendance-loop.queries.ts (reopenEventsStampedBy — which resolutions a family may undo), src/common/utils/assert-referent-can-write.ts (referentWritableStudentIds, the batched writable-set half of canEdit), src/attendance/dto/create-justification.dto.ts (incl. FamilyJustificationsQueryDto) + dto/update-justification.dto.ts + dto/justification-response.dto.ts + dto/family-to-justify-query.dto.ts + dto/admin-justification-list.dto.ts, src/attendance/attendance-family.controller.ts (seven family routes) + src/attendance/attendance-insights.controller.ts (office list + school download), prisma/seed/roles.ts (REFERENT_ACTION_KEYS) + prisma/seed/rbac-catalogue.ts (attendance.justify — reused by every mutation, no seed delta), prisma/schema.prisma (AttendanceJustification, AttendanceDayEvent.justificationId, AttendanceRecord.justificationId, FileUsage/FileOwnerType JUSTIFICATION) 19 §11.3 + §11.4, docs/superpowers/specs/2026-08-04-attendance-justifications-design.md + 2026-08-10-referent-justification-crud-design.md + 2026-08-10-family-attendance-surface-harmonization-design.md + 2026-08-10-attendance-justification-materialization-design.md (the last amends the withdrawal cascade — its §5.4 is the authority on the ordering)
Teacher attendance surface / teacher day board src/attendance/attendance-teacher.service.ts (getTeacherDay — slot agenda incl. ACTIVITY cards + duty flags + contradiction badge + duties[] + otherGroups), src/attendance/attendance-teacher.controller.ts (GET /attendance/teacher-day), src/attendance/attendance.queries.ts (loadTaughtSubjectGroupIdsOn / loadSubjectGroupDayMeta / loadActivityDayMeta / loadActivityRosterIds / countSubjectGroupRostersOn), src/attendance/attendance-day.queries.ts (loadSubjectCohortGroup + loadActivityCohortGroup — the synthetic SUBJECT_GROUP/COMBINED_CLASS/ACTIVITY board groups; loadTeacherDutiesOn), src/attendance/attendance-access.service.ts (own-group read gate: teaching unit / tutored class / supervised activity) 19 §7.1, docs/superpowers/specs/2026-08-02-teacher-attendance-surface-design.md (canonical)
Compare a date in the attendance module src/attendance/attendance.queries.ts (loadSchoolTimezone), src/attendance/attendance-clock.ts (schoolToday / schoolNowMinutes / isElapsed) — every date question is asked in School.timezone; a new Date() against a stored date desynchronizes the read and write paths for hours at a time 19 §14
Back-fill a past register / touch attendance day-shape freezing src/attendance/attendance-day-shape.service.ts (resolveDaySlots live-vs-frozen rule + freezeDay), src/attendance/attendance-day.queries.ts (loadFrozenDaySlots / freezeDaySlots / anchorKeyOf — the key mirrors the SQL CHECK attendance_day_slots_anchor_xor), src/attendance/attendance-board.service.ts (assembleGroupDays routes through the resolver), src/attendance/attendance.service.ts (freezeDay first in the write tx; per-cell upsert), src/attendance/attendance-planning.ts (planAnchors/planLessonAnchor/planActivityAnchor resolve a date's anchors — module-level functions since 2026-08-10, shared with the family materializer; isSchoolDayCached, whose throwing wrapper stays on the service), src/attendance/attendance.queries.ts (loadLessonGraphByAnchor — by-id, no timetable filter), prisma/schema.prisma (AttendanceDaySlot) 19 §3.1, docs/superpowers/specs/2026-07-26-attendance-day-shape-snapshot-design.md
Send a user notification / add a notification kind (± email) src/notifications/notification.interfaces.ts (NotificationPayloads + NOTIFICATION_KINDS — the central registry consumers append to), src/notifications/notification-email.registry.ts (NOTIFICATION_EMAIL_SPECS — publish the Resend template pair FIRST), consumer-side: resolve User ids + @Inject(NOTIFICATION_PORT) + send() AFTER the owning tx commits 23 §6, spec 2026-07-23-notification-engine-design.md
Touch the attendance notification cadence / add a scheduled job src/attendance/attendance-notifier.service.ts (the sweeper — per-tenant withTenantGuc + per-row CAS claims + post-commit sends; THE template for future scheduled jobs), src/attendance/attendance-notifier.queries.ts (candidate scans + CAS claims), src/attendance/attendance-timing.config.ts (grace/delay/tick env resolver), src/app.module.ts (ScheduleModule.forRoot()) 19 §11.1, spec 2026-08-04-attendance-loop-notifications-design.md
Change who a sender may email (tier rules / picker / private-group vetting) src/communications/recipient-policy.ts + .queries.ts (THE single authority — never re-derive reach elsewhere; picker cohort facets and homeroomNames also live here), src/communications/communication.interfaces.ts (SenderTier, SenderContext, RecipientPickerCandidate), src/communications/dto/recipient-candidate.dto.ts (picker query/response contract), src/students/students.queries.ts (studentCohortDisplayWhere — shared student/referent cohort-chip semantics), ch24 §2/§9
Touch the communication send flow (Reply-To rules, counts, template gate, attachments) src/communications/communications.service.ts (the 10-step create), src/communications/communications.queries.ts (insertCommunicationAggregate, findVettedReplyToEmails), ch24 §1/§4
Add or modify mailing groups src/communications/mailing-groups/ (service = shared-vs-private write gates; queries = accessibleGroupWhere + syncGroupMembers)
Touch communication delivery (sweeper cadence, batching, ledger, webhook statuses) src/communications/sender/communications-sender.service.ts + .queries.ts (CAS claim + drain), src/communications/communications-delivery.service.ts (monotonic webhook transitions), src/mailer/resend/resend-report.mapper.ts (namespace dispatch)
Touch the recipient inbox (received communications, read state) src/communications/inbox/ (auth-only controller + self-scoped service + fenced queries — findCallerPersonRefs is the keying rule), ch24 §7
Add a communication email template kind Resend dashboard (publish communication-<kind>-{en,it}) + nothing server-side — src/communications/resend-templates.service.ts crawls the published catalog (ch24 §7)
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
Change command-center person-list search src/command-center/dto/{completeness-query,onboarding-query,curriculum-selection-query}.dto.ts (q contract), src/command-center/{completeness,onboarding,curriculum-selections}.{service,queries}.ts, src/common/utils/person-search-where.ts (token AND containing per-field OR clauses), and co-located specs 05 §Person free-text search; docs/fe-requests/2026-08-18-command-center-search-be-request.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/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). Roster study-plan (2026-07-15): GET /homerooms/:id embeds a per-roster-student studyPlan (curriculum selection) mirroring GET /students/:id's assignment.studyPlan verbatim — HomeroomsService.enrichRosterStudyPlans (second-pass, GET-only, soft-gated on students.assignment read via PermissionsService.checkScope; platform admins always) reuses src/students/study-plan.mapper.ts (buildStudyPlanSummary/flattenCurriculumForStudent) + students.queries.ts (STUDY_PLAN_SELECTION_SELECT/findSelectedCurriculumTree); roster row DTO HomeroomRosterStudentDto; findRosterStudyPlanRows in homerooms.queries.ts. Subject drill-down (2026-08-14): GET /subject-groups/by-subject/:curriculumSubjectId — intersection read (deliberately outside the table's union chips): all courses of one subject across grades as full SubjectGroupDetailDto rows + the combined classes they contribute to (SubjectGroupsService.findBySubject, CombinedClassesService.listAnchoredOnSubject, findSubjectGroupDetails in subject-groups.queries.ts) 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) + docs/superpowers/specs/2026-07-10-grouped-courses-iteration-5-design.md (dept → grade → cv → track tree, nested leaves, numOfferedSubjectGroups)
Touch the homeroom↔course link or the roster cascade src/homerooms/linked-course-cascade.ts (pure per-student planner — close/open/skip verdicts off forward SG states + the belonging predicate), src/homerooms/explicit-belonging.ts (classifyExplicitBelonging — the blueprint selected rule, shared with CourseBlueprintService.countSelected), src/homerooms/homerooms.service.ts (prepareCourseCascade/executeCourseCascade + the widened same-day probe sets), src/homerooms/homerooms.queries.ts (findLinkedCoursesForHomerooms, closeSubjectGroupAssignmentsForGroups), src/subject-groups/subject-group-write-ops.ts (assertHomeroomLinkValid), src/subject-groups/subject-groups.queries.ts (loadSubjectGroupMembershipStatesBySubject, table-filter union leg), prisma/schema.prisma (SubjectGroup.homeroomId), src/common/constants/scope-fields.ts (homeroomId in SUBJECT_GROUP_SCOPES.composition) 14 §1.5, docs/superpowers/specs/2026-08-06-homeroom-course-link-design.md (canonical)
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 + temporal C2) src/students/curriculum-selection.* (controller/service/queries/swagger/specs), src/students/class-selection-sync.ts (whole-aggregate versioning + membership prune), src/students/dto/{write-selection,selection-read-response,pending-selection,selection-choice-read}.dto.ts, bulk assignment service/DTOs, src/curriculum/selection-consistency.ts, and prisma/schema.prisma (StudentCurriculumSelection interval versions + choice children). Reads resolve current plus one complete pending aggregate; writes open/version/amend under CAS and emit full structural audit snapshots. 05, docs/superpowers/specs/2026-07-26-attendance-temporal-c2-membership-valid-time-design.md, docs/superpowers/plans/2026-07-27-attendance-temporal-c2-membership-valid-time.md, and docs/fe-guides/2026-07-27-membership-valid-time-BREAKING.md
Add or modify a preset / custom parametric role prisma/seed/roles.ts (role matrix + parameterDim), prisma/seed/helpers/{seed-role,expected-preset-grants}.ts, prisma/seed/rbac-catalogue.ts, src/common/constants/parameter-dim.ts, src/permissions/permissions.service.ts, src/common/utils/{access-context-helpers,entity-access-policy}.ts, relevant policies, tools/reconcile-preset-roles.ts. Every preset grant addition/removal/access change reaches global and existing-tenant rows through the final every-deploy reconciler; custom roles are not modified. 04, 15, docs/superpowers/specs/2026-08-28-authoritative-preset-role-reconciliation-iteration-1-design.md, parametric-role specs
Add or change a profile-coupled base role prisma/seed/roles.ts (authoritative inclusion/exclusion-shaped preset), prisma/seed/helpers/expected-preset-grants.ts (drift mirror), src/common/constants/person-profiles.ts (PROFILE_COUPLED_ROLE_KEYS), src/invitations/invitations.queries.ts (RECIPIENT_PRESET_ROLE_KEY), src/invitations/invitations.service.ts (accept-time user-only binding), src/permissions/role-clone.ts (frozen tenant reconciliation; Staff also uses backfillStaffProfileRoleAssignments), prisma/seed.ts (Tier-1 ordering), and src/tenants/tenants.queries.ts (direct Staff bootstrap binding) 04 §Profile-coupled preset roles, 15 §Tier 1, docs/superpowers/specs/2026-08-21-staff-profile-role-design.md
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 Tenant-facing PATCH /roles/:key REMOVED 2026-08-12 — the permission matrix is read-only for schools (no role, admin included, can edit it). The engine survives: src/permissions/role-grants.{service,queries,coherence}.ts, src/permissions/dto/patch-role-grants.dto.ts, PermissionsService.buildRoleCatalogItem; its only live route is the backoffice PATCH /roles/presets/:key (next row) 04 §"Editing role grants — tenant surface REMOVED", docs/superpowers/specs/2026-06-09-role-grant-editing-design.md
Edit global role presets (backoffice) / provision or reconcile tenant roles src/permissions/roles.controller.ts (GET/PATCH /roles/presets; writes are deployment-ephemeral), src/permissions/role-clone.ts (provisioning/backfill + seed-local frozen safety), prisma/seed/roles.ts, tools/reconcile-preset-roles.ts (transactional all-preset exactness), railway.toml (migration → seed → reconcile), src/common/rbac-deploy-reconciliation.drift.spec.ts 04 §"Global preset roles", 15 §"Deploy wiring", docs/superpowers/specs/2026-08-28-authoritative-preset-role-reconciliation-iteration-1-design.md
Add a custom field type / touch the definitions-management gate src/custom-fields/ (controller decorators = custom_fields.configuration route gate; service keeps the per-target entity+scope second gate), src/common/constants/entity-keys.ts (EntityKey.CUSTOM_FIELDS), src/common/constants/scope-fields.ts (CUSTOM_FIELDS_SCOPES + FLAT_DTO_ENTITIES), prisma/seed/rbac-catalogue.ts (custom_fields entity/scope/actions), prisma/seed/roles.ts (ADMIN_EXCLUDED_SCOPE_KEYS / ADMIN_EXCLUDED_ACTION_KEYS + per-preset exclusions — granted to nobody) 05, docs/superpowers/specs/2026-08-18-custom-fields-rbac-entity-design.md
Change how custom fields surface on entity reads src/custom-fields/build-custom-field-values.ts (shared buildCustomFieldValues), src/common/services/base-tenanted-crud.service.ts (pickCustomFields), src/homerooms/homerooms.service.ts + src/subject-groups/subject-groups.service.ts (toDetailDto + validateCustomFieldsFlat) 05, 14, docs/superpowers/specs/2026-07-15-custom-fields-always-surface-design.md
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, correct, withdraw, or temporally read a student grade src/grades/* (grades.scale-snapshot.ts frozen evidence/calculation/aggregates; grades.scale-context.ts creation-time live cascade; grades.scale-projection.ts compact ordered public shape; grades.visibility.ts root/revision/directory visibility; grades.service.ts append commands + academicYearId/contextDate/asOf reads + school-today department-TERM periodStartDate on gradebook/homeroom responses; DTO/controller/Swagger), prisma/schema.prisma (GradeEntry root, GradeEntryRevision, GradeCriterionMarkRevision, ledger enums), migrations 20260812150000150003 + 20260813120000, tools/backfill-grade-revisions.ts, RLS/tenanted/reset registries, entity-key/scope/action catalogue and preset grants 21, 12, iteration-2 design, iteration-3 design
Run or write a migration prisma/schema.prisma, prisma/migrations/ 12 required read before migrating
Change the rooms read contract src/rooms/rooms.queries.ts (roomListInclude / roomDetailInclude), src/rooms/rooms.service.ts (getScopeFieldMappings / toDetailResponse), src/rooms/dto/scopes/room-configuration.dto.ts, src/rooms/rooms.service.spec.ts. Collection responses expose both lunchShiftCount and ordered lunchShifts[]; non-canteen rooms return an empty array. 05
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

Notification task-route extension

For changes to the private inbox API, open src/notifications/notifications.controller.ts, notifications-read.service.ts, queries.ts, and notifications.swagger.ts. Every route is auth-only and every operation is fenced by tenant plus caller user id; chapter 23 and the notifications FE guide own the public contract.

For “send a user notification / add a notification kind”, also open src/notifications/notification-push.registry.ts when the kind should push. For provider delivery open push-sender.interfaces.ts, the selected sender, and push-notification.transport.ts; for device lifecycle open push-registrations.controller.ts, push-registrations.service.ts, the two registration DTOs, and the PushDeviceRegistration Prisma model. The governing route remains chapter 23, now with the approved push spec.

For a catalogue producer, additionally open the owning domain's transaction seam and PostCommitCoordinator; do not infer a notification from controller intent. For automatic curriculum/completeness milestones, open src/command-center/notification-milestones.{service,queries,config}.ts, the ScheduledNotificationClaim model/migration/tenancy registries, chapter 08 §Pre-year completeness, and the 2026-08-25 catalogue design. FE route selection is not an input to these producers: the notification engine derives a domain targetType, payloads carry stable reload IDs, and neither contains route fields.

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) and editable via PATCH while the year is DRAFT or ACTIVE (rename is DRAFT-only) — it is the pre-year completeness deadline, constrained to fall strictly before the earliest Department.calendarStartDate of its year (ch08 §Pre-year completeness). Note status = ACTIVE does not mean the year has started: the wizard activates it immediately, and the calendar lives per-department, so the constraint is date-vs-date and AY status is irrelevant to it. Every tenant also carries a hidden __bootstrap__ sentinel year (BOOTSTRAP_YEAR_NAME, permanently DRAFT) — the pre-setup Staff anchor for admin users; the academic-years API filters it out of list and by-id read/write (see docs/15-seeding.md Conventions).
  • 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 taught in a grade is the presence of an Hours cell (always with positive hours — 0/null submissions are discarded). 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 taught in (@@unique([subjectId, gradeId])). Row presence = taught with positive weeklyHours (NOT NULL + weekly_hours > 0 CHECK; the FE renders "—" where there is no cell); a cell submitted with 0/null hours is the "not taught" idiom, silently discarded at write — validation and sync filter with the shared isTaughtCell predicate (src/curriculum/curriculum.validation.ts), and a 0/null resubmission of an existing cell is a cell removal (in-use guard + empty-SG clear + consistency sweep). expandPreset deliberately does not filter: null-hours preset cells are "school-defined hours" placeholders (IB DP/MYP/IGCSE) shown for the admin to fill; the discard happens only at save. Also carries an optional per-(subject, grade) gradingScaleId override. The teacher hour budget and SG eligibility resolve hours against the SG's gradeId cell. Spec: 2026-07-07-curriculum-zero-hours-not-taught-design.md.
  • Subject unit / main subject — a CurriculumSubject has one of three shapes: a leaf (its own hours cells, scale, criteria — the default), a main subject (a subject taught in units — owns its own cells exactly like a leaf: hours, scale, level, rooms, period; criteriaLevel ∈ {SUBJECT, UNIT}), or a unit (parentSubjectId set — carries only criteria + a per-unit criteria scale in UNIT mode; no hours/scale/level/rooms/period — CURRICULUM_UNIT_HAS_CELLS rejects them; never choosable, never gets its own SubjectGroup; a CHECK forbids a unit from an option block or track). Hours were inverted onto the main subject (2026-07-20) — a main subject is scheduled as one SG worth its own cell hours, and is the structural unit everywhere (coverage counts it once, selection sees one choosable subject, one timetable slot, one row in the student's course list). A grade on a main-subject SG names a unit (RecordGradeDto.curriculumSubjectId, stamping the main subject into GradeEntry.parentSubject*; subject-mark scale resolves from the main subject); attendance optionally tags covered units (coveredUnit*). Units are filtered out of coverage/selection at the source query (parentSubjectId: null). There is no effectiveHours (a main subject's hours are its own cells). See docs/14-homerooms-subject-groups.md §1.4; baseline spec 2026-07-14-subject-units-design.md + hours-inversion 2026-07-20-subject-units-iteration-1-design.md.
  • 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. Homerooms no longer bind tracks (the 2026-07-08 decoupling dropped Homeroom.trackId); tracks scope curriculum subjects/blocks, referent selections, and standalone-SG cohorting.
  • 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); a qualified school-side selection manager 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, while persisted QUEUED means durable delivery is owed. Each attempt owns an InvitationDelivery outbox row; the sender mints the token at claim time and advances QUEUED → SENT|FAILED. STUDENT is the only conditionally invitable type: the live Department.studentPlatformAccess gate runs at queue, claim, verify, and accept, and flipping the flag OFF cascade-deletes pending rows and their deliveries. See 2026-06-03-student-invitations-design.md and 2026-08-09-invitation-outbox-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). Authorized management opens it with a startDate; Curriculum Coordinators are limited to departments owning their qualified curricula. 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). Because gracePeriodEnding must precede the first day of school (ch08 §Pre-year completeness), a window is pre-year by construction — it can only ever read OPEN before the year starts, so a referent's selection is always a declaration for a year that has not begun; management writers may edit selections outside the family window. 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 + temporal C2) — a student's curriculum, track, carried flags, and choice children form one effective-dated aggregate. StudentCurriculumSelection rows are half-open [validFrom, validUntil) versions protected by student_curriculum_selections_concurrent_excl, not a singleton row. Reads expose the version effective on the captured school date plus a complete pendingSelection; repeated future edits amend that one pending aggregate. Children StudentOptionBlockChoice rows hold {optionBlockId, curriculumSubjectId, carried} and cascade with their owning version. isComplete is materialized structural completeness; confirmedAt is the distinct referent lock. Status (NOT_STARTED / IN_PROGRESS / COMPLETE) derives from isComplete on the version governing today — a scheduled submission reads NOT_STARTED with the substance in pendingSelection. The lock is the exception: it is a forward question (2026-07-29). A referent may not pick a boundary and submits inside a pre-year window, so their version is scheduled from day one of the year and nothing governs today; both canWrite and the SELECTION_ALREADY_CONFIRMED refusal therefore read header.confirmedAt != null || pendingHead.confirmedAt != nulleither version being confirmed spends this window's confirm. pending ?? header would be wrong: it would let a referent overwrite an admin's scheduled partial edit. PATCH-as-versioned-upsert — no POST. Referent writes remain window/link/lock-gated and inherit timing; authorized management writers may provide assignment.validFrom. Canonical implementation: src/students/curriculum-selection.*, src/students/class-selection-sync.ts, and src/students/dto/selection-choice-read.dto.ts; governing design: docs/superpowers/specs/2026-07-26-attendance-temporal-c2-membership-valid-time-design.md.
  • 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
Change setup-import full-replace / delete-on-absent behavior (computeSweepTargets, deleteRecordWithinTx) 07 - Import Pipeline §6, docs/superpowers/specs/2026-07-17-setup-import-full-replace-design.md
Expose import-template columns to the FE (GET /<entity>/import/columns) 07 - Import Pipeline §9
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
Back-fill a past register / touch day-shape freezing 19 - Attendance Register §3.1
Gate an attendance route (which of the five policies? read gate vs write fence?) 19 - Attendance Register §7.1, §8, §10
Touch the family communication loop (day events, arm window, follow-up ack) 19 - Attendance Register §11.1
Touch the referent/student attendance surface (to-justify, overview, day view) 19 - Attendance Register §11.2
Record or read a student grade 21 - Grades
Create/read/update/delete homework or tests, attach allowed documents/images/links, mint school/family file URLs, or change active/history visibility 26 - Class-register Assignments, src/assignments/assignments-attachments.service.ts, src/assignments/assignments-attachments.queries.ts, src/files/files.service.ts
Add/read the attendance-page “what we did today” course text 26 - Class-register Assignments §2–§5; attendance only composes the API
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.