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, andGRADUATEDrows can therefore appear in grade/department rosters or through directSTUDENTselectors. - 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_ENROLLEDsupport for pre-year setup, while refusing terminalLEFT/GRADUATEDstudents.
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-visibleENROLLEDstudents 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, andGRADUATEDstudents are absent from timetable activity and break live rosters for every selector kind, includingSTUDENT, 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
ENROLLEDandPRE_ENROLLEDstudents, but reportsLEFT/GRADUATEDrequests asNOT_APPLICABLEand 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 /studentsby 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:
- Require
timetableId, verify existing timetable read scope, and project/read the requested timetable through the existing timetable policy seam. - Derive
academicYearIdfrom the timetable. If the caller also suppliesacademicYearId, preserve the projector's current mismatch behavior rather than silently switching years. - Build one Student WHERE used by both
dataandcount:StudentsReadPolicy.where, timetableacademicYearId,status = ENROLLED, optional display-tier department/grade filters on the capturedschoolToday, and optional name search. - 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¶
resolveActivityAudienceskeeps its existing selector union, display-cohort overlay, and activity department fence, but its outer candidate WHERE additionally requiresstatus = ENROLLEDand the timetable academic year.resolveBreakAudiencesgainsacademicYearIdand applies the same outerstatus = ENROLLEDplus 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 coversEVERYONE, 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 thanENROLLEDproduce422 SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLEwith{ studentId }. - Activity validation resolves each eligible direct student's display cohort on the one captured
schoolTodaybefore 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
timetablesand conditionaltimetableIddocumentation toStudentsFilterQueryDtoand the filter route description. - Add the localized
SCHEDULED_AUDIENCE_STUDENT_NOT_ELIGIBLEexample 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_APPLICABLEdescription 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 narrowingsrc/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/studentsfor 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=timetablesafter 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.tsandsrc/filters/filters.controller.spec.ts:surface=timetablesis accepted only withtimetableId; permissions are forwarded for the timetable read check.src/filters/filters.service.spec.ts: timetable AY anchoring, readable-timetable enforcement,ENROLLEDpredicate, 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 excludePRE_ENROLLED,LEFT, andGRADUATEDfor 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:ENROLLEDandPRE_ENROLLEDremain assignable;LEFT/GRADUATEDare skipped asNOT_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.tsandtest/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.