Student participation status fences
1. Problem distillation
- Only
ENROLLED students take part in the rolling academic year. Every other StudentStatus value — PRE_ENROLLED, LEFT, GRADUATED — must be absent from the operational surfaces of the active year: attendance rosters and widgets, gradebooks, disciplinary cohorts, homework, notification audiences, email recipients, invitations, dashboards, department head counts, and login access.
- Today no module except
timetables/ consults Student.status. Attendance deliberately never did: the temporal program made the StudentPlacement interval the whole "enrolled-present" predicate (contract §2b #10). That is right for exits, whose placement is closed on the exit date, and wrong for pre-enrolment, which has an open placement and no dated event. A PRE_ENROLLED student placed in a grade therefore sits in every grade group, board total, activity audience, teacher-day card and family day view, and the sweeper mints absence events for them.
- Exited students leak through a second door: reads anchored on today that use the display-tier placement fallback (head department/grade when no interval governs the date — exactly an exited student's state) or plain undated reads. Follow-up worklists, the justification table, invitations, the communications contactable set, dashboard completeness/onboarding, and department counts all re-admit them.
- A student set to
LEFT/GRADUATED keeps an active User, refresh tokens and an accessed invitation: students are absent from the archive-revocation plumbing that teachers and staff register.
- Two shapes of fence are needed, not one: a live fence (
status = ENROLLED) for anything anchored on today, and a dated fence for register-style reads that must keep history intact — there the placement/membership interval already dates exits, and the only status the interval cannot express is PRE_ENROLLED.
Success criteria (observable behavior that proves this works):
- A PRE_ENROLLED student with an effective placement appears in no attendance group, card, row, total, teacher-day roster, activity audience, follow-up worklist or family day view on any date; writing a register cell for them fails the group fence with 422 ATTENDANCE_ENTRIES_SPAN_GROUPS.
- A student set to LEFT or GRADUATED still appears on every register day before their exit boundary and on none after it; they appear in no today-anchored attendance list (follow-ups, justification table).
- Grade-entry rosters, missing-marks counters and homeroom gradebooks exclude PRE_ENROLLED; recording or re-dating a mark for one fails with 422 GRADE_STUDENT_NOT_IN_SUBJECT_GROUP.
- Disciplinary cohort lists exclude non-ENROLLED students today; creating or re-dating a note for a PRE_ENROLLED student fails with 422 DISCIPLINARY_NOTE_STUDENT_NOT_PLACED.
- The family "active homework" list, the family to-justify list and every other family list of children or their open items show ENROLLED children only; a child's history stays reachable by id.
- timetable.published and attendance event/reminder notifications reach families of ENROLLED students only.
- Invitations cannot be sent to a non-ENROLLED student, nor to a referent with no ENROLLED linked student; the invitations list and the onboarding card follow the same rule.
- The communications recipient picker and the send-time classifier treat non-ENROLLED students, and referents whose linked students are all non-ENROLLED, as non-contactable.
- Dashboard completeness and onboarding count ENROLLED students only; department and grade studentCount count ENROLLED students only.
- Setting a student to LEFT/GRADUATED deactivates their User and revokes their refresh tokens in the same transaction; readmission restores access exactly as the teacher/staff un-archive does.
- The setup rollover's bulk exit closes homeroom, subject-group and curriculum-selection episodes like the PATCH exit already does.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Making the PRE_ENROLLED → ENROLLED flip a dated event. After the flip a student is expected on every day their placement covers, including days before the flip; correcting the enrolment date re-anchors the placement and is the remedy.
- Changing the student directory (GET /students, /students/table), the students filter surface, the rollover review list, audit history or any by-id historical read. Directories show every status, with the existing explicit status filter.
- Touching the deliberate {ENROLLED, PRE_ENROLLED} exceptions: curriculum-selection window prerequisites, bulk curriculum assignment, the command-center selection-lifecycle tab, notification milestones, rollover eligibility. They are pre-year workflows by prior decision (see §8).
- Cancelling or deleting existing rows (events, justifications, marks, notes, memberships) that belong to now non-ENROLLED students. Rows stay; surfaces stop showing them.
- Teacher/staff PeopleStatus handling in the onboarding card (see §9).
2. Patterns survey
| Analogous module/spec |
What we'd borrow |
What doesn't fit |
docs/superpowers/specs/2026-08-28-timetable-student-participation-eligibility-design.md + src/timetables/timetables.queries.ts (~1367, ~1691) |
The live status: StudentStatus.ENROLLED fence wrapped outside every selector OR; the lifecycle rule matrix as the spec artefact; the "directories and history stay status-neutral" boundary |
That spec scoped itself to timetables and warned "there is no single global active-student predicate". This spec introduces exactly one named predicate for participation and leaves the pre-year {ENROLLED, PRE_ENROLLED} set untouched, so the two coexist by name rather than by accident |
src/students/constants/admissible-status.ts |
A tiny constants module owned by students/ that names a lifecycle set and a predicate function, consumed cross-module |
Admissibility answers "may this record be created in this status"; participation answers "does this student take part in the year now / on D". Sibling file, not an extension |
src/students/student-placement.queries.ts (studentPlacedOnWhere, studentPlacementDisplayWhere) + docs/superpowers/specs/2026-07-26-attendance-temporal-program-contract.md §2b #10 |
The strict/display two-tier read discipline; placement interval as the dated exit predicate; the rule that status is a current-workflow projection |
§2b #10 says status is "never consulted as history". Amended, not reversed: the dated fence consults status only to remove PRE_ENROLLED, the one value the interval cannot express; exits remain interval-dated |
src/common/services/base-tenanted-crud.service.ts (revokeAccessOnArchive) + src/invitations/invitations.service.ts (revokeAccessForArchivedRecipient / restoreAccessForRecipient) |
The revoke/restore pair and its transaction discipline (inside the caller's tx) |
The base hook keys on the literal 'ARCHIVED', which is not a StudentStatus. Students already own a richer status transition in StudentsService.afterUpdate (exit / readmission), so the calls land there, not in the base hook |
src/homerooms/homeroom-eligibility.ts, src/subject-groups/subject-group-eligibility.ts |
Pure classifiers that hide non-ENROLLED students and whose write gates reject them (STUDENT_NOT_IN_ELIGIBLE_POOL / STUDENT_NOT_ELIGIBLE_FOR_SUBJECT, reason NOT_ENROLLED) |
Already correct; these are why class rosters cannot contain a PRE_ENROLLED student and why the import cannot either (no membership column). Cited as the reason roster reads need no fence |
docs/superpowers/specs/2026-08-11-disciplinary-notes-design.md §"writable date" |
Intent already stated: "prevents pre-enrolment and post-exit notes" |
Used placement as the proxy for pre-enrolment; placement is open for PRE_ENROLLED, so the intent never held. This spec fixes the proxy |
src/communications/recipient-policy.ts + memory project_communications_recipient_query_performance |
"Reach edits only in WHERE builders"; one link witness for the referent leg |
Fits cleanly — the status limb rides the existing student WHERE and the existing referent link witness |
3. Architecture mapping
| Primitive |
Apply? |
How |
Justify |
| Tenant scope |
yes |
Every touched query keeps its existing tenantId predicate or policy; the new limbs are added beside them, never replacing them |
Status is orthogonal to tenancy |
| Academic-year scope |
yes |
Unchanged per query. The live fence is year-independent (status is a head flag); the dated fence composes with the existing academicYearId + effectiveOn(D) predicates |
Status never stands in for the year predicate |
| RBAC entity key |
existing |
No entity-keys.ts delta |
Lifecycle is not authority |
| Scopes |
existing |
No scope changes |
Fewer rows on existing routes |
| Actions |
none |
— |
— |
| Service base |
custom |
Predicate constants live in src/students/constants/participation-status.ts (new sibling of admissible-status.ts); consumers are the existing owners' queries.ts / services. Access revoke/restore rides StudentsService.afterUpdate's existing exit/readmission branches |
Ownership stays where the reads already are; one greppable source for the rule |
queries.ts shape |
existing named functions |
Add the status limb to existing WHERE builders; no new repository functions except a student relation filter on placement/membership reads. Rollover gains a bulk membership-closure call |
Keeps data access in current owners |
| Error codes |
existing, one copy widening |
Attendance: ATTENDANCE_ENTRIES_SPAN_GROUPS (group fence runs first). Grades: GRADE_STUDENT_NOT_IN_SUBJECT_GROUP. Disciplinary: DISCIPLINARY_NOTE_STUDENT_NOT_PLACED with messages{en,it} widened to "not enrolled and placed on that date". Invitations: INVITATION_ACCESS_DISABLED (today raised when the department flag is off; now also for a non-ENROLLED student or a referent with no ENROLLED link — messages{en,it} re-read so the copy covers both causes) |
No new failure class; a stale picker recovers the same way it does today |
| DTO conventions |
unchanged |
No request/response shape changes. The referent's children list (GET /referents/me) keeps every link; the FE already reads each child's status through the students read its referent policy allows, so no new field is needed |
Exited children stay reachable for history; the FE routes them to history-only views by status |
| File-backed sub-resources |
n/a |
— |
— |
| Custom fields |
no |
— |
— |
| Profile completeness |
no |
No completion-required field changes; only the dashboard cohort narrows |
— |
Lifecycle rule matrix
| Surface |
Fence |
Anchored on |
Notes |
Attendance expected-day universe (loadPlacedStudentsOn), board catalogue, subject/combined cohort roster, homeroom/grade-group/SG write fences, teacher-day other-groups counts |
dated: placement/membership effectiveOn(D) ∧ status ≠ PRE_ENROLLED |
requested date D |
Exits stay interval-dated; history intact. The homeroom write fence additionally gains the placement intersection it lacked |
| Attendance follow-up worklist/summary, admin justification table |
live: status = ENROLLED |
schoolToday |
Replaces the display-tier leak |
| Attendance cohort ownership authz seam |
none |
D |
A class holding only non-participating students must still be owned by its department, else its manager gets a spurious 403 |
| Attendance notifier (event + reminder deliveries) |
live on the event's student |
now |
Families are not nagged about a child who left |
| Family lists (to-justify, overview, open events, active homework, referent children in list contexts) |
live |
schoolToday |
By-id reads (day view, justification, grade stream, note) keep the link-only access predicate |
| Grades: SG roster, homeroom gradebook, counters, mark create/re-date |
dated: membership effectiveOn(date) ∧ status ≠ PRE_ENROLLED |
contextDate / assignmentDate |
Exits already dated by membership close |
| Disciplinary notes: cohort list |
live |
schoolToday |
Today-anchored list |
| Disciplinary notes: create / re-date gate |
dated: placement effectiveOn(date) ∧ status ≠ PRE_ENROLLED |
note date |
Restores the 2026-08-11 intent |
timetable.published lesson-leg audience |
live |
publish time |
Break/activity legs already fenced upstream |
| Invitations: student invitability + list + onboarding counts |
live |
schoolToday |
Beside the department flag |
| Invitations: referent invitability + list + onboarding counts |
live via link witness: ≥1 linked ENROLLED student |
schoolToday |
See §8 for the pre-year consequence |
| Communications: STUDENT and REFERENT candidate legs |
live (referent via link witness) |
schoolToday |
Send-time classifier inherits, so mailing groups need no re-validation |
| Command-center completeness, onboarding list, onboarding raw counts, overview folds |
live |
schoolToday |
Selection-lifecycle tab, selection-process card, milestones unchanged |
Departments / grades studentCount |
live filtered relation count |
head columns |
Display tier; dated counts are out of scope |
| Login access |
revoke on transition into {LEFT, GRADUATED}, restore on transition out |
status write |
Immediate at the status write, independent of the placement boundary — same as teacher/staff ARCHIVED |
| Rollover bulk exit |
close memberships at the exit boundary |
rollover date |
Symmetry with the PATCH exit; access revocation not needed (no users exist at setup time) |
| Student directory, students filter surface, rollover review, audit, by-id history |
none |
— |
Unchanged, documented as such |
The dated fence is a single limb, status: { not: PRE_ENROLLED }, placed beside an effectiveOn(D) interval predicate. It removes only the status whose non-participation the interval cannot express; it never removes exits, whose interval already ends. The live fence is status: ENROLLED.
4. Data model plan
Schema deltas
Migration shape
- n/a — no schema change, no backfill. Existing rows of non-
ENROLLED students stay untouched.
Indexes and uniqueness
- None.
students.status is a low-cardinality column; the new limbs ride existing tenant/year indexes as post-filters. The placement universe read adds a student relation join per expected-day resolution; the query-budget spec counts delegate calls, not joins, and stays at its current bound.
5. API surface
No new routes. Behavior changes on existing routes:
| Verb |
Path |
Change |
| GET |
/attendance/groups, /groups/pending, /rows, /inconsistencies, /teacher/day |
Rosters, cards, totals exclude PRE_ENROLLED on every date; exits unchanged (interval-dated) |
| POST / PATCH |
/attendance/records, /attendance/records/:id |
A PRE_ENROLLED student fails the group fence (422 ATTENDANCE_ENTRIES_SPAN_GROUPS); the homeroom write fence now also requires an effective placement |
| GET |
/attendance/follow-ups, /follow-ups/summary, /attendance/justifications |
ENROLLED students only |
| GET |
/attendance/family/to-justify, /family/overview, family open-event lists |
ENROLLED children only; /family/students/:id/day/:date and justification by id unchanged |
| GET |
/grades/subject-group/:id, /grades/subject-groups, /grades/homeroom/:id |
Rosters and counters exclude PRE_ENROLLED |
| POST / PATCH |
/grades, /grades/:id (date move) |
PRE_ENROLLED subject → 422 GRADE_STUDENT_NOT_IN_SUBJECT_GROUP |
| GET |
/disciplinary-notes?cohortType&cohortId |
Cohort roster ENROLLED only |
| POST / PATCH |
/disciplinary-notes, /disciplinary-notes/:id |
PRE_ENROLLED subject → 422 DISCIPLINARY_NOTE_STUDENT_NOT_PLACED (copy widened) |
| GET |
/assignments/family/active |
ENROLLED children only |
| POST |
/invitations, /invitations/:id/resend |
Non-ENROLLED student, or referent without an ENROLLED linked student, is not invitable → INVITATION_ACCESS_DISABLED |
| GET |
/invitations |
Same cohort rule on the list |
| GET |
/communications/recipients; POST /communications, mailing-group member validation |
Non-ENROLLED students and their referents are non-contactable |
| GET |
/dashboard/completeness, /dashboard/onboarding, /dashboard/overview |
ENROLLED students only in student legs; referent leg requires an ENROLLED link |
| GET |
/departments, /departments/:id, /departments/:id/grades |
studentCount counts ENROLLED only |
| PATCH |
/students/:id (status → LEFT/GRADUATED, or back) |
Revokes / restores platform access in the same transaction |
| POST |
setup rollover apply |
Bulk exit closes memberships |
Swagger considerations
- Route JSDoc for the affected GETs gains one FE-facing sentence: "Only students with status
ENROLLED are listed" (or "expected on the date", for register reads). No error examples change shape.
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 |
7. Divergence ledger
| Pattern |
We diverge by |
Reason |
Tradeoff accepted |
Temporal contract §2b #10: "status … never consulted as history" |
Dated reads now carry status ≠ PRE_ENROLLED beside the interval predicate |
Pre-enrolment has no dated event; the interval alone cannot express "never participated" |
A PRE_ENROLLED → ENROLLED flip mid-year makes the student expected on every placement-covered day before the flip. Remedy: correct the enrolment date (re-anchor) |
| 2026-08-28 spec: "there is no single global active-student predicate" |
Introduce one named participation predicate (ENROLLED) used across modules |
The audit found the same leak in nine modules; a named, greppable rule is cheaper than nine local conventions |
The pre-year {ENROLLED, PRE_ENROLLED} set stays as a second named predicate; the two must never be merged |
Display-tier placement reads (studentPlacementDisplayWhere) on today-anchored operational lists |
Add the live status limb beside the display cohort |
The display fallback exists for pre-year lists and exited-student labelling, not to re-admit exited students into worklists |
Setup-time pickers keep the fallback; only worklists narrow |
Base-service revokeAccessOnArchive hook |
Students call revoke/restore from StudentsService.afterUpdate |
The base hook keys on 'ARCHIVED'; students already run a richer exit/readmission branch in the same tx |
Two call sites for one behavior; documented in ch03 |
| Family access predicate (link-only) |
Lists get a second predicate (link ∧ ENROLLED); by-id reads keep link-only |
Decision: hide exited children from active lists and badges, keep history reachable |
The FE routes exited children to history-only views using the status it already reads from /students |
| Attendance homeroom write fence (membership-only) |
Gains the placement intersection |
Every other roster construction already intersects placement; the omission was a hole, not a choice |
A stale-open membership without placement is now rejected at the fence |
8. Pushback log
| US says |
Conflicts with |
Proposed instead |
Status |
| "anything that relates to the rolling year should include only enrolled" applied to invitations and communications |
Pre-enrolled families cannot be onboarded or emailed before the year starts; the selection-window and milestone exceptions still target PRE_ENROLLED students, whose referents now need an ENROLLED sibling to be invitable |
Surfaced 2026-09-03; user reaffirmed "remove pre enrolled from everything". Consequence accepted: pre-enrolled families are reached once the student is flipped to ENROLLED, or the office makes the selection on their behalf |
Resolved (user decision) |
| "students with a status different from enrolled should not come up … in attendance groups" read literally over history |
The compliance register would go dark for every student who left; recorded cells become orphans the board no longer renders |
Exits stay interval-dated on register reads; only today-anchored surfaces apply the live fence |
Resolved (user: "the rest is ok") |
| Referent invitability was not named in the report |
A referent whose only linked children are non-ENROLLED would still be invited and listed under a student-only fence |
Referent legs (invitations, onboarding counts, communications) use the same link witness as the communications referent leg |
Resolved by default in this spec; say so if referents should stay unfenced |
| Event-driven deliveries after an exit |
Attendance reminders keep nagging a departed family; a disciplinary note armed before an exit would be delivered after it |
Fence the attendance notifier (recurring); leave disciplinary armed delivery alone (one-shot, minutes after authoring, and a stuck PENDING row is worse) |
Resolved |
9. Deferrals
PRE_ENROLLED → ENROLLED as a dated event — needs a placement-model iteration (pre-enrolled students would hold no effective placement until enrolment) — follow-up: revisit if schools flip status mid-year often; remedy today is correcting the enrolment date.
- Teacher/staff
PeopleStatus.ARCHIVED in the onboarding card and counts — same class of leak, different enum, not a student concern — follow-up: separate one-file change after this lands.
- Disciplinary armed delivery for a student who exits between authoring and
armAt — one-shot, minutes-scale window — follow-up: only if observed.
- Cleaning up open
AttendanceDayEvent rows of exited students — rows stay open and hidden; no cancellation — follow-up: only if the FE needs a terminal state.
- Dated (placement-based) department counts — head-based live count matches the display tier used everywhere in configuration — follow-up: none planned.
- Attendance cohort-ownership seam status fence — deliberately unchanged (spurious 403 risk) — follow-up: none.
10. Open questions
11. Verification plan
- Unit specs:
src/students/constants/participation-status.spec.ts — the two predicate builders and the status classifier.
src/attendance/attendance.queries.spec.ts — where-shape assertions on loadPlacedStudentsOn, loadHomeroomRoster (placement + status), loadGradeGroupRoster, loadCohortRosterIds, countSubjectGroupRostersOn; attendance-day.queries.spec.ts for the catalogue and subject cohort; attendance-insights.service.spec.ts and attendance-justifications.queries.spec.ts for the live limb; attendance-notifier.queries for the event student fence; attendance-family.* for list vs by-id predicates. expected-attendance.query-budget.spec.ts must stay at its bound.
src/grades/grades.queries.spec.ts / grades.service.spec.ts — roster excludes PRE_ENROLLED; create/re-date rejects.
src/disciplinary-notes/disciplinary-notes.queries.spec.ts — cohort live fence; create gate dated fence.
src/assignments/assignments.queries.spec.ts — family active list fence, by-id unchanged.
src/timetables/timetables.queries.spec.ts — published-audience lesson leg fence.
src/invitations/invitations.queries.spec.ts — student invitability requires ENROLLED; referent requires an ENROLLED link; list cohorts.
src/communications/recipient-policy.spec.ts — STUDENT and REFERENT legs; classifier marks exited/pre-enrolled non-contactable.
src/command-center/*.spec.ts — completeness and onboarding cohorts; raw-SQL leg asserted by the e2e below.
src/departments/departments.service.spec.ts — filtered relation count.
src/students/students.service.spec.ts — exit calls revoke, readmission calls restore, both inside the tx; rollover bulk exit closes memberships.
- E2E specs (fixture rules a–k from memory
project_e2e_temporal_fixture_discipline: self-provision students with explicit placements, run-relative dates, never borrow seeded students for as-of-D assertions):
test/attendance-temporal.e2e-spec.ts — PRE_ENROLLED student with an effective placement is expected on no date; LEFT student appears before the boundary and not after; flip to ENROLLED makes the student expected from placement start.
test/attendance.e2e-spec.ts — register write for a PRE_ENROLLED student → 422 ATTENDANCE_ENTRIES_SPAN_GROUPS; homeroom cohort write for a member with no placement → same.
test/attendance-teacher.e2e-spec.ts — rosterSize excludes the pre-enrolled fixture.
test/attendance-family.e2e-spec.ts — to-justify hides the exited child; day view by id still 200.
test/grades*.e2e-spec.ts, test/disciplinary-notes*.e2e-spec.ts — roster/list exclusion and the two 422s.
test/invitations*.e2e-spec.ts — LEFT student not invitable and not listed; referent with only a LEFT child not listed.
test/communications*.e2e-spec.ts — recipients exclude the exited student and their referent; send with a stale mailing group drops them as non-contactable.
test/students*.e2e-spec.ts — PATCH to LEFT deactivates the user and refresh fails; PATCH back to ENROLLED restores.
test/command-center*.e2e-spec.ts, test/departments*.e2e-spec.ts — counts exclude the exited and pre-enrolled fixtures.
- Manual verification: on the seeded dev tenant, create one
PRE_ENROLLED student in a grade with a homeroom-less peer, open the attendance board and the teacher day for today, then flip the student to ENROLLED and reload; set an enrolled student to LEFT, confirm yesterday's register still shows them and today's follow-ups do not, and confirm their login is refused.
12. Sign-off
- Approved by: Fabio Barbieri
- Date: 2026-09-03
- Chat reference: approved in chat 2026-09-03 ("ok go on") after the two-tier split was rejected in favour of ENROLLED-only, GRADUATED = LEFT, referent link-witness fence accepted by default
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.