Skip to content

Timetable student participation eligibility


1. Problem distillation

  • Timetable activity and break audiences are live-resolved from selector rows, but their candidate student queries do not require an actively enrolled student. PRE_ENROLLED, LEFT, and GRADUATED rows can therefore appear in grade/department rosters or through direct STUDENT selectors.
  • The timetable admin needs a purpose-specific student picker. Reusing the status-neutral student directory or display-tier filter behavior makes exited students intentionally findable, which is correct for administration and history but wrong for scheduling participation.
  • Direct student selector validation currently proves only tenant existence (and, for activities, the forward-head department). It can persist a student from another academic year or one whose status makes them ineligible, and it can judge a scheduled department move before it takes effect.
  • Audience-derived teacher visibility has a parallel authorization gap: direct selector legs, and stale-open membership legs, are not uniformly intersected with the strict effective-placement candidate universe.
  • The audit found one additional write surface with an unambiguous lifecycle rule: bulk curriculum assignment must retain PRE_ENROLLED support for pre-year setup, while refusing terminal LEFT/GRADUATED students.

In this document, timetable-participating means Student.status = ENROLLED in the timetable's academic year. The repository has no Student.ARCHIVED status; for this purpose, the archival/retired states are LEFT and GRADUATED.

Success criteria (observable behavior that proves this works):

  • GET /filters/students?surface=timetables&timetableId=... returns only policy-visible ENROLLED students from that timetable's academic year. Optional department and grade filters use the existing display-cohort semantics, so an effective placement wins over a future head-column move.
  • PRE_ENROLLED, LEFT, and GRADUATED students are absent from timetable activity and break live rosters for every selector kind, including STUDENT, and therefore absent from participant-derived diagnostics, views, and notification recipient sets.
  • Activity and break create/update reject a direct student selector unless the student is tenant-visible, belongs to the timetable academic year, and is ENROLLED. Activity validation checks the student's display cohort on the captured school date before enforcing the activity department fence.
  • A teacher cannot gain student record visibility solely through an activity or break audience unless the student has an effective placement on schoolToday; this applies uniformly to direct and relation-based audience selectors.
  • Bulk curriculum assignment continues to accept ENROLLED and PRE_ENROLLED students, but reports LEFT/GRADUATED requests as NOT_APPLICABLE and creates no selection for them.
  • Generic student directories, student-table filters, invitations, rollover, attendance history, audit history, and other historical reads keep their existing lifecycle behavior.

Non-goals (in-scope-shaped things this iteration is explicitly not doing):

  • Changing the global display-tier helpers or hiding terminal students from GET /students by default.
  • Removing or rewriting legacy audience selector rows that point at a student who later becomes ineligible. Those rows remain stored and can become effective again only if the student is valid for the live rule.
  • Materializing timetable audience membership or adding a historical roster snapshot. Attendance remains the dated/frozen source for historical register facts.
  • Changing homeroom, subject-group, attendance, invitation, rollover, command-center readiness, or curriculum-selection-window eligibility rules that already encode their own lifecycle semantics.
  • Narrowing communications recipients without a product decision separating operational messaging, admissions/pre-enrolment messaging, and alumni/exit communication.
  • Implementing the external WORKSHOP admin client, which is not present in this repository. This spec defines the backend picker contract that client must consume.

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/expected/expected-audience.ts One candidate universe applied to every activity audience kind, including direct students and stale-open curriculum/homeroom relations Attendance answers a dated historical question with strict placement; timetable authoring must retain pre-year planning for currently ENROLLED rows through the display tier
src/students/student-placement.queries.ts Explicit separation between strict placement for authorization/attendance and display cohort for administrative lists and pre-year planning Neither helper alone encodes timetable participation status or timetable academic-year scope
src/homerooms/homeroom-eligibility.ts and src/subject-groups/subject-group-eligibility.ts Picker/write defense-in-depth: exclude non-ENROLLED students at candidate classification, not only in the UI Timetable group selectors are live unions and may validly resolve to zero; only direct student refs are rejected at write time
src/timetables/timetables.queries.ts resolveActivityAudiences / resolveBreakAudiences Batched selector-union reads, display-cohort resolution, in-memory selector matching, and live re-resolution by consumers The current outer candidate predicate is missing ENROLLED; break resolution is also missing explicit timetable academic-year scope
src/filters/ and 2026-08-26-teacher-record-visibility-filter-scoping-iteration-2-design.md Required surface discriminator, policy-projected people results, identical predicates for page data/count, and timetable-id anchoring already used by other timetable filters Timetable student options are authoring candidates, not students already present in surviving timetable cells, so they must be projected from the timetable AY plus picker cascade rather than projection.studentIds
src/students/students.policy.ts studentRelatedToTeacherOn Governing timetable window, strict dated relationships, and one canonical predicate reused by student-related read consumers Direct activity/break selector legs and stale-open relation legs currently bypass a shared placement candidate universe
src/students/bulk-curriculum-assignment.service.ts Batched classification with stable assigned/skipped response and display-tier fallback that enables pre-year work The candidate row omits status, so terminal students can pass academic-year/department/grade applicability

