Skip to content

Teacher people-visibility narrowing (no People section for simple teachers)

1. Problem distillation

  • Product decision: a simple teacher (teacher role only) must not see the People section at all. Today the role reads: same-department colleagues (teachers.identity + teachers.contacts), tenant-wide staff contacts (staff.contacts + StaffPolicy teacher pass-through), tenant-wide referent resolution (ReferentsPolicy teacher pass-through on GET /referents/:id), and department-wide students on every student scope.
  • Target state: zero grants on the Teachers and Staff entities (directory, detail, filters all 403; no teachers.* / staff.* keys in the compiled permission map, so FE nav hides the tabs); students narrowed from department-wide to "students they teach"; referents narrowed from tenant-wide to referents linked to a taught student.
  • "Students they teach" (Fabio-approved 2026-08-02): students holding a dated roster assignment to (a) an SG where the caller holds a dated teaching episode, or (b) an SG combined on the queried day with an SG they teach (parity with the attendance teacher surface, which already shows them the whole combined-class roster), plus (c) students in a homeroom where the caller is the tutor, plus (d) students in the audience of a ScheduledActivity the caller supervises in the timetable version governing the day — supervision is attendance-bearing teaching (Fabio 2026-08-02). A teacher with no assignments sees no students.
  • Self-view is untouched by construction: GET/PATCH /teachers/me, the /teachers/me/documents/* routes, and GET /auth/profile are scope-decorator-free (self-service design 2026-07-17).
  • Teachers keep all sections on the students they can see (identity through health/documents/curriculum_selection) — narrowing is who, not what (Fabio 2026-08-02).

Success criteria (observable behavior that proves this works): - As a teacher-role caller: GET /teachers, GET /teachers/:id, GET /filters/teachers, GET /staff, GET /staff/:id → 403 (InsufficientScopeException); GET /auth/permissions-style compiled map contains no teachers.* or staff.* entries (incl. others). - GET /students, GET /students/table, GET /filters/students as a teacher return exactly the taught set (SG-episode ∪ CC-sibling ∪ tutored-homeroom ∪ supervised-activity audience, all hops dated on schoolToday); a colleague's student in the same department but outside the taught set is invisible (list) and 404 (detail). - A teacher supervising an activity sees every student its audience selectors expand to on schoolToday (all seven kinds), whether or not the activity meets today (weekday deliberately ignored — visibility is week-long, unlike the day-scoped register). - GET /referents/:id as a teacher resolves only referents linked to a taught student; every other referent 404s. GET /referents (list) stays 403 for teachers (already role-gated to admin/principal/department_head). - Homeroom/SG roster views and grouped boards, which AND StudentsPolicy.where into roster queries, show a teacher only the taught subset of any visible group's roster. - GET/PATCH /teachers/me and GET /auth/profile still return the caller's own full record. - Reseeding an existing tenant prunes the revoked grants (frozen-role reconciliation), with no manual migration.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No scrubbing of colleague names embedded in server-composed payloads (timetable lessons, SG teacher lists, homeroom tutor, attendance rosters). Product acked: the ask is "no People section", not "no names anywhere" (Fabio 2026-08-02). - No change to any other role's grants or policy branches. - No change to the structural dept-wide teacher branches (departments, curricula, grades-of-department, selection windows, attendance day-shape) — those gate structure, not people. - No CC-sibling or supervised-activity widening of the HomeroomsPolicy/SubjectGroupsPolicy teacher branches, and no activity leg in grades.visibility.ts (see §9 Deferrals). - No slimming of the student scope set visible to teachers.


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-07-17-self-service-profile-editing-design.md + prisma/seed/roles.ts (TEACHER_EXCLUDED_SCOPE_KEYS) The exact prior teacher-narrowing move: add scope keys to the exclusion set; expected-preset-grants.ts derives from the same constant so the drift guard follows automatically Others-scopes (teachers.others, staff.others) are seeded unconditionally (seed-role.ts others loop has "no exclusions") — this iteration must add an exclusion knob there
src/homerooms/homerooms.policy.ts teacher branch The dated userId traversal for "taught": roster hop + SG hop + teaching-episode hop, each wrapped in effectiveOn(requireSchoolToday(ctx)); the requireSchoolToday throw-on-missing discipline Anchor entity is Student, not Homeroom — the traversal starts from subjectGroupAssignments/homeroomAssignments; and we add the CC-sibling leg it doesn't have
src/attendance/attendance-access.service.ts (assertTeacherCohortRead) + loadCombinedClassMembersOn The CC semantics: teaching any member SG of a combined class grants the whole combined cohort; membership is the dated CombinedClassMembership episodes, never the denormalized head That is an imperative service assert; here the same rule must be a pure Prisma WHERE inside a policy branch (combinedClassMemberships → combinedClass → memberships → subjectGroup.teachers)
src/grades/grades.visibility.ts teacher branch Confirms the product semantics already exist in tree: teacher grade-book visibility = taught SGs + tutored homerooms, dated ID-list resolved by a pre-query (loadTaughtSubjectGroupIds); policies are synchronous WHERE-builders, so we inline the traversal instead
src/attendance/expected/expected-audience.ts (expandAudienceOn, temporal program spec E) + resolveActivityAudiences in src/timetables/ The audience-kind semantics for leg (d): STUDENT direct, HOMEROOM via dated assignment, DEPARTMENT/GRADE via placement, CURRICULUM/TRACK via curriculum selection, EVERYONE = the AY's placed body; and the "placement is the candidate universe" rule Both are imperative expanders (index-based / live-row); the policy needs the same kind table as relational EXISTS legs, resolved as-of-D (dated placement rows, not the forward-head Student.departmentId columns)
src/timetables/timetables.queries.ts (findTimetableVersionEffectiveOn) The status-free governing-version fragment for leg (d): effectiveFrom <= D AND (effectiveUntil IS NULL OR effectiveUntil > D) — DRAFT and empty [X, X) intervals self-exclude; correctly picks an ARCHIVED copy still governing D during a pre-effective republish window Fits cleanly as a nested WHERE on activity.timetable
docs/superpowers/specs/2026-07-22-frozen-preset-role-reconciliation-design.md (src/permissions/role-clone.ts) Rollout: teacher is a frozen preset; the global row is seeded with prune: true and reconcileFrozenPresetRolesIntoTenants mirrors the delta onto every tenant clone on reseed Fits cleanly — needs the usual prod reseed, no migration
src/referents/referents.policy.ts referent branch + src/students/students.policy.ts referent branch The link hop (StudentReferentLink) is deliberately undated (guardianship is not valid-time data); only the taught-side hops are dated Fits cleanly

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged — every policy branch keeps buildBase tenantId, and the inner teacher/homeroomTeacher hops pin tenantId like the existing branches No new query surface
Academic-year scope yes Implicit via the dated hops: roster/teaching/membership episodes are AY-bound rows filtered by effectiveOn(schoolToday) Same C2/C3 discipline as homerooms/SG policies
RBAC entity key existing No delta to entity-keys.ts Grant prune + policy rework only
Scopes existing No catalogue delta; teacher role loses teachers.identity, teachers.contacts, staff.contacts (native) and teachers.others, staff.others (others-scopes) ScopeGuard's @RequireScopes(entity, mode) passes on any scope of the entity — leaving others READ would keep /teachers reachable and keep a teachers.* key in the FE permission map
Actions none Teacher keeps attendance.take + grade_entries.record Untouched
Service base n/a No service-layer change; visibility flows through the policies services already AND in
queries.ts shape n/a No new queries — policy WHERE-builders are pure
Error codes existing 403s are the existing InsufficientScopeException / role-gate denial; narrowed reads 404 via policy-composed WHERE No new codes
DTO conventions n/a No DTO changes
File-backed sub-resources n/a Student document routes ride StudentsPolicy (person-document-operations.ts) — narrowing flows in automatically; /teachers/me/documents/* is scope-free and unaffected
Custom fields yes (grants only) Teacher loses teachers.others + staff.others READ via a new others-exclusion knob in seed-role.ts (§6); keeps students.others etc.
Profile completeness n/a Command-center is admin-facing; teacher isn't admitted

4. Data model plan

Schema deltas

  • None.

Migration shape

  • None. Grant revocation is seed-reconciled (frozen preset + prune), not migrated.

Indexes and uniqueness

  • None. The new traversals ride existing indexes (subject_group_assignments studentId/subjectGroupId, subject_group_teachers subjectGroupId/teacherId, combined_class_memberships subjectGroupId/combinedClassId, homeroom_assignments, student_referent_links.referentId).

5. API surface

No new routes; no route decorator changes. Behavioral deltas for a teacher-role caller:

Verb + Path Before After
GET /teachers, GET /teachers/:id 200, same-dept colleagues, identity+contacts 403 (no teachers.* scope)
GET /filters/teachers 200, same-dept 403
GET /staff, GET /staff/:id 200, tenant-wide, contacts 403 (no staff.* scope)
GET /referents/:id 200 for any referent in tenant 200 only if linked to a taught student, else 404
GET /referents 403 (role-gated) unchanged 403
GET /students, /students/table, GET /students/:id, student sub-resources (guardians read, documents, filters) dept-wide taught set only (SG ∪ CC-sibling ∪ tutored homeroom ∪ supervised-activity audience, dated); all scopes still visible on those rows
Homeroom/SG rosters + grouped boards roster ∩ dept-wide students roster ∩ taught set
GET/PATCH /teachers/me, /teachers/me/documents/*, GET /auth/profile own record unchanged

Swagger considerations

  • None structural. JSDoc on /teachers, /staff, /referents/:id, /students list routes mentions caller-dependent visibility in generic terms already; verify no copy promises teachers directory access (adjust wording if it does).

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) TEACHER_EXCLUDED_SCOPE_KEYS += teachers.identity, teachers.contacts, staff.contacts (teacher then holds zero native scopes on both entities). New TEACHER_EXCLUDED_OTHERS_ENTITIES = new Set(['teachers', 'staff']) passed to the teacher seedRoleForTenant call
seed-role.ts helper SeedRoleInput gains optional othersExcludedEntities?: ReadonlySet<string>, mirroring nativeScopes.excludedKeys; the others loop skips excluded entities, so prune: true deletes the two stale grants on the global preset row
expected-preset-grants.ts Teacher others loop skips TEACHER_EXCLUDED_OTHERS_ENTITIES (import the new constant — the drift guard and the db-sync e2e then track the change with no further edits)
*_SCOPES runtime constant none — no scope is added or removed from the catalogue, only grants
Tenant propagation automatic: reconcileFrozenPresetRolesIntoTenants mirrors the pruned global row onto every tenant clone at Tier-1 seed. Needs the usual prod reseed (same rollout as the 2026-07-22 narrowing)

Policy deltas (the second half of the change)

  • TeachersPolicy (src/teachers/teachers.policy.ts): delete the teacher branch. policy.roles then drops 'teacher', keeping @AppliesPolicy role gates consistent with the grant removal; a teacher reaching the policy through any future cross-module consumer fail-closes (NEVER_MATCH_WHERE when no branch contributes). Verified consumers today: teachers module + filters.service/filters.controller — all behind the teachers read gate the role no longer passes.
  • StaffPolicy (src/staff/staff.policy.ts): delete the teacher pass-through. Only consumer is the staff module (behind the staff read gate).
  • StudentsPolicy (src/students/students.policy.ts): rework the teacher branch from studentPlacedInDepartmentsOn(ctx.teacherDepartmentIds, …) to the taught-set WHERE. Extract it as an exported builder (working name studentTaughtByCallerOn(ctx, schoolToday): Prisma.StudentWhereInput) so ReferentsPolicy reuses the identical subtree:
OR: [
  { subjectGroupAssignments: { some: { ...effectiveOn(today), subjectGroup: { OR: [
      { teachers: { some: { ...effectiveOn(today), teacher: { userId, tenantId } } } },            // (a) direct episode
      { combinedClassMemberships: { some: { ...effectiveOn(today), combinedClass: { memberships: { some: {
          ...effectiveOn(today), subjectGroup: { teachers: { some: { ...effectiveOn(today),
          teacher: { userId, tenantId } } } } } } } } } } },                                       // (b) CC-sibling
  ] } } } },
  { homeroomAssignments: { some: { ...effectiveOn(today), homeroom: { homeroomTeacher: { userId, tenantId } } } } },  // (c) tutor
  // (d) supervised-activity audience — see below
]

Leg (a) stays alongside (b): a solo SG has no membership rows, so the CC leg alone would miss it. The branch no longer consumes ctx.teacherDepartmentIds (and drops the empty-set NEVER_MATCH guard — an assignment-less teacher simply matches no rows); requireSchoolToday still throws when the caller forgot to derive a date.

Leg (d) — supervised-activity audience. Let SUPERVISED = { teachers: { some: { teacher: { userId, tenantId } } }, timetable: { effectiveFrom: { lte: today }, OR: [{ effectiveUntil: null }, { effectiveUntil: { gt: today } }] } } — an activity of the caller's in the version governing schoolToday (status-free fragment per findTimetableVersionEffectiveOn; at most one version governs a (tenant, AY) by the frontier invariant). weekdays is deliberately not filtered: supervising a Monday-only activity grants week-long visibility, same as teaching a Monday-only SG. The audience expansion becomes one OR sub-leg per selector kind, each an EXISTS chain from Student mirroring spec-E semantics as-of-D: - STUDENTscheduledActivityAudiences: { some: { activity: SUPERVISED } } (direct ref); - HOMEROOM — via homeroomAssignments (dated) → homeroom.activityAudiences; - DEPARTMENT / GRADE — via the dated placements rows → department. / grade. audience back-relations (as-of-D placement, not the forward-head Student.departmentId/gradeId columns); - CURRICULUM / TRACK — via the student's curriculum-selection rows → curriculum. / track. audience back-relations; - EVERYONE — student is placed on today (placement-as-candidate-universe) in the AY of a governing version containing a SUPERVISED activity with an EVERYONE selector row (traversal via academicYear.timetables).

Exact Prisma relation names for the audience back-relations are verified at implementation; every relation field has a mandatory Prisma back-relation, so all six chains are expressible without raw SQL. - ReferentsPolicy (src/referents/referents.policy.ts): teacher pass-through → students: { some: { student: { tenantId, OR: <taught legs> } } } (link hop undated, per pattern). Imports the shared builder from ../students (same cross-module direction as filtersstudents; no cycle: students does not import referents' policy). referents.service.ts must thread { ...ctx, schoolToday } into ReferentsPolicy.where on its read paths (findOne/requireRow) — today it passes bare ctx, which the new dated branch would reject for teacher callers. - GuardiansWritePolicy: untouched (admin + referent writers only); guardian reads ride StudentsPolicy and narrow automatically.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
DepartmentsPolicy/GradesPolicy deliberately keep branches for roles without direct grants (cross-module consumers) Deleting the teacher branches on TeachersPolicy/StaffPolicy outright Audited consumers: both policies are only consumed under their own entity's read gate, which the teacher role no longer passes; a kept branch would be dead code implying reachable visibility A future cross-module consumer must consciously re-add a teacher branch; fail-closed is the safe default
Sibling dept-wide teacher branches (departments, curricula, grades-of-dept, selection windows, attendance day-shape) StudentsPolicy teacher branch becomes assignment-anchored while those stay teacherDepartmentIds-based Product draws the line between people (narrow to taught) and structure (dept-wide stays); grades.visibility already sits on the narrow side Two narrowing idioms coexist across policies; documented in ch04
grades.visibility.ts "taught" = direct SG episodes + tutored homerooms StudentsPolicy "taught" additionally includes CC-sibling rosters and supervised-activity audiences Parity with the attendance teacher surface: a teacher taking attendance for a combined cohort or a supervised activity must be able to open those students' profiles Asymmetry with the grade book (which stays anchored on own SGs by design — supervisors don't grade)
"Only students they teach" as a narrowing An EVERYONE- or DEPARTMENT-audience activity makes its supervisor see the whole student body / department — potentially wider than the dept-wide read being removed Faithful to "supervision is teaching": the register already hands that supervisor the full live roster; visibility is data-driven by how the school authors audiences A school that gives a simple teacher a whole-school assembly slot has, by that act, granted them school-wide student profiles
grades.visibility.ts resolves "taught" with an async pre-query feeding a pure builder StudentsPolicy stays a synchronous pure WHERE-builder despite the larger traversal StudentsPolicy.where is consumed synchronously across ~7 modules (homerooms, subject-groups, filters, blueprint, completeness, files, students); an async signature would ripple through all of them One large OR-of-EXISTS WHERE; audience/teaching tables are small and indexed, acceptable plan shape

8. Pushback log

US says Conflicts with Proposed instead Status
"Teachers should have no reads on other Teachers / same for staff" Colleague names appear inside timetable lessons, SG teacher lists, homeroom tutor fields, attendance payloads Kill the entity grants (directory/detail/filters + FE nav); leave server-composed embeds Resolved — acked by Fabio 2026-08-02
"Only themself and the students they teach" (referents unmentioned) Teacher currently resolves any referent tenant-wide Keep referents.identity/contacts/students READ but narrow the policy branch to referents of taught students Resolved — directed by Fabio 2026-08-02
"Only students they teach" Attendance teacher-day shows activity rosters; first draft treated supervision as not-teaching (payload names visible, profiles not) Supervision is teaching: supervised-activity audiences join the taught set as leg (d) Resolved — directed by Fabio 2026-08-02

9. Deferrals

  • HomeroomsPolicy/SubjectGroupsPolicy teacher branches do not gain the CC-sibling or supervised-activity legs — a teacher may see student S (via a combined cohort or an activity audience) without seeing S's homeroom/SG unless tutor/direct-taught. Revisit if FE reports navigation gaps — follow-up: memory project_teacher_people_visibility_narrowing.
  • grades.visibility.ts keeps its narrower taught set (own SGs + tutored homerooms) — supervisors and CC-siblings don't grade; deliberate, not drift.
  • FE dropdowns on teacher-facing views that used /filters/teachers (if any) lose their source; FE guide flags it — follow-up: FE-guide task in the plan.
  • Any future "teacher sees colleague timetable by name" product ask would need a deliberate re-grant or a purpose-built surface — none planned.

10. Open questions

None — all five design questions (referents narrowed-not-cut, taught-set definition incl. CC-sibling + tutor legs, full scope set on visible students, embedded names persist, supervised activities count as teaching) resolved by Fabio in chat 2026-08-02.


11. Verification plan

  • Unit specs:
  • students.policy.spec.ts — teacher branch: direct-episode match; episode expired/starting-tomorrow excluded (dated hops); CC-sibling match via dated membership; CC membership ended → sibling drops; tutored-homeroom match; activity legs — supervised activity in the governing version matches its STUDENT/HOMEROOM/DEPARTMENT/GRADE/CURRICULUM/TRACK/EVERYONE audiences, a DRAFT-timetable activity matches nothing, a superseded version's activity stops matching once effectiveUntil <= today, weekday mismatch still matches; assignment-less non-supervising teacher matches nothing; missing schoolToday throws; other branches untouched.
  • teachers.policy.spec.ts / staff.policy.spec.tsroles no longer contains 'teacher'; teacher ctx resolves NEVER_MATCH_WHERE.
  • referents.policy.spec.ts — teacher narrowed to taught-linked referents; referent/admin branches untouched; missing schoolToday throws for teacher.
  • Seed-helper unit coverage for othersExcludedEntities (skip + prune) appended where computeFrozenRoleDelta/seed-role cases live (role-clone.spec.ts).
  • E2E specs:
  • teachers.e2e-spec.ts / self-service e2e — directory/detail as teacher flip from 200-narrowed to 403; /teachers/me read+patch still green.
  • staff.e2e-spec.ts — list/detail as teacher → 403.
  • students.e2e-spec.ts (or the roster e2e home the fixtures fit best) — taught-only visibility: same-dept-untaught student invisible/404; CC-sibling student visible; tutored-homeroom student visible; supervised-activity audience student visible (one representative kind, e.g. HOMEROOM, in a published governing timetable); dated fixtures straddle schoolToday per feedback_e2e_isolation_patterns + temporal fixture rules.
  • referents e2e — GET /referents/:id as teacher: linked-to-taught 200, unlinked 404.
  • filters.e2e-spec.ts/filters/teachers as teacher → 403; /filters/students returns taught set.
  • rbac-grants.db-sync.e2e-spec.ts — no edit needed; follows the constants and verifies prune/reconcile.
  • Manual verification: none beyond e2e; prod rollout = reseed (frozen-role reconciliation) + FE coordination.

Docs to land with the change: ch04 (teacher preset description + policy inventory rows), ch05 n/a, docs/REFERENCE.md module rows (teachers/staff/students/referents visibility notes), and the authoritative FE guide updated in place (People section hidden for teacher role; student lists shrink; /filters/teachers gone for teachers) per feedback_plans_end_with_fe_guide_task.


12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-02
  • Chat reference: "approved" in chat 2026-08-02, after the supervised-activity iteration (activities count as teaching; EVERYONE/DEPARTMENT-audience widening acked in the same walkthrough)

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.