Skip to content

Unified Students API — Record-Level Access via Context

Status: Design approved, awaiting implementation plan Date: 2026-04-20 Author: brainstormed with Fabio Barbieri

Problem

GET /students is currently restricted via @RequireRoles('admin', 'teacher'). Referents (parent/guardian users, soon mobile-first) fetch their children through a parallel API surface:

  • GET /referents/me/students — a referent's own linked students
  • GET /referents/:id/students — admin/teacher looking at a specific parent's children

With the referent mobile app landing, duplicating student read logic across two surfaces is untenable. Every change to student shape, filtering, or pagination must be made twice. The existing RBAC doc (chapter 04) already anticipates a single endpoint narrowed by role-aware record-level filtering — we just haven't built it.

Decision: unify. GET /students becomes the single entry point; which records are visible is driven by the caller's access context (tenantId + userId + roles). The handler never branches by role; the Prisma where clause is composed from a role-aware filter function.

Scope

In scope

  • GET /students — listed records restricted by the caller's access context.
  • GET /students/:id — returns the record if visible to the caller, 404 otherwise.
  • New RecordAccessContext interface.
  • New @AccessContext() parameter decorator.
  • New studentsForAccessContext(ctx) query helper in src/students/students.queries.ts.
  • @RequireRoles on both read routes widens to ('admin', 'teacher', 'referent').
  • Deletion of GET /referents/me/students, GET /referents/:id/students, their ReferentsService methods (findStudentsByUserId, findStudentsByReferentId), their referents.queries.ts helpers (studentsForReferentByUserId, studentsForReferentById), their swagger decorators (ApiGetMeStudents, ApiGetReferentStudents), and the tests covering them.
  • Update to chapter 04 RBAC doc: the paragraph at line 402 is rewritten; the "Record-Level Access" sketch (lines 594–634) is replaced with the implemented pattern.

Out of scope (explicitly deferred)

  • POST / PATCH / DELETE /students/:id scoping. No current role has write on students without being admin-equivalent; no record-level filter is needed on writes today. Revisit when a write-capable non-admin role is introduced.
  • Admin-facing "show me this specific parent's children" view. A future spec may add this as a relation on the referent DTO or as a dedicated lookup — not this spec.
  • Pushing RecordAccessContext into BaseTenantedCrudService. Premature until a second entity joins the pattern.
  • Postgres RLS policies. The query helpers are shaped to translate directly, but RLS itself is a separate migration.
  • Teacher-specific filtering. The teacher branch of studentsForAccessContext initially returns { tenantId } (same as admin) with a TODO to narrow once class assignments are modeled. Matches current behavior — teachers already see all tenant students today.
  • Consolidating GET /referents/me / GET /users/me into a single app-wide /me. Captured in the pattern library as a rule; acting on it is future work.

Decisions log