On-axis/off-axis assessment (chapter 16 §4–§5): all changes are on-axis refinements of existing query predicates, picker surfaces, record policies, DTO enum values, and batched write classification. No new entity, event/fan-out mechanism, cross-module write coordinator, or aggregate-response pattern is introduced. The invariant is cross-cutting in meaning but lands in existing module-owned seams; none of chapter 16 §5's off-axis cases applies.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All student reads retain direct tenantId predicates or StudentsReadPolicy; timetable access stays policy-projected; RLS remains defense-in-depth A selector or picker must never become a tenant oracle
Academic-year scope yes The timetable row is the authority for academicYearId; both audience resolvers and direct student validation receive that value. The timetable picker derives the year from timetableId and treats a supplied academicYearId only as a consistency check Prevents same-tenant students from another year entering the roster
RBAC entity key existing TIMETABLES and STUDENTS No entity-keys.ts delta The picker composes existing timetable-read and student-read authority; mutations remain timetable writes
Scopes existing Picker route keeps students.*:read and additionally verifies timetables.configuration:read for surface=timetables; timetable mutations keep timetables.configuration:write Lifecycle eligibility does not create field authority
Actions none No action key changes Existing create/update route authority is sufficient
Service base custom FiltersService, TimetablesService, timetable query helpers, and BulkCurriculumAssignmentService remain custom; student reads continue through StudentsReadPolicy Existing ownership is already canonical
queries.ts shape existing selects + named functions Add status/year predicates to timetable candidate queries; widen resolveBreakAudiences with academicYearId; keep picker projection in filters.service/queries; add status to BULK_ASSIGN_STUDENT_SELECT; regroup audience policy legs behind strict placement Keeps data access in current owners and avoids a global status-default helper
Error codes one new SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE (422), params { studentId }; unknown/cross-tenant ids remain existing NOT_FOUND, and activity department mismatch remains SCHEDULED_ACTIVITY_AUDIENCE_OUT_OF_DEPARTMENT Gives clients a stable stale-picker recovery signal without exposing inaccessible records
DTO conventions list-query DTO + unchanged mutation DTOs Extend StudentsFilterQueryDto.surface with timetables and require timetableId for that value; direct selector request shapes do not change The existing surface contract is the right picker discriminator
File-backed sub-resources n/a No file changes
Custom fields no No custom-field reads or writes change
Profile completeness no No completion-required field changes

Lifecycle rule matrix

Surface Allowed lifecycle Cohort rule
Timetable student picker ENROLLED only Display cohort on schoolToday, within timetable AY
Timetable live activity/break resolver ENROLLED only Existing display cohort on the resolver date, within timetable AY
Timetable direct STUDENT write ENROLLED only Display cohort on captured schoolToday; activity also requires matching department
Audience-derived teacher authorization Effective placement required Strict placement on captured schoolToday; no head fallback
Attendance expected audience Effective placement required Existing strict as-of-date indexes; unchanged
Bulk curriculum assignment ENROLLED or PRE_ENROLLED Existing placement-on-today with head fallback when none governs
Student directory / history All statuses Existing explicit filter/display behavior; unchanged

