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+StaffPolicyteacher pass-through), tenant-wide referent resolution (ReferentsPolicyteacher pass-through onGET /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
ScheduledActivitythe 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, andGET /auth/profileare 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_assignmentsstudentId/subjectGroupId,subject_group_teacherssubjectGroupId/teacherId,combined_class_membershipssubjectGroupId/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,/studentslist 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 theteacherbranch.policy.rolesthen drops'teacher', keeping@AppliesPolicyrole gates consistent with the grant removal; a teacher reaching the policy through any future cross-module consumer fail-closes (NEVER_MATCH_WHEREwhen no branch contributes). Verified consumers today: teachers module +filters.service/filters.controller— all behind theteachersread gate the role no longer passes.StaffPolicy(src/staff/staff.policy.ts): delete theteacherpass-through. Only consumer is the staff module (behind thestaffread gate).StudentsPolicy(src/students/students.policy.ts): rework theteacherbranch fromstudentPlacedInDepartmentsOn(ctx.teacherDepartmentIds, …)to the taught-set WHERE. Extract it as an exported builder (working namestudentTaughtByCallerOn(ctx, schoolToday): Prisma.StudentWhereInput) soReferentsPolicyreuses 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:
- STUDENT — scheduledActivityAudiences: { 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 filters → students; 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/SubjectGroupsPolicyteacher 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: memoryproject_teacher_people_visibility_narrowing.grades.visibility.tskeeps 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 onceeffectiveUntil <= today, weekday mismatch still matches; assignment-less non-supervising teacher matches nothing; missingschoolTodaythrows; other branches untouched.teachers.policy.spec.ts/staff.policy.spec.ts—rolesno longer contains'teacher'; teacher ctx resolvesNEVER_MATCH_WHERE.referents.policy.spec.ts— teacher narrowed to taught-linked referents; referent/admin branches untouched; missingschoolTodaythrows for teacher.- Seed-helper unit coverage for
othersExcludedEntities(skip + prune) appended wherecomputeFrozenRoleDelta/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/meread+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 straddleschoolTodayperfeedback_e2e_isolation_patterns+ temporal fixture rules.referentse2e —GET /referents/:idas teacher: linked-to-taught 200, unlinked 404.filters.e2e-spec.ts—/filters/teachersas teacher → 403;/filters/studentsreturns 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.