Ordered answers to the clarifying questions during brainstorming. Each links to the design section enforcing the decision.

  1. Unlinked-student access by referent → 404 Not Found. Falls out of findFirst({ where: { id, ...filter } }) and matches the tenant-boundary pattern. See §3.
  2. Same DTO for every role. Roles differ only in which records and which scope groups survive; never in response shape. See §3.
  3. Filter lives as a named function in queries.ts. Rejected the inline and Prisma-middleware alternatives. See §2.
  4. RecordAccessContext threaded via @AccessContext() param decorator + StudentsService override. BaseTenantedCrudService is not modified. See §2.
  5. @RequireRoles widened to explicit allowlist ('admin', 'teacher', 'referent'). Kept in lockstep with filter branches. See §2 and §5.
  6. No alias period; delete the /referents/*/students endpoints outright. Web and mobile clients migrate in the same release window. See §4.
  7. Generalization to other entities is the future direction but not this spec's job. Design documents the pattern but touches only students. See §6.
  8. /me is a single app-wide endpoint (GET /auth/me). No per-entity /me routes. Self-data is strictly through /me; everything else is collection access via GET /<entity> + ctx filter. See §6.7.

1. Architecture summary

Three new primitives, shared across future entities that adopt the pattern:

@AccessContext()  →  RecordAccessContext  →  <entity>ForAccessContext(ctx)
                                                Prisma.WhereInput fragment
                                               merged into service where
  • The decorator reads from request.user — no DB work.
  • The context is a narrow, immutable value object: { tenantId, userId, roles }.
  • The entity helper is a pure function: one branch per role, defensive fallback, always includes tenantId.

1.1 RecordAccessContext (interface, src/common/interfaces)

export interface RecordAccessContext {
  tenantId: string;
  userId: string;
  roles: string[];         // role keys from JWT, same shape as request.user.roles
  isPlatformAdmin: boolean; // mirrors request.user.isPlatformAdmin
}

Kept narrow on purpose: contains only what record-level filtering needs. email and bearer-token metadata stay on request.user where they belong. isPlatformAdmin is included because platform admins bypass every guard (chapter 04) — they reach the filter with arbitrary (often empty) tenant roles, and without this flag they'd fall through to the defensive __never_matches__ branch and get []. The filter short-circuits on the flag before role-branch evaluation.

1.2 @AccessContext() (param decorator, src/common/decorators)

export const AccessContext = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): RecordAccessContext => {
    const req = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
    return {
      tenantId: req.user.tenantId,
      userId: req.user.userId,
      roles: req.user.roles,
      isPlatformAdmin: req.user.isPlatformAdmin === true,
    };
  },
);

Sibling of the existing @TenantId(). Additive — existing controllers that only need tenantId stay unchanged.

1.3 studentsForAccessContext (named function, src/students/students.queries.ts)

export function studentsForAccessContext(
  ctx: RecordAccessContext,
): Prisma.StudentWhereInput {
  const base = { tenantId: ctx.tenantId };
  if (ctx.isPlatformAdmin) return base;  // platform admins bypass role-branch evaluation
  if (ctx.roles.includes('admin')) return base;
  if (ctx.roles.includes('teacher')) return base;   // TODO: narrow to teacher's classes
  if (ctx.roles.includes('referent')) return {
    ...base,
    referents: {
      some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
    },
  };
  return { id: '__never_matches__' };   // defensive: unknown role → empty set
}

Kept intentionally boring — one function, one file, one owner. The __never_matches__ branch is belt-and-suspenders in case @RequireRoles ever drifts out of sync.

1.4 Controller-side changes

@Get()
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent')  // lockstep with studentsForAccessContext
@ApiListStudents()
async findAll(
  @Query() query: AcademicYearPaginationQueryDto,
  @AccessContext() ctx: RecordAccessContext,
) {
  return this.studentsService.findAll(ctx, query.page, query.limit, query.academicYearId);
}

@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent')
@ApiGetStudent()
async findOne(
  @Param('id', ParseUUIDPipe) id: string,
  @AccessContext() ctx: RecordAccessContext,
) {
  return this.studentsService.findOne(id, ctx);
}

@TenantId() is removed from these two routes — ctx.tenantId subsumes it. All other routes (create, update, delete, import) keep @TenantId() unchanged.

1.5 Service-side changes

StudentsService.findAll and findOne are overridden (they currently inherit from BaseTenantedCrudService). New signatures take ctx instead of tenantId. Internally:

async findAll(ctx: RecordAccessContext, page?: number, limit?: number, academicYearId?: string) {
  const where = {
    ...studentsForAccessContext(ctx),
    ...(academicYearId ? { academicYearId } : {}),
  };
  // pagination + include as today
}

async findOne(id: string, ctx: RecordAccessContext) {
  const record = await this.prisma.student.findFirst({
    where: { id, ...studentsForAccessContext(ctx) },
    include: studentQueryInclude,
  });
  if (!record) throw new AppException(ErrorCode.STUDENT_NOT_FOUND, 'Student not found', HttpStatus.NOT_FOUND);
  return this.toScopedResponse(record);
}

findFirst with the merged where is what gives us 404-not-403 for free when a referent queries an unlinked student — the record simply doesn't match.

BaseTenantedCrudService is not modified in this spec.

1.6 What stays the same

  • ScopeGuard, ActionGuard, FieldWriteGuard, FieldFilterInterceptor, RolesGuard — all unchanged.
  • StudentResponseDto — unchanged. Referent callers get the same shape; FieldFilterInterceptor strips scopes they lack (referent preset has READ on anagraphic/contacts/enrollment/documents/sensitive, so most fields survive).
  • All student write paths (POST, PATCH, DELETE, POST /import) — unchanged.
  • GET /referents/me, GET /referents/:id — unchanged. Only the two */students child routes are deleted.