4. Data model plan

Schema deltas

  • None. Student.status, Student.academicYearId, placement intervals, and audience references already contain every required fact.
  • No new models, columns, enums, foreign keys, RLS policies, or tenant registries.

Migration shape

  • Additive / destructive / renaming: n/a — no migration.
  • Data backfill: none. Legacy direct selectors remain stored and become inert while their student is not eligible.
  • Hazards from chapter 12 checklist: none; Prisma schema and generated client are unchanged.

Indexes and uniqueness

  • None. The affected reads already anchor on tenant/year/id or existing audience relation indexes; status is an additional predicate, not a new independent scan surface.
  • Do not add a speculative Student(tenantId, academicYearId, status) index in this iteration. Revisit only with query-plan evidence after the timetable picker ships.

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /filters/students existing @RequireScopes(STUDENTS, 'read'), student policy dimensions; surface=timetables also enforces existing timetable configuration read scope and readable timetableId StudentsFilterQueryDto: add surface=timetables; require UUID timetableId for that surface; keep optional search/department/grade/page/limit and optional consistency-check academicYearId unchanged paginated StudentFiltersResponseDto; timetable surface contains only ENROLLED students
POST /timetables/:id/activities existing timetable write scope/policy unchanged CreateScheduledActivityDto; a direct studentId must satisfy the timetable eligibility rule unchanged mutation envelope; may return new 422 code
PATCH /timetables/:id/activities/:activityId existing unchanged UpdateScheduledActivityDto; submitted audience and stored audience revalidated on department change unchanged mutation envelope; may return new 422 code
POST /timetables/:id/breaks existing timetable write scope/policy unchanged CreateScheduledBreakDto; a direct studentId must satisfy the timetable eligibility rule unchanged mutation envelope; may return new 422 code
PATCH /timetables/:id/breaks/:breakId existing unchanged UpdateScheduledBreakDto; submitted direct students revalidated unchanged mutation envelope; may return new 422 code
GET timetable graph/view/diagnostics surfaces that consume live audience resolvers existing no query delta unchanged DTOs; participant ids/names/conflicts exclude non-ENROLLED rows
POST /students/curriculum-selections/bulk-assign existing student curriculum-selection write scope and admin role unchanged BulkAssignCurriculumDto unchanged BulkAssignCurriculumResponseDto; terminal students are skipped with existing NOT_APPLICABLE

Timetable student picker contract

For surface=timetables, FiltersService must:

  1. Require timetableId, verify existing timetable read scope, and project/read the requested timetable through the existing timetable policy seam.
  2. Derive academicYearId from the timetable. If the caller also supplies academicYearId, preserve the projector's current mismatch behavior rather than silently switching years.
  3. Build one Student WHERE used by both data and count: StudentsReadPolicy.where, timetable academicYearId, status = ENROLLED, optional display-tier department/grade filters on the captured schoolToday, and optional name search.
  4. Treat these rows as authoring candidates. Do not intersect with timetableProjector.project(...).studentIds, which is intentionally empty today and would model already-used participants rather than selectable students.

The external WORKSHOP activity dialog should use this route for its individual-student step. GET /students?...&status=ENROLLED remains a compatible generic fallback, but it is not the canonical timetable picker because it does not anchor the request to a readable timetable.

Audience read and write behavior

  • resolveActivityAudiences keeps its existing selector union, display-cohort overlay, and activity department fence, but its outer candidate WHERE additionally requires status = ENROLLED and the timetable academic year.
  • resolveBreakAudiences gains academicYearId and applies the same outer status = ENROLLED plus academic-year fence before selector matching. All call sites (view projection, diagnostics, publish notifications, and retained-version views) pass the owning timetable year.
  • The status/year fence applies outside the selector OR, so it covers EVERYONE, department, grade, curriculum, track, homeroom, and direct student selectors uniformly. A stale-open related row cannot bypass lifecycle eligibility.
  • Direct write validation loads all requested student ids tenant-scoped first. Unknown/cross-tenant ids remain 404 NOT_FOUND; known rows from the wrong timetable year or with status other than ENROLLED produce 422 SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE with { studentId }.
  • Activity validation resolves each eligible direct student's display cohort on the one captured schoolToday before applying the existing department error. Validation order is existence → year/status eligibility → department membership.
  • Grade/curriculum/track/homeroom selectors remain valid even when their live eligible union is empty. Only an explicit direct student assertion is rejected.

