Skip to content

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

  • None.

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

  • Two-tier (participating vs present) split — rejected by user 2026-09-03: one predicate, ENROLLED only, pre-enrolled removed from invitations, communications, completeness/onboarding and department counts as well.
  • Treat GRADUATED like LEFT everywhere, access revocation included — yes (user 2026-09-03).
  • Family lists hide exited children, history stays reachable by id — yes.
  • Slicing: attendance first, then participating surfaces, then onboarding/contact/access — yes.
  • Referent fence via link witness (≥1 ENROLLED linked student) — resolved by default in §8; overridable at sign-off.

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.tsPRE_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.tsrosterSize 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.tsLEFT 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.