2. Approaches considered

Approach A: Inline per service

StudentsService.findAll(tenantId, ctx) builds where in-place: if (ctx.roles.includes('referent')) { where.referents = { some: ... } }.

  • ✅ Zero new abstractions.
  • ❌ Logic scatters across every entity that eventually needs record-level filtering. Easy to forget a path. Defense-in-depth risk.

Approach B: Named query helpers in queries.ts (SELECTED)

One function per entity returns a Prisma.WhereInput fragment. Services compose: prisma.student.findMany({ where: studentsForAccessContext(ctx), ... }).

  • ✅ Matches the existing queries.ts convention (mandatory per-module queries file, named functions, no repository classes).
  • ✅ One visible function per entity — easy to review, easy to unit-test in isolation.
  • ✅ Trivial to extend: add teachersForAccessContext, attendanceForAccessContext, etc. when those entities need it.
  • ✅ Maps 1:1 onto a future RLS policy — the WHERE fragment becomes the policy predicate.
  • ❌ Relies on convention: services must use the helper. Mitigation: the service's typed ctx parameter makes it impossible to call findAll without access context.

Approach C: Prisma $extends / middleware auto-injection

Request-scoped Prisma client rewrites queries to append the access filter.

  • ✅ Impossible to forget.
  • ❌ Magic; harder to trace. Requires AsyncLocalStorage or per-request client instances — both add moving parts the rest of the codebase doesn't have.

B selected for alignment with the existing convention and inspectability. C stays on the table as a future move if the pattern proves too easy to bypass, but we're not starting there.


3. Error semantics

Scenario Response Reasoning
Referent calls GET /students/:id for linked child 200 + filtered DTO Record matches filter. FieldFilterInterceptor strips scopes the referent lacks.
Referent calls GET /students/:id for an unlinked student 404 STUDENT_NOT_FOUND Record doesn't match the merged where. Doesn't leak existence.
Referent calls GET /students with no linked children 200 + empty page Not an error.
Role not in the role branches (hypothetical principal) 403 Blocked by @RequireRoles. Safety net: __never_matches__ returns [] if decorator drifts out of sync.
Unauthenticated 401 JWT guard, unchanged.
Cross-tenant access 404 tenantId is in every filter branch.

No new error codes. STUDENT_NOT_FOUND already exists in the ErrorCode enum (chapter 06).

Response shape contract (non-negotiable): every role receives the same StudentResponseDto. Roles differ only in (a) which records appear and (b) which scope groups the FieldFilterInterceptor preserves. Never role-branched field additions. Role-specific enrichment (e.g. referent "is primary contact" flags) belongs on a future /referents-namespaced resource, not on StudentResponseDto.


4. Deprecation mechanics

Single cutover, no transition window.

Delete: - GET /referents/me/students handler in ReferentsController - GET /referents/:id/students handler in ReferentsController - ReferentsService.findStudentsByUserId - ReferentsService.findStudentsByReferentId - studentsForReferentByUserId in src/referents/referents.queries.ts - studentsForReferentById in src/referents/referents.queries.ts - ApiGetMeStudents, ApiGetReferentStudents in src/referents/referents.swagger.ts - Corresponding tests in referents.controller.spec.ts and referents.service.spec.ts

Keep: - GET /referents/me — referent's own profile read. Flagged in pattern library as future /me consolidation work, out of scope here. - GET /referents/:id — admin/teacher lookup of a specific referent. Untouched.

Client coordination: - Web app — re-point any caller from /referents/me/students to GET /students in the same release window as backend merge. - Mobile app — ship against GET /students before backend merge.

No alias layer, no @Deprecated swagger markers, no feature flags. Codebase stays clean.


5. Decorator-filter synchronization rule

The role list in @RequireRoles(...) on GET /students and GET /students/:id and the if (ctx.roles.includes('...')) branches in studentsForAccessContext are two halves of one access policy.

Rule (documented in chapter 04 and in code comments on both sides):

When adding or removing a role from @RequireRoles on GET /students or GET /students/:id, you MUST add or remove the matching branch in studentsForAccessContext. Conversely, every role with a filter branch must appear in @RequireRoles. The two are reviewed together as a single change.