Teacher visibility behavior

Inside studentRelatedToTeacherOn, group all scheduled-activity audience legs under one AND with studentPlacedOnWhere({}, schoolToday), and do the same for all scheduled-break audience legs. Keep the existing governing-timetable and week-long relationship rules.

This placement candidate universe is deliberately stricter than timetable display resolution:

  • no effective placement means no audience-derived profile access, even if a direct selector or stale-open curriculum/homeroom row remains;
  • a former student remains visible through a genuinely historical authorization contract only if a future as-of API is designed; current record policies continue to use schoolToday;
  • non-audience teacher relations are unchanged in this iteration.

Swagger considerations

  • Add timetables and conditional timetableId documentation to StudentsFilterQueryDto and the filter route description.
  • Add the localized SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLE example to activity/break create and update Swagger decorators. Public text says only that the selected student is not eligible for this timetable; it does not describe Prisma, policy, or placement internals.
  • Expand the bulk response NOT_APPLICABLE description to include terminal lifecycle status alongside academic year, department, and grade.
  • No response schema changes are required.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none
*_SCOPES runtime constant none

The timetable picker composes two existing authorities rather than creating a grant: the caller must be able to read Student identity rows through the current filter controller and read the anchored timetable configuration through the existing timetable check.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Shared display tier deliberately keeps exited and future students findable Add an outer ENROLLED fence only in timetable picker/live-resolver consumers Administrative findability and scheduling participation are different questions Timetable views no longer name a student after exit even when a legacy selector remains stored
Attendance audience expansion uses strict placement as every-kind candidate universe Timetable authoring uses current ENROLLED status plus display cohort, while authorization remains strict placement Pre-year timetable planning needs current enrolled rows before a placement interval governs; security and attendance must not use that fallback Timetable diagnostics are live planning data, not a historical attendance roster
/filters/* timetable projections source options from surviving timetable cells Timetable student picker sources authoring candidates from timetable AY + cascade, not current projection.studentIds A student being selectable cannot depend on already appearing in the timetable being edited The student route has one explicit timetable-surface branch
Direct audience rows remain stable content and live membership re-resolves Writes reject an ineligible direct student, but later status changes do not delete the selector row Avoids destructive cross-module cascades and preserves revision content A dormant selector can remain visible as a named configuration ref while resolving to no participant
Timetable participation is ENROLLED only Bulk curriculum assignment also permits PRE_ENROLLED Curriculum setup is explicitly a pre-year workflow; scheduling participation is not There is no single global “active student” predicate
Existing teacher visibility spec lists direct audience refs as sufficient Every audience family is additionally intersected with effective placement Authorization text promises strict effective facts; direct/stale-open rows violate that promise A teacher may lose current profile visibility immediately when the student's placement closes, even while the selector row remains

8. Pushback log

US says Conflicts with Proposed instead Status
“Do not show withdrawn students; only enrolled students for the grade” Generic student lists intentionally keep exited students in their last display cohort for administration/history Add a timetable-specific picker surface and timetable-local participant fence; do not change the shared display tier Resolved in this draft
Apply the same exclusion to other student selects Invitations, rollover, admissions, command-center pre-year work, and historical reads require different lifecycle sets Adopt the explicit lifecycle matrix in §3; harden only the unambiguous bulk curriculum write in this iteration Resolved in this draft
“Archived students” StudentStatus has no ARCHIVED value Treat LEFT and GRADUATED as terminal/archival for operational timetable and bulk-write eligibility Resolved in this draft
Hide ineligible rows in the picker A client can still submit a stale or forged student id Pair picker narrowing with service-side validation and live-resolver filtering Resolved in this draft

9. Deferrals

  • Communications recipient lifecycle — operational announcements likely want ENROLLED, while admissions and alumni/exit contact may validly need other statuses. Define a recipient purpose/mode before narrowing src/communications/recipient-policy.ts — follow-up: communications recipient eligibility design.
  • Legacy audience cleanup — no automatic deletion when a student exits or changes year; live resolution is the safety boundary — follow-up: only if product requests configuration cleanup or warning diagnostics.
  • Historical timetable roster snapshots — current timetable audiences are intentionally live-resolved; attendance already preserves dated operational facts — follow-up: separate historical timetable/audit design.
  • Status parameters on /filters/students for non-timetable surfaces — existing surface semantics remain policy/display-driven — follow-up: only with a concrete picker requirement.
  • External WORKSHOP client wiring — switch its activity/break student selector to surface=timetables after the backend contract ships — follow-up: client repository story.

10. Open questions

None. The draft resolves the audited cases with purpose-specific lifecycle rules; explicit user approval of this document is still required by the repository design gate before implementation.


11. Verification plan

  • Unit specs:
  • src/filters/dto/filter-query.dto.spec.ts and src/filters/filters.controller.spec.ts: surface=timetables is accepted only with timetableId; permissions are forwarded for the timetable read check.
  • src/filters/filters.service.spec.ts: timetable AY anchoring, readable-timetable enforcement, ENROLLED predicate, display department/grade filters, search, and identical data/count WHEREs; other student surfaces remain status-neutral.
  • src/timetables/timetables.queries.spec.ts: activity and break resolvers exclude PRE_ENROLLED, LEFT, and GRADUATED for every selector family; enrolled rows remain; break queries include academic-year scope; all resolver consumers pass the year.
  • src/timetables/timetables.service.spec.ts: direct activity/break selector rejects wrong-year and non-enrolled students with the new 422, preserves unknown-id 404, and checks activity department against display cohort rather than a future head pair.
  • src/students/students.policy.spec.ts: every activity/break audience branch is under strict effective placement; direct and stale-open membership selectors cannot grant access after exit or before entry.
  • src/students/bulk-curriculum-assignment.service.spec.ts: ENROLLED and PRE_ENROLLED remain assignable; LEFT/GRADUATED are skipped as NOT_APPLICABLE; counts and response ordering remain stable.
  • Error-code/i18n/Swagger coverage specs: the new code has typed params, English/Italian messages, an example, and activity/break decorator coverage.
  • E2E specs:
  • test/filters.e2e-spec.ts: timetable student picker returns only policy-visible enrolled students in the anchored AY/grade and rejects missing, unreadable, or mismatched timetable context.
  • test/timetables-activities.e2e-spec.ts and test/timetables.e2e-spec.ts: grade/direct activity and break audiences exclude pre-enrolled/exited students in reads and diagnostics; direct stale submissions return the new 422.
  • test/teacher-people-visibility.e2e-spec.ts: a direct/stale audience row does not keep a departed or not-yet-placed student's list/detail/filter visibility; an effectively placed audience student remains visible.
  • test/bulk-curriculum-assignment.e2e-spec.ts: pre-enrolled setup remains green while terminal students are skipped without selection writes.
  • Manual verification: in the WORKSHOP activity assignment flow, select one grade containing enrolled, pre-enrolled, left, and graduated fixtures; confirm only the enrolled fixture appears, submit it, and confirm the timetable diagnostics and subsequent edit show the same participant set.

Documentation delivered with implementation: update docs/18-timetables.md for participation/read/write rules and picker usage; docs/04-rbac.md for the audience-derived placement candidate universe; docs/14-homerooms-subject-groups.md for the expanded filter-surface matrix; and docs/REFERENCE.md for the canonical invariant and task map.

Per repository policy, no build, lint, test, migration, or other verification command runs unless explicitly requested.


12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-28
  • Chat reference: “approved” in chat after review of the timetable picker, roster, authorization, and bulk-assignment lifecycle matrix.

Until this section is filled, no implementation code is written. On approval, update this section and flip frontmatter status: to Approved in the same edit; commit the approved spec before creating the ordered implementation plan.