The __never_matches__ fallback exists as runtime safety but is not the primary enforcement — silent empty results are a development-time bug, not a production data risk.

Why not automate? A grep-based lint rule is doable but brittle. Social enforcement + runtime fallback is sufficient at two entities; reassess once five+ entities adopt the pattern.


6. Reusable patterns (pattern library)

The primitives introduced by this spec generalize to every future record-level filtering concern. This section documents the pattern explicitly so downstream specs (attendance, grades, invoices, …) have a recipe instead of a puzzle.

6.1 RecordAccessContext — the per-request access shape

  • What it is. Immutable value object: { tenantId, userId, roles, isPlatformAdmin }. Built once per request from request.user. No DB work.
  • Why these fields. tenantId is the mandatory outer boundary. userId is the only identity dimension filters actually need. roles drives branch selection. isPlatformAdmin is carried because platform admins bypass every guard and must short-circuit the filter — without the flag they fall through to the defensive branch and get [].
  • Why not pass the whole request.user. Narrow shape is easier to type, mock in tests, and reason about. Prevents accidental dependence on session metadata or bearer tokens.
  • Evolving the shape. If a filter ever needs an additional fact (e.g. departmentIds for a principal), add it here and populate in the decorator. Never add role-specific fields — the shape stays role-agnostic. Facts on the context should be orthogonal to role; they describe the caller, not the role's logic.

6.2 @AccessContext() — the single construction site

  • Rule. Anywhere a controller needs RecordAccessContext, use this decorator. Never reconstruct it ad-hoc from @Req().
  • Why. One construction site means one place to change if the shape grows. Ad-hoc construction is how drift starts.
  • Coexistence with @TenantId(). Both decorators coexist. Routes that only need tenantId keep using @TenantId(). No migration sweep.

6.3 <entity>ForAccessContext(ctx) — the chokepoint convention

The load-bearing pattern. Every future entity that needs record-level filtering follows this recipe.

Contract:

  1. Lives in the entity's queries.ts as a named exported function.
  2. Takes RecordAccessContext, returns a Prisma.<Entity>WhereInput (or fragment composable into one).
  3. Always includes tenantId in every returned branch. Tenant isolation is never optional.
  4. One branch per role key that may call the gated route.
  5. Ends with a defensive fallback ({ id: '__never_matches__' } or equivalent) producing an empty result set when no branch matched.
  6. Never throws. Never does DB work. Pure function from ctx to WHERE fragment.
  7. Never role-gates at the function level — that is @RequireRoles's job. This function assumes the caller is already authorized to invoke the route; its job is narrowing records, not authorizing access.

Composition in services:

const where = { ...<entity>ForAccessContext(ctx), /* caller-supplied narrowing */ };

Always spread first. Caller criteria may narrow further but must not widen past the access filter.

Recipe: adding record-level filtering to a new entity.

  1. Add <entity>ForAccessContext(ctx) to src/<entity>/<entity>.queries.ts following the contract.
  2. On read routes: add @RequireRoles(...) listing every role with a branch; add @AccessContext() ctx: RecordAccessContext to the handler signature.
  3. Override findAll and findOne in the entity service to accept ctx and merge the helper into where. Use findFirst for findOne so 404 falls out of the query layer naturally.
  4. Leave BaseTenantedCrudService alone until multiple entities are on the pattern (see 6.5).
  5. Add tests: one per role branch, plus the "no matching role" defensive case (see §7).
  6. Update chapter 04's "Record-Level Access" table to list the new entity + role-branch matrix.

6.4 Decorator–filter synchronization as a reviewable invariant

  • What it prevents. A future PR that adds a role to @RequireRoles but forgets the filter branch (new role silently sees nothing or everything) or vice versa (dead code).
  • How it's enforced. Social: both pieces live adjacent (<entity>.controller.ts and <entity>.queries.ts), both carry pointer comments, reviewer checks for drift. Runtime fallback catches the worst silent-failure mode.
  • Upgrade path. If the pattern reaches five+ entities, write a unit test that introspects route metadata vs. filter helper role keys.

6.5 Future generalization — ctx in BaseTenantedCrudService

Today only StudentsService is on the pattern. When a second entity adopts it, revisit:

  • Trigger. Two or more entities with identical findFirst({ where: { id, ...helper(ctx) } }) patterns.
  • Shape of the future refactor. BaseTenantedCrudService gains a protected getRecordAccessFilter(ctx): Prisma.WhereInput hook defaulting to { tenantId: ctx.tenantId } and overridden per entity. findAll, findOne, update, remove call it. Subclasses overriding findAll / findOne become thin or disappear.
  • Why wait. Generalizing on one data point tends to pick the wrong abstraction. The second entity often reveals nuance (soft-delete, archival state) that changes the hook signature. Cheaper to refactor once informed.

6.6 RLS migration mapping

Every filter branch is shaped so it maps directly onto a future Postgres RLS policy:

  • The WHERE fragment becomes the policy's USING clause.
  • ctx.userIdcurrent_setting('app.user_id')::uuid.
  • ctx.tenantIdcurrent_setting('app.tenant_id')::uuid.

Convention. Branches use only tenantId, userId, and plain joins. No precomputed in-memory sets, no runtime computation. Anything else wouldn't translate.

Migration plan. When RLS lands, service-layer helpers stay as primary enforcement; RLS is defense-in-depth. A global middleware sets per-request Prisma session variables; services call the helpers exactly as before. No app-code migration.

Reference. Chapter 04's "Record-Level Access" section documents the two-phase rollout. This spec is the Phase 1 implementation.

6.7 Relationship to /me — the rule

Two orthogonal mechanisms, no overlap:

  • Self-data (the caller's own record, identity, session payload) → single app-wide /me endpoint (GET /auth/me). The /me payload is ctx-driven: composes the caller's profile based on roles/user. No <entity>/me routes — those duplicate identity lookup and fragment the /me surface.
  • Collection access (records the caller can see that are NOT themselves) → GET /<entity> + <entity>ForAccessContext filter.

GET /students is purely collection access: a referent's children are not the referent's self-data, they are records the referent has visibility into. So the full pattern applies and /me is untouched.

GET /referents/me exists today but is an anti-pattern under this rule. Out of scope for this spec; flagged for a future /me consolidation.

6.8 When NOT to reach for this pattern

  • Entity-level visibility ("can this role hit this endpoint at all?") → @RequireRoles + @RequireScopes. Not the filter helper.
  • Field-level stripping ("can this role see the disabilityInfo field?") → scope model + FieldFilterInterceptor.
  • Action-level gates ("can this role delete students?") → @RequireAction.
  • Identity / session payload/auth/me. Self-data is not collection access.

The ctx-filter pattern earns its complexity when the same endpoint must return different subsets of the same collection to different roles. That is its purpose. For anything else, use the purpose-built existing mechanism.


7. Testing

Per chapter 09 conventions: unit tests with mocks, e2e through supertest and the real guard stack.

7.1 students.queries.spec.ts (new file)

Pure-function tests on studentsForAccessContext:

  • admin role → { tenantId } only
  • teacher role → { tenantId } only (document the TODO on narrowing)
  • referent role → { tenantId, referents: { some: { referent: { userId, tenantId } } } }
  • isPlatformAdmin: true with empty roles{ tenantId } (short-circuit ahead of role-branch evaluation)
  • isPlatformAdmin: true with conflicting roles (e.g. ['referent']) → { tenantId } (short-circuit wins; lock behavior under test)
  • unknown role, isPlatformAdmin: false{ id: '__never_matches__' }
  • multiple tenant roles (admin + referent) → admin branch wins (order of includes-checks is part of the contract; lock it under test)

7.2 students.service.spec.ts (updates)

  • findAll(ctx) referent with no linked children → empty array
  • findAll(ctx) referent with 2 linked children → both records, nothing else
  • findAll(ctx) referent with 1 linked child in a tenant of 100 students → only the linked child
  • findOne(id, ctx) referent on linked child → record returned
  • findOne(id, ctx) referent on unlinked same-tenant student → throws STUDENT_NOT_FOUND
  • findOne(id, ctx) referent on cross-tenant student → throws STUDENT_NOT_FOUND (tenantId boundary holds through the filter)
  • Admin behavior unchanged (regression)

7.3 students.controller.spec.ts (updates)

  • Controller forwards ctx into service correctly (mock @AccessContext() via NestJS testing utilities)
  • @RequireRoles allowlist metadata includes 'referent'
  • ctx is narrow-shaped (no leakage of isPlatformAdmin, email, or bearer metadata)

7.4 E2E (test/students.e2e-spec.ts)

Full stack through the guard chain:

  • Seed: tenant with 3 referents, 5 students, 2 linked to referent-A
  • referent-A JWT → GET /students → 2 records
  • referent-A JWT → GET /students/:id on linked → 200
  • referent-A JWT → GET /students/:id on unlinked → 404
  • referent-A JWT → POST /students → 403 (no create action)
  • admin JWT → GET /students → 5 records (regression)

7.5 Deletions

Remove the tests covering findStudentsByUserId / findStudentsByReferentId from referents.service.spec.ts and their counterparts in referents.controller.spec.ts. Keep profile-lookup tests intact.


8. Documentation updates

  • docs/04-rbac.md
  • Rewrite the paragraph at line 402 ("GET /students and GET /students/:id carry @RequireRoles('admin', 'teacher') because referents access student data through their own controller…"). Replace with the unified behavior and the allowlist-plus-filter synchronization rule.
  • Replace the "Record-Level Access" sketch (lines 594–634) with the implemented pattern: RecordAccessContext + @AccessContext() + <entity>ForAccessContext triad. Keep the two-phase (service-layer now, RLS later) framing.
  • Add a subsection "Extending record-level filtering to a new entity" lifted from §6.3's recipe.
  • docs/05-crud-patterns.md — if there is a "how to add a read endpoint" recipe, append the question "does this endpoint need record-level filtering?" with a forward pointer to chapter 04.
  • docs/11-workflows.md — new entity checklist gains "if record-level visibility differs by role, define <entity>ForAccessContext in queries.ts".
  • CLAUDE.md — no change; chapter 04 is already indexed.

No new chapter.


9. Acceptance criteria

A PR implementing this spec is complete when:

  1. GET /students accepts referent JWTs and returns only their linked children.
  2. GET /students/:id with a referent JWT returns 200 for linked children, 404 for unlinked or cross-tenant students.
  3. GET /referents/me/students and GET /referents/:id/students are removed from the codebase — including handlers, service methods, query helpers, swagger decorators, and their tests.
  4. RecordAccessContext, @AccessContext(), and studentsForAccessContext are in place and used exclusively in the two student read routes.
  5. @RequireRoles('admin', 'teacher', 'referent') is applied to both read routes with a comment pointing at studentsForAccessContext.
  6. All new and updated unit / e2e tests pass.
  7. Chapter 04 documentation reflects the implemented pattern including the pattern-library content.
  8. Admin and teacher behavior on GET /students is unchanged (no regression).
  9. Every where on a student read still includes tenantId, now inside the filter helper — verified by test.
  10. Platform admin bypass behavior is unchanged: they bypass the guard stack and hit the filter, where the isPlatformAdmin short-circuit returns { tenantId } — see §1.1 for why the flag is on RecordAccessContext.

10. Risks & mitigations

Risk Likelihood Impact Mitigation
@RequireRoles and studentsForAccessContext drift out of sync Medium Low (bug produces [], not data leak) Synchronization rule documented; __never_matches__ fallback; cross-pointer comments
A developer writes a new student read path and forgets the filter Low High (tenant-bounded data leak to wrong user) Only two read paths exist; service method signatures require ctx. Revisit via BaseTenantedCrudService generalization when a second entity joins
Clients not migrated in time Low Medium Coordinated release window; no backward-compat alias
Future role with more complex visibility (e.g. principal) doesn't fit the pattern Medium Low RecordAccessContext is extensible; worst case the helper grows extra branches
RLS migration diverges from service-layer logic Low Medium Filter helpers written in RLS-compatible form today (userId / tenantId only); translation is mechanical

References

  • docs/04-rbac.md — RBAC reference, record-level access sketch (target for update)
  • docs/01-architecture.md — service/controller conventions
  • docs/09-testing.md — test conventions
  • docs/12-migrations.md — migration safety (not triggered by this spec — no schema changes)
  • src/students/students.controller.ts — current controller
  • src/students/students.service.ts — current service
  • src/students/students.queries.ts — existing query helpers
  • src/referents/referents.controller.ts — source of deletions
  • src/referents/referents.queries.ts — source of deletions