EntityAccessPolicy Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Collapse the lockstep pair @RequireRoles(...) + <entity>ForAccessContext(ctx) into a single EntityAccessPolicy per entity. Migrate every role-aware entity (every controller currently using @RequireRoles) in one iteration. Close the @AggregateResponse() boot-time footgun. Fold assertYearWritable into runImportPipeline.
Architecture: A policy is a typed object holding { entityKey, roles, where }. roles is derived from the declared branches so the role allowlist and the role-branching logic share a single source. The new @AppliesPolicy(policy) decorator composes Roles(...policy.roles) + SetMetadata(POLICY_METADATA, policy). Services consume policy.where(ctx) directly. The existing makeRoleVisibilityResolver continues to do the runtime branching — the policy is a thin wrapper that exposes the role list back to the decorator layer.
Tech Stack: NestJS 11, Prisma 7.6, Jest 30 + ts-jest (ESM), TypeScript 5.7. Build sits on existing infra (makeRoleVisibilityResolver, NEVER_MATCH_WHERE, RecordAccessContext, @RequireRoles, RolesGuard).
Spec: docs/superpowers/specs/2026-05-26-entity-access-policy-design.md
File Structure¶
New files¶
| Path | Responsibility |
|---|---|
src/common/utils/entity-access-policy.ts |
EntityAccessPolicy<T> interface + definePolicy(...) factory. Wraps makeRoleVisibilityResolver and exposes the role list. |
src/common/utils/entity-access-policy.spec.ts |
Unit spec for definePolicy (role-list derivation, where-delegation, branch ordering). |
src/common/decorators/applies-policy.decorator.ts |
@AppliesPolicy(policy) composing Roles(...) + SetMetadata(POLICY_METADATA). |
src/common/decorators/applies-policy.decorator.spec.ts |
Unit spec for the decorator. |
src/common/constants/policy-metadata.ts |
POLICY_METADATA symbol constant. |
src/common/dto/aggregate-response-invariant.ts |
Boot-time scanner: walks discovered controllers and asserts every method returning an AggregateResponseDto subclass has @AggregateResponse() metadata. |
src/common/dto/aggregate-response-invariant.spec.ts |
Unit spec for the scanner (fixture controllers). |
src/<entity>/<entity>.policy.ts |
Per-entity policy definition. 14 new files — one per migrated entity. |
src/<entity>/<entity>.policy.spec.ts |
Per-entity policy spec covering every role branch + platform admin + unrecognised role. |
Modified files¶
| Path | Change |
|---|---|
src/common/utils/index.ts |
Re-export entity-access-policy.ts. |
src/common/decorators/index.ts |
Re-export applies-policy.decorator.ts. |
src/common/constants/index.ts |
Re-export policy-metadata.ts. |
src/common/dto/index.ts |
Re-export aggregate-response-invariant.ts. |
src/common/index.ts |
Re-export the new module surface. |
src/common/services/import-pipeline.ts |
Call assertYearWritable(tenantId, yearId) inside the orchestrator before invoking loadLookupData. Accept the resolved yearId as an explicit parameter (callers stop pre-asserting). |
src/common/services/import-pipeline.spec.ts |
New test: archived-year fails inside the pipeline with ACADEMIC_YEAR_ARCHIVED. |
src/main.ts |
Wire the aggregate-response invariant scanner at boot (after module init, before listen). |
src/<entity>/<entity>.controller.ts |
Flip @RequireRoles(...) to @AppliesPolicy(<Entity>Policy) on every route. 14 controllers. |
src/<entity>/<entity>.service.ts |
Replace <entity>ForAccessContext(ctx) calls with <Entity>Policy.where(ctx). 8 services with role-branching helpers, plus the 2 already on makeRoleVisibilityResolver which just rename the import. |
src/<entity>/<entity>.queries.ts (or .access-context.ts) |
Delete the old <entity>ForAccessContext export. 10 helper files. |
src/<entity>/<entity>.queries.spec.ts (or .access-context.spec.ts) |
Delete the old role-branch tests (now in <entity>.policy.spec.ts). |
src/<entity>/<entity>.controller.spec.ts |
Update the decorator-metadata assertions from @RequireRoles(...) to @AppliesPolicy(<Entity>Policy). |
src/students/students.service.ts, src/teachers/teachers.service.ts, src/staff/staff.service.ts |
Remove the pre-flight assertYearWritable call before runImportPipeline(...). |
docs/04-rbac.md |
Replace <entity>ForAccessContext references with <Entity>Policy.where(ctx). Update the "Decorator Decision Table" to show @AppliesPolicy instead of @RequireRoles. |
docs/11-workflows.md |
Add §13 "Add a new role-aware entity (define a policy)" recipe. Update §3 step 11 and §9 to reference the policy. |
docs/14-homerooms-subject-groups.md |
Update the access-context snippet to show the policy. |
docs/REFERENCE.md |
Add a row for the policy file in §6 file index. |
prisma/seed/roles.ts |
No change. (Confirm.) |
Migration entities (Phase 2 order)¶
- Students (canonical proof, Task 5)
- Homerooms (already on
makeRoleVisibilityResolver, lowest risk, Task 6) - SubjectGroups (same, Task 7)
- Departments (Task 8)
- Grades (Task 9)
- Curricula (Task 10)
- StudyPlans (Task 11)
- SelectionWindows (Task 12)
- Teachers (Task 13)
- Staff (Task 14)
- Referents (Task 15)
- AcademicYears (Task 16)
- School (Task 17)
- Command-Center: onboarding + study-plan-selections + completeness (Task 18)
- Students/curriculum-selection sub-resource (Task 19)
Phase 1 — Build the abstraction¶
Task 1: Define EntityAccessPolicy<T> and definePolicy¶
Files:
- Create: src/common/utils/entity-access-policy.ts
- Create: src/common/utils/entity-access-policy.spec.ts
- Modify: src/common/utils/index.ts
- Step 1: Write the failing test
// src/common/utils/entity-access-policy.spec.ts
/* eslint-disable */
import type { Prisma } from '../../generated/prisma/client';
import { EntityKey } from '../constants';
import { definePolicy, NEVER_MATCH_WHERE } from './index';
import type { RecordAccessContext } from '../interfaces';
const ctx = (overrides: Partial<RecordAccessContext> = {}): RecordAccessContext => ({
tenantId: 'tenant-1',
userId: 'user-1',
roles: [],
isPlatformAdmin: false,
departmentIds: [],
...overrides,
});
describe('definePolicy', () => {
it('exposes entityKey verbatim', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [{ role: 'admin', build: 'pass-through' }],
});
expect(policy.entityKey).toBe(EntityKey.STUDENTS);
});
it('derives roles from branches in declaration order', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{ role: 'department_head', build: 'pass-through' },
{ role: 'teacher', build: 'pass-through' },
],
});
expect(policy.roles).toEqual(['admin', 'department_head', 'teacher']);
});
it('platform admin returns base regardless of roles', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [{ role: 'admin', build: 'pass-through' }],
});
expect(policy.where(ctx({ isPlatformAdmin: true }))).toEqual({ tenantId: 'tenant-1' });
});
it('unrecognised role returns NEVER_MATCH_WHERE', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [{ role: 'admin', build: 'pass-through' }],
});
expect(policy.where(ctx({ roles: ['ghost'] }))).toBe(NEVER_MATCH_WHERE);
});
it('builder branch receives ctx + base', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [
{
role: 'department_head',
build: (c, base) => {
if (c.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, departmentId: { in: c.departmentIds } };
},
},
],
});
expect(policy.where(ctx({ roles: ['department_head'], departmentIds: ['d1'] }))).toEqual({
tenantId: 'tenant-1',
departmentId: { in: ['d1'] },
});
expect(policy.where(ctx({ roles: ['department_head'], departmentIds: [] }))).toBe(
NEVER_MATCH_WHERE,
);
});
it('honours branch declaration order on the first match', () => {
const policy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (c) => ({ tenantId: c.tenantId }),
branches: [
{
role: 'department_head',
build: (c, base) => ({ ...base, departmentId: { in: c.departmentIds } }),
},
{ role: 'staff', build: 'pass-through' },
],
});
// dept_head + staff in same ctx → dept_head wins because declared first
expect(
policy.where(ctx({ roles: ['staff', 'department_head'], departmentIds: ['d1'] })),
).toEqual({ tenantId: 'tenant-1', departmentId: { in: ['d1'] } });
});
});
- Step 2: Run test to verify it fails
Run: npx jest src/common/utils/entity-access-policy.spec.ts
Expected: FAIL with Cannot find module './index' resolving definePolicy (or "definePolicy is not a function").
- Step 3: Write the implementation
// src/common/utils/entity-access-policy.ts
import type { EntityKey } from '../constants';
import {
NEVER_MATCH_WHERE,
makeRoleVisibilityResolver,
type RoleVisibilityBranch,
} from './access-context-helpers';
import type { RecordAccessContext } from '../interfaces';
/**
* The unified per-entity record-access policy. Bundles the role allowlist
* (consumed by `@AppliesPolicy` for the route gate) and the role-branching
* Prisma WHERE builder (consumed by the service) so the two cannot drift.
*
* The `where` function returns either a typed `Prisma.<Entity>WhereInput`
* (when a branch matches) or `NEVER_MATCH_WHERE` (when no branch matches).
* Services AND this output into their `where` clause; the AND with
* `NEVER_MATCH_WHERE` collapses the query to zero rows — fail closed.
*/
export interface EntityAccessPolicy<TWhere> {
readonly entityKey: EntityKey;
/** Role keys this entity admits, in branch-declaration order. Source of truth for `@AppliesPolicy`. */
readonly roles: readonly string[];
readonly where: (ctx: RecordAccessContext) => TWhere | typeof NEVER_MATCH_WHERE;
}
export function definePolicy<TWhere>(args: {
entityKey: EntityKey;
buildBase: (ctx: RecordAccessContext) => TWhere;
branches: ReadonlyArray<{
role: string;
build: RoleVisibilityBranch<TWhere>;
}>;
}): EntityAccessPolicy<TWhere> {
const where = makeRoleVisibilityResolver(args);
const roles = Object.freeze(args.branches.map((b) => b.role));
return Object.freeze({
entityKey: args.entityKey,
roles,
where,
});
}
- Step 4: Wire the barrel export
Edit src/common/utils/index.ts. Append:
- Step 5: Run test to verify it passes
Run: npx jest src/common/utils/entity-access-policy.spec.ts
Expected: PASS, 6 tests.
- Step 6: Commit
git add src/common/utils/entity-access-policy.ts src/common/utils/entity-access-policy.spec.ts src/common/utils/index.ts
git commit -m "feat(common): add EntityAccessPolicy + definePolicy
Wraps makeRoleVisibilityResolver and exposes the branch role list so
the route-level role gate and the record-level WHERE share one source.
First half of the @RequireRoles ↔ <entity>ForAccessContext refactor;
the decorator binding lands in the next commit.
Spec: docs/superpowers/specs/2026-05-26-entity-access-policy-design.md"
Task 2: Implement @AppliesPolicy decorator¶
Files:
- Create: src/common/constants/policy-metadata.ts
- Create: src/common/decorators/applies-policy.decorator.ts
- Create: src/common/decorators/applies-policy.decorator.spec.ts
- Modify: src/common/constants/index.ts
- Modify: src/common/decorators/index.ts
- Step 1: Create the metadata key
// src/common/constants/policy-metadata.ts
/**
* Reflect-metadata key for the EntityAccessPolicy attached to a route by
* @AppliesPolicy. Read by the boot-time policy-drift check; future tooling
* (telemetry, audit) can also consume it.
*/
export const POLICY_METADATA = Symbol('policyMetadata');
Append to src/common/constants/index.ts:
- Step 2: Write the failing decorator test
// src/common/decorators/applies-policy.decorator.spec.ts
/* eslint-disable */
import 'reflect-metadata';
import { ROLES_METADATA } from '../../permissions/decorators/require-roles.decorator';
import { POLICY_METADATA } from '../constants';
import type { EntityAccessPolicy } from '../utils';
import { AppliesPolicy } from './applies-policy.decorator';
const fakePolicy = {
entityKey: 'students',
roles: ['admin', 'teacher'],
where: () => ({ tenantId: 'x' }),
} as unknown as EntityAccessPolicy<unknown>;
describe('@AppliesPolicy', () => {
it('attaches POLICY_METADATA on the method', () => {
class Ctrl {
@AppliesPolicy(fakePolicy)
method() {}
}
const policy = Reflect.getMetadata(POLICY_METADATA, Ctrl.prototype, 'method');
expect(policy).toBe(fakePolicy);
});
it('attaches ROLES_METADATA derived from policy.roles', () => {
class Ctrl {
@AppliesPolicy(fakePolicy)
method() {}
}
const roles = Reflect.getMetadata(ROLES_METADATA, Ctrl.prototype, 'method');
expect(roles).toEqual(['admin', 'teacher']);
});
it('attaches both metadata at class level when applied to a class', () => {
@AppliesPolicy(fakePolicy)
class Ctrl {}
expect(Reflect.getMetadata(POLICY_METADATA, Ctrl)).toBe(fakePolicy);
expect(Reflect.getMetadata(ROLES_METADATA, Ctrl)).toEqual(['admin', 'teacher']);
});
});
- Step 3: Run test to verify it fails
Run: npx jest src/common/decorators/applies-policy.decorator.spec.ts
Expected: FAIL — module not found.
- Step 4: Read
RequireRolesdecorator to confirm the metadata key name
Run: open src/permissions/decorators/require-roles.decorator.ts. Confirm the exported constant is ROLES_METADATA (or whichever name RolesGuard reads). Adjust the spec import if the actual name differs.
- Step 5: Implement the decorator
// src/common/decorators/applies-policy.decorator.ts
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { RequireRoles } from '../../permissions/decorators/require-roles.decorator';
import { POLICY_METADATA } from '../constants';
import type { EntityAccessPolicy } from '../utils';
/**
* Apply an EntityAccessPolicy to a controller method (or class). Composes:
* - @RequireRoles(...policy.roles) — the existing RolesGuard keeps working
* unchanged. policy.roles is the SOLE source of the role allowlist.
* - SetMetadata(POLICY_METADATA, policy) — the boot-time drift check and
* future tooling read this; the route-level guard chain does not.
*
* Replaces hand-rolled @RequireRoles(...) on routes that have a matching
* <Entity>Policy. Eliminates the drift between the controller's role list
* and the role branches in <entity>ForAccessContext.
*/
export function AppliesPolicy(
policy: EntityAccessPolicy<unknown>,
): ClassDecorator & MethodDecorator {
return applyDecorators(
SetMetadata(POLICY_METADATA, policy),
RequireRoles(...policy.roles),
) as ClassDecorator & MethodDecorator;
}
Append to src/common/decorators/index.ts:
- Step 6: Run decorator spec to verify it passes
Run: npx jest src/common/decorators/applies-policy.decorator.spec.ts
Expected: PASS, 3 tests.
- Step 7: Commit
git add src/common/constants/policy-metadata.ts src/common/constants/index.ts \
src/common/decorators/applies-policy.decorator.ts \
src/common/decorators/applies-policy.decorator.spec.ts \
src/common/decorators/index.ts
git commit -m "feat(common): add @AppliesPolicy decorator
Composes SetMetadata(POLICY_METADATA) + RequireRoles(...policy.roles).
RolesGuard keeps reading ROLES_METADATA — no guard changes. POLICY_METADATA
is for boot-time drift checks and future tooling."
Task 3: Boot-time aggregate-response invariant check¶
Files:
- Create: src/common/dto/aggregate-response-invariant.ts
- Create: src/common/dto/aggregate-response-invariant.spec.ts
- Modify: src/common/dto/index.ts
- Modify: src/main.ts
- Step 1: Read the existing aggregate-response setup
Run: open src/common/dto/aggregate-response.dto.ts and src/common/decorators/aggregate-response.decorator.ts. Confirm:
- The marker class is AggregateResponseDto.
- The decorator key (AGGREGATE_RESPONSE_METADATA or similar) read by FieldFilterInterceptor.
- Step 2: Write the failing scanner test
// src/common/dto/aggregate-response-invariant.spec.ts
/* eslint-disable */
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { Controller, Get } from '@nestjs/common';
import { AggregateResponseDto } from './aggregate-response.dto';
import { AggregateResponse } from '../decorators/aggregate-response.decorator';
import { assertAggregateResponseInvariant } from './aggregate-response-invariant';
class ConformantDto extends AggregateResponseDto {}
class ViolatingDto extends AggregateResponseDto {}
@Controller('conformant')
class ConformantCtrl {
@Get()
@AggregateResponse()
list(): ConformantDto { return new ConformantDto(); }
}
@Controller('violating')
class ViolatingCtrl {
@Get()
list(): ViolatingDto { return new ViolatingDto(); }
}
describe('assertAggregateResponseInvariant', () => {
it('passes when every aggregate-returning route carries @AggregateResponse()', async () => {
const mod = await Test.createTestingModule({ controllers: [ConformantCtrl] }).compile();
expect(() => assertAggregateResponseInvariant(mod)).not.toThrow();
});
it('throws when an aggregate-returning route is missing @AggregateResponse()', async () => {
const mod = await Test.createTestingModule({ controllers: [ViolatingCtrl] }).compile();
expect(() => assertAggregateResponseInvariant(mod)).toThrow(
/ViolatingCtrl\.list.+missing.+@AggregateResponse/,
);
});
});
- Step 3: Run test to verify it fails
Run: npx jest src/common/dto/aggregate-response-invariant.spec.ts
Expected: FAIL — module not found.
- Step 4: Implement the scanner
// src/common/dto/aggregate-response-invariant.ts
import 'reflect-metadata';
import type { INestApplicationContext } from '@nestjs/common';
import { MetadataScanner } from '@nestjs/core';
import { AggregateResponseDto } from './aggregate-response.dto';
// Import the actual key exported by aggregate-response.decorator.ts — replace
// `AGGREGATE_RESPONSE_METADATA` if Step 1 surfaced a different name.
import { AGGREGATE_RESPONSE_METADATA } from '../decorators/aggregate-response.decorator';
/**
* Walks every discovered controller in the module tree and asserts: any
* method whose declared return type extends AggregateResponseDto must carry
* the @AggregateResponse() marker.
*
* Failure mode is environment-aware:
* - NODE_ENV !== 'production': throws at app bootstrap (CI catches it).
* - NODE_ENV === 'production': logs at error level, leaves boot to proceed,
* and lets FieldFilterInterceptor's runtime safety-net catch a real hit.
*
* Why both: dev/CI is the right place to fail; production prefers a logged
* incident over a self-inflicted outage if a previously-conformant DTO is
* later edited in a way the scanner mis-classifies (e.g. a class extending
* AggregateResponseDto via an inheritance chain the scanner can't fully see).
*/
export function assertAggregateResponseInvariant(
app: INestApplicationContext,
options: { strict?: boolean } = {},
): void {
const strict = options.strict ?? process.env.NODE_ENV !== 'production';
const scanner = new MetadataScanner();
const violations: string[] = [];
// Pull every controller instance from the container.
// NestJS exposes them via `app.get(...)` per provider token; use the
// `Reflect.getMetadata('controllers', ...)` walk over discovered modules.
// Implementation detail — verify shape against actual NestJS 11 APIs in
// `src/main.ts` bootstrap before relying on it.
const controllers = collectControllers(app);
for (const controller of controllers) {
const proto = Object.getPrototypeOf(controller);
scanner.scanFromPrototype(controller, proto, (methodName) => {
const returnType: unknown = Reflect.getMetadata('design:returntype', proto, methodName);
if (
typeof returnType === 'function' &&
(returnType === AggregateResponseDto ||
AggregateResponseDto.isPrototypeOf(returnType as Function))
) {
const hasMarker = Reflect.getMetadata(AGGREGATE_RESPONSE_METADATA, proto[methodName]) === true;
if (!hasMarker) {
violations.push(`${controller.constructor.name}.${methodName}`);
}
}
});
}
if (violations.length === 0) return;
const message =
`[AggregateResponseInvariant] ${violations.length} route(s) return ` +
`AggregateResponseDto without @AggregateResponse(): ` +
violations.map((v) => ` - ${v} (missing @AggregateResponse)`).join('\n');
if (strict) throw new Error(message);
// eslint-disable-next-line no-console
console.error(message);
}
function collectControllers(app: INestApplicationContext): object[] {
// Concrete implementation depends on NestJS internals. Iterate
// `app['container']?.getModules()` and gather `.controllers` instances.
// Document the exact API call here once verified in Step 5.
// Returning [] for the spec fixture path; the real implementation
// uses the testing module's container.
return [];
}
Note on
collectControllers: the test fixture instantiates aTestingModuledirectly. The real production wiring traversesapp.get(ApplicationContext).controllers(or equivalent NestJS 11 API). Verify againstsrc/main.tsandsrc/app.module.spec.tswhen wiring at boot — Step 6 below.
- Step 5: Implement
collectControllersfor the test fixture path
For the spec, walk the module's controllers array via the testing module's container. The simplest robust approach is to take the controllers array as an injected dependency rather than discovering it via reflection — adjust the signature:
// Final shape used by main.ts and the spec
export function assertAggregateResponseInvariant(
controllers: object[],
options: { strict?: boolean } = {},
): void { /* same body, replace collectControllers call with the param */ }
Update the spec fixtures to pass [new ConformantCtrl()] / [new ViolatingCtrl()] directly.
- Step 6: Run scanner spec to verify it passes
Run: npx jest src/common/dto/aggregate-response-invariant.spec.ts
Expected: PASS, 2 tests.
- Step 7: Wire into
src/main.ts
// src/main.ts — inside bootstrap(), AFTER `app = await NestFactory.create(...)`
// BEFORE `await app.listen(...)`
import { assertAggregateResponseInvariant } from './common/dto/aggregate-response-invariant';
import { DiscoveryService } from '@nestjs/core';
const discovery = app.get(DiscoveryService);
const controllers = discovery.getControllers().map((wrapper) => wrapper.instance).filter(Boolean);
assertAggregateResponseInvariant(controllers);
- Step 8: Append the barrel export
Edit src/common/dto/index.ts:
- Step 9: Smoke-test the boot path
Run: npm run start:dev
Expected: app starts cleanly. No invariant violations logged. Stop the process with Ctrl+C.
- Step 10: Commit
git add src/common/dto/aggregate-response-invariant.ts \
src/common/dto/aggregate-response-invariant.spec.ts \
src/common/dto/index.ts \
src/main.ts
git commit -m "feat(common): add boot-time @AggregateResponse() invariant check
Walks every controller method whose declared return type extends
AggregateResponseDto and asserts @AggregateResponse() is present.
Hard fail in dev/test/CI; error log in prod. Closes the 'forgot the
decorator → silent {} response' footgun called out in 16-maintainability §5.5."
Task 4: Fold assertYearWritable into runImportPipeline¶
Files:
- Modify: src/common/services/import-pipeline.ts
- Modify: src/common/services/import-pipeline.spec.ts
- Modify: src/students/students.service.ts
- Modify: src/teachers/teachers.service.ts
- Modify: src/staff/staff.service.ts
- Step 1: Read existing
runImportPipelinesignature + caller sites
Run: open src/common/services/import-pipeline.ts. Confirm the orchestrator signature.
Run: grep -rn "runImportPipeline" src/students src/teachers src/staff — confirm the three caller sites and that each calls assertYearWritable immediately before.
- Step 2: Write the failing test (archived-year through the pipeline)
Append to src/common/services/import-pipeline.spec.ts:
describe('runImportPipeline year-writability guard', () => {
it('throws ACADEMIC_YEAR_ARCHIVED before invoking loadLookupData', async () => {
const prisma = {
academicYear: {
findFirst: jest.fn().mockResolvedValue({
id: 'year-archived',
status: 'ARCHIVED',
tenantId: 'tenant-1',
}),
},
} as any;
const loadLookupData = jest.fn();
const validateRows = jest.fn();
const checkDuplicates = jest.fn();
const createRecords = jest.fn();
await expect(
runImportPipeline(
Buffer.from('first_name,last_name\nJane,Doe\n'),
'text/csv',
'tenant-1',
{ allColumns: ['first_name', 'last_name'], requiredColumns: ['first_name', 'last_name'] },
{ loadLookupData, validateRows, checkDuplicates, createRecords },
{ prisma, academicYearId: 'year-archived' },
),
).rejects.toMatchObject({ code: 'ACADEMIC_YEAR_ARCHIVED' });
expect(loadLookupData).not.toHaveBeenCalled();
});
});
- Step 3: Run test to verify it fails
Run: npx jest src/common/services/import-pipeline.spec.ts -t "year-writability"
Expected: FAIL — runImportPipeline does not yet accept prisma + academicYearId, or doesn't call assertYearWritable.
- Step 4: Update
runImportPipelineto accept{ prisma, academicYearId }and callassertYearWritablebefore the callbacks
// src/common/services/import-pipeline.ts
// Add to the function signature: a new last parameter
// options: { prisma: PrismaService; academicYearId: string }
// At the top of the body — BEFORE loadLookupData is invoked:
await assertYearWritable(options.prisma, tenantId, options.academicYearId);
Import assertYearWritable from src/common/utils/assert-year-writable.ts (or wherever the existing helper lives — confirm in Step 1).
- Step 5: Run pipeline tests to verify they pass
Run: npx jest src/common/services/import-pipeline.spec.ts
Expected: ALL PASS (existing tests + the new archived-year test).
- Step 6: Remove pre-flight
assertYearWritablefrom the three caller services
In each of src/students/students.service.ts, src/teachers/teachers.service.ts, src/staff/staff.service.ts:
- Find the
importFromFile()method. - Delete the
await this.assertYearWritable(tenantId, yearId)line that precedesrunImportPipeline(...). -
Pass
{ prisma: this.prisma, academicYearId: yearId }as the new options arg torunImportPipeline. -
Step 7: Run service specs to confirm parity
Run: npx jest src/students/students.service.spec.ts src/teachers/teachers.service.spec.ts src/staff/staff.service.spec.ts
Expected: ALL PASS.
- Step 8: Update chapter 07 doc
Edit docs/07-import-pipeline.md §2 ("Using runImportPipeline"). Replace the snippet:
Before:
After:
Also delete the "must call it explicitly" sentence and the corresponding warning earlier in the chapter.
- Step 9: Commit
git add src/common/services/import-pipeline.ts src/common/services/import-pipeline.spec.ts \
src/students/students.service.ts src/teachers/teachers.service.ts src/staff/staff.service.ts \
docs/07-import-pipeline.md
git commit -m "refactor(import-pipeline): fold assertYearWritable into the orchestrator
The pipeline now resolves year-writability before invoking the four
callbacks. Caller services stop owning the gate. Symmetric with how
BaseTenantedCrudService.create/update/remove auto-call it. Closes one
of the foot-guns called out in the 2026-05-26 access-policy spec."
Phase 2 — Migrate entities¶
Task 5: Migrate Students (canonical recipe)¶
This task is the proof. Every subsequent migration follows this shape; later tasks list only the per-entity deltas.
Files:
- Create: src/students/students.policy.ts
- Create: src/students/students.policy.spec.ts
- Modify: src/students/students.queries.ts (delete studentsForAccessContext)
- Modify: src/students/students.queries.spec.ts (delete studentsForAccessContext describe block)
- Modify: src/students/students.service.ts (replace studentsForAccessContext(ctx) → StudentsPolicy.where(ctx))
- Modify: src/students/students.controller.ts (flip every @RequireRoles(...) to @AppliesPolicy(StudentsPolicy))
- Modify: src/students/students.controller.spec.ts (update decorator-metadata assertions)
- Modify: src/students/index.ts (export StudentsPolicy)
- Modify: src/command-center/completeness.queries.ts (re-import the policy instead of the helper)
- Step 1: Write the failing policy spec
// src/students/students.policy.spec.ts
/* eslint-disable */
import { EntityKey } from '../common/constants';
import { NEVER_MATCH_WHERE } from '../common';
import { StudentsPolicy } from './students.policy';
import type { RecordAccessContext } from '../common/interfaces';
const ctx = (overrides: Partial<RecordAccessContext> = {}): RecordAccessContext => ({
tenantId: 'tenant-1',
userId: 'user-1',
roles: [],
isPlatformAdmin: false,
departmentIds: [],
...overrides,
});
describe('StudentsPolicy', () => {
it('declares EntityKey.STUDENTS', () => {
expect(StudentsPolicy.entityKey).toBe(EntityKey.STUDENTS);
});
it('roles cover admin, department_head, hr, principal, staff, teacher, referent in declaration order', () => {
expect(StudentsPolicy.roles).toEqual([
'admin',
'department_head',
'hr',
'principal',
'staff',
'teacher',
'referent',
]);
});
it('platform admin → tenant base', () => {
expect(StudentsPolicy.where(ctx({ isPlatformAdmin: true }))).toEqual({ tenantId: 'tenant-1' });
});
it('admin → tenant base', () => {
expect(StudentsPolicy.where(ctx({ roles: ['admin'] }))).toEqual({ tenantId: 'tenant-1' });
});
it('department_head with no departmentIds → NEVER_MATCH_WHERE', () => {
expect(StudentsPolicy.where(ctx({ roles: ['department_head'], departmentIds: [] }))).toBe(
NEVER_MATCH_WHERE,
);
});
it('department_head with departmentIds → narrowed by departmentId IN', () => {
expect(
StudentsPolicy.where(ctx({ roles: ['department_head'], departmentIds: ['d1', 'd2'] })),
).toEqual({ tenantId: 'tenant-1', departmentId: { in: ['d1', 'd2'] } });
});
it('department_head wins when caller also carries staff (declaration order)', () => {
expect(
StudentsPolicy.where(
ctx({ roles: ['staff', 'department_head'], departmentIds: ['d1'] }),
),
).toEqual({ tenantId: 'tenant-1', departmentId: { in: ['d1'] } });
});
it('hr → tenant base', () => {
expect(StudentsPolicy.where(ctx({ roles: ['hr'] }))).toEqual({ tenantId: 'tenant-1' });
});
it('principal → tenant base', () => {
expect(StudentsPolicy.where(ctx({ roles: ['principal'] }))).toEqual({ tenantId: 'tenant-1' });
});
it('staff → tenant base', () => {
expect(StudentsPolicy.where(ctx({ roles: ['staff'] }))).toEqual({ tenantId: 'tenant-1' });
});
it('teacher → tenant base (TODO: narrow when class models land)', () => {
expect(StudentsPolicy.where(ctx({ roles: ['teacher'] }))).toEqual({ tenantId: 'tenant-1' });
});
it('referent → narrowed via referent.userId path', () => {
expect(
StudentsPolicy.where(ctx({ roles: ['referent'], userId: 'u-ref-1' })),
).toEqual({
tenantId: 'tenant-1',
referents: {
some: { referent: { userId: 'u-ref-1', tenantId: 'tenant-1' } },
},
});
});
it('unrecognised role → NEVER_MATCH_WHERE', () => {
expect(StudentsPolicy.where(ctx({ roles: ['ghost'] }))).toBe(NEVER_MATCH_WHERE);
});
});
- Step 2: Run policy spec to verify it fails
Run: npx jest src/students/students.policy.spec.ts
Expected: FAIL — module not found.
- Step 3: Implement
StudentsPolicy
// src/students/students.policy.ts
import type { Prisma } from '../generated/prisma/client';
import { EntityKey } from '../common/constants';
import { definePolicy, NEVER_MATCH_WHERE } from '../common';
/**
* Record-level visibility policy for the `students` entity.
*
* Replaces the previous `studentsForAccessContext` helper + the lockstep
* `@RequireRoles(...)` list on every Students controller route. The role
* allowlist is the SAME tuple as the branches below — `@AppliesPolicy`
* pulls it from `StudentsPolicy.roles`, so adding/removing a role is one
* edit here, not three across the controller, the helper, and the tests.
*
* Branch order matters — `department_head` MUST precede `staff`/`teacher`
* because every dept_head's JWT carries the synthetic `staff` profile role.
* Letting `staff` match first would silently broaden dept_head visibility to
* the whole tenant.
*/
export const StudentsPolicy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{
role: 'department_head',
build: (ctx, base) => {
if (ctx.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, departmentId: { in: ctx.departmentIds } };
},
},
{ role: 'hr', build: 'pass-through' },
{ role: 'principal', build: 'pass-through' },
{ role: 'staff', build: 'pass-through' },
// TODO: narrow to the teacher's class/department assignments when class
// models land. Current behavior matches the pre-unification state —
// teachers see all tenant students.
{ role: 'teacher', build: 'pass-through' },
{
role: 'referent',
build: (ctx, base) => ({
...base,
referents: {
some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
}),
},
],
});
- Step 4: Run policy spec to verify it passes
Run: npx jest src/students/students.policy.spec.ts
Expected: PASS, 12 tests.
- Step 5: Update barrel exports
Edit src/students/index.ts. Add:
- Step 6: Replace
studentsForAccessContext(ctx)withStudentsPolicy.where(ctx)in the service
In src/students/students.service.ts:
- Delete the studentsForAccessContext import line.
- Add import { StudentsPolicy } from './students.policy';.
- Replace every studentsForAccessContext(ctx) call with StudentsPolicy.where(ctx).
In src/command-center/completeness.queries.ts:
- Delete the import { studentsForAccessContext } from '../students'; line.
- Add import { StudentsPolicy } from '../students';.
- Replace studentsForAccessContext(ctx) with StudentsPolicy.where(ctx).
- Step 7: Flip the controller decorators
In src/students/students.controller.ts, replace every occurrence of:
(and the variant @RequireRoles('admin') on the import route) with the policy variant. For the import route (admin-only), keep @RequireRoles('admin') — the import policy intentionally has a tighter role list than the read/write CRUD policy. Do not force the import route through StudentsPolicy.
For the CRUD routes:
import { AppliesPolicy } from '../common/decorators';
import { StudentsPolicy } from './students.policy';
// Before:
// @RequireRoles('admin', 'teacher', 'referent', 'department_head')
// After:
@AppliesPolicy(StudentsPolicy)
Remove the @RequireRoles import if no longer used.
- Step 8: Update the controller decorator-metadata spec
In src/students/students.controller.spec.ts, replace assertions like:
expect(Reflect.getMetadata(ROLES_METADATA, StudentsController.prototype.findOne)).toEqual([
'admin', 'teacher', 'referent', 'department_head',
]);
with:
import { POLICY_METADATA } from '../common/constants';
import { StudentsPolicy } from './students.policy';
// ...
expect(Reflect.getMetadata(POLICY_METADATA, StudentsController.prototype.findOne)).toBe(StudentsPolicy);
expect(Reflect.getMetadata(ROLES_METADATA, StudentsController.prototype.findOne)).toEqual([
...StudentsPolicy.roles,
]);
- Step 9: Delete the old
studentsForAccessContexthelper + its tests
In src/students/students.queries.ts:
- Delete the export function studentsForAccessContext(...) block.
- Delete NEVER_MATCH_WHERE import if no other code in the file uses it.
In src/students/students.queries.spec.ts:
- Delete the entire describe('studentsForAccessContext', ...) block (the cases are now in students.policy.spec.ts).
- Step 10: Run the full Students test suite
Run: npx jest src/students src/command-center/completeness.queries.spec.ts
Expected: ALL PASS (policy spec + service spec + controller spec + queries spec + completeness queries spec).
- Step 11: Run the Students E2E suite
Run: npm run test:e2e -- --testPathPatterns=students
Expected: ALL PASS — behavioural parity preserved across every role.
- Step 12: Commit
git add src/students/students.policy.ts src/students/students.policy.spec.ts \
src/students/students.queries.ts src/students/students.queries.spec.ts \
src/students/students.service.ts src/students/students.controller.ts \
src/students/students.controller.spec.ts src/students/index.ts \
src/command-center/completeness.queries.ts
git commit -m "refactor(students): migrate to EntityAccessPolicy
Replaces studentsForAccessContext + the parallel @RequireRoles allowlist
on every CRUD route with a single StudentsPolicy referenced via
@AppliesPolicy. The import route stays @RequireRoles('admin') —
intentionally tighter than CRUD.
Spec: docs/superpowers/specs/2026-05-26-entity-access-policy-design.md
Plan: docs/superpowers/plans/2026-05-26-entity-access-policy.md (Task 5)"
Task 6: Migrate Homerooms¶
Already on makeRoleVisibilityResolver. Replace the existing homeroomsForAccessContext export with HomeroomsPolicy (wraps the same branches in definePolicy).
Files:
- Create: src/homerooms/homerooms.policy.ts
- Create: src/homerooms/homerooms.policy.spec.ts
- Delete: src/homerooms/homerooms.access-context.ts
- Delete: src/homerooms/homerooms.access-context.spec.ts
- Modify: src/homerooms/homerooms.service.ts — homeroomsForAccessContext → HomeroomsPolicy.where
- Modify: src/homerooms/homerooms.controller.ts — @RequireRoles(...) → @AppliesPolicy(HomeroomsPolicy) on every CRUD route; the eligible-students picker uses its own narrower policy if applicable (audit per route)
- Modify: src/homerooms/homerooms.controller.spec.ts
- Modify: src/homerooms/index.ts
- Step 1: Write the policy spec
Mirror the structure of students.policy.spec.ts. Branches to cover: platform admin, admin, department_head (empty + populated), hr, principal, teacher, staff, referent (via assignment roster join), student (via assignment roster), unrecognised role. Reuse the role list from the existing homerooms.access-context.ts.
-
Step 2: Run to verify FAIL —
npx jest src/homerooms/homerooms.policy.spec.ts. -
Step 3: Implement
src/homerooms/homerooms.policy.ts
import type { Prisma } from '../generated/prisma/client';
import { EntityKey } from '../common/constants';
import { definePolicy, NEVER_MATCH_WHERE } from '../common';
export const HomeroomsPolicy = definePolicy<Prisma.HomeroomWhereInput>({
entityKey: EntityKey.HOMEROOMS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{
// Must precede `staff`/`teacher` — every dept_head's JWT carries the
// synthetic `staff` profile role.
role: 'department_head',
build: (ctx, base) => {
if (ctx.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, departmentId: { in: ctx.departmentIds } };
},
},
{ role: 'hr', build: 'pass-through' },
{ role: 'principal', build: 'pass-through' },
{ role: 'teacher', build: 'pass-through' },
{ role: 'staff', build: 'pass-through' },
{
role: 'referent',
build: (ctx, base) => ({
...base,
assignments: {
some: {
student: {
referents: {
some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
},
},
},
}),
},
{
role: 'student',
build: (ctx, base) => ({
...base,
assignments: {
some: { student: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
}),
},
],
});
-
Step 4: Run policy spec — verify PASS.
-
Step 5: Service swap —
homeroomsForAccessContext(ctx)→HomeroomsPolicy.where(ctx)inhomerooms.service.ts. -
Step 6: Controller decorator flip —
@RequireRoles(...)→@AppliesPolicy(HomeroomsPolicy)on every CRUD route. Eligible-students picker is admin/hr/dept_head only — gate it with@RequireRoles('admin', 'hr', 'department_head')for now (or define a tighterHomeroomsPickerPolicyif the role list is stable; defer to follow-up). -
Step 7: Update barrel — add
export { HomeroomsPolicy } from './homerooms.policy';tosrc/homerooms/index.ts. -
Step 8: Delete
homerooms.access-context.tsand its spec. -
Step 9: Update controller spec assertions —
ROLES_METADATA→POLICY_METADATA. -
Step 10: Run Homerooms test suite —
npx jest src/homerooms. Expected: PASS. -
Step 11: Run Homerooms E2E —
npm run test:e2e -- --testPathPatterns=homerooms. Expected: PASS. -
Step 12: Commit
git add src/homerooms
git rm src/homerooms/homerooms.access-context.ts src/homerooms/homerooms.access-context.spec.ts
git commit -m "refactor(homerooms): migrate to EntityAccessPolicy"
Task 7: Migrate SubjectGroups¶
Identical shape to Homerooms (already on makeRoleVisibilityResolver).
Files:
- Create: src/subject-groups/subject-groups.policy.ts
- Create: src/subject-groups/subject-groups.policy.spec.ts
- Delete: src/subject-groups/subject-groups.access-context.ts
- Delete: src/subject-groups/subject-groups.access-context.spec.ts
- Modify: src/subject-groups/subject-groups.service.ts
- Modify: src/subject-groups/subject-groups.controller.ts
- Modify: src/subject-groups/subject-groups.controller.spec.ts
- Modify: src/subject-groups/index.ts
-
Step 1: Mirror Task 6 steps 1–12. The branches in
SubjectGroupsPolicycome from the existingsubject-groups.access-context.ts(referent path joins viastudyPlanSubject.studyPlan.grade.departmentIdfor dept_head; via the SG roster for referent; etc.). Copy verbatim into the policy. -
Step 2: Commit
git add src/subject-groups
git rm src/subject-groups/subject-groups.access-context.ts src/subject-groups/subject-groups.access-context.spec.ts
git commit -m "refactor(subject-groups): migrate to EntityAccessPolicy"
Task 8: Migrate Departments¶
Files:
- Create: src/departments/departments.policy.ts
- Create: src/departments/departments.policy.spec.ts
- Modify: src/departments/departments.queries.ts — delete departmentsForAccessContext
- Modify: src/departments/departments.queries.spec.ts — delete the corresponding describe block
- Modify: src/departments/departments.service.ts
- Modify: src/departments/departments.controller.ts
- Modify: src/departments/departments.controller.spec.ts
- Modify: src/departments/index.ts
- Modify: src/curriculum/selection-window.service.ts (re-imports departmentsForAccessContext — now DepartmentsPolicy.where)
- Modify: src/curriculum/curriculum.service.ts (same re-import)
-
Step 1: Write the policy spec. Branches to cover (from existing
departments.queries.ts): platform admin, admin (pass-through), department_head (filter byid IN ctx.departmentIds), hr (pass-through), principal (pass-through), teacher (pass-through), staff (pass-through), referent (pass-through — referents read department names), student (pass-through), unrecognised role. -
Step 2: Run to verify FAIL.
-
Step 3: Implement
DepartmentsPolicy
// src/departments/departments.policy.ts
import type { Prisma } from '../generated/prisma/client';
import { EntityKey } from '../common/constants';
import { definePolicy, NEVER_MATCH_WHERE } from '../common';
export const DepartmentsPolicy = definePolicy<Prisma.DepartmentWhereInput>({
entityKey: EntityKey.DEPARTMENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{
role: 'department_head',
build: (ctx, base) => {
if (ctx.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, id: { in: ctx.departmentIds } };
},
},
{ role: 'hr', build: 'pass-through' },
{ role: 'principal', build: 'pass-through' },
{ role: 'teacher', build: 'pass-through' },
{ role: 'staff', build: 'pass-through' },
{ role: 'referent', build: 'pass-through' },
{ role: 'student', build: 'pass-through' },
],
});
-
Step 4: Run policy spec — verify PASS.
-
Step 5: Service swap — replace
departmentsForAccessContext(ctx)withDepartmentsPolicy.where(ctx)in: src/departments/departments.service.tssrc/curriculum/selection-window.service.ts-
src/curriculum/curriculum.service.ts -
Step 6: Controller decorator flip —
@RequireRoles(...)→@AppliesPolicy(DepartmentsPolicy)on the Departments controller. -
Step 7: Delete the old helper from
departments.queries.tsand its describe block fromdepartments.queries.spec.ts. -
Step 8: Update barrel.
-
Step 9: Run Departments test suite + E2E.
-
Step 10: Commit
git add src/departments src/curriculum/selection-window.service.ts src/curriculum/curriculum.service.ts
git commit -m "refactor(departments): migrate to EntityAccessPolicy"
Task 9: Migrate Grades¶
Files:
- Create: src/departments/grades.policy.ts
- Create: src/departments/grades.policy.spec.ts
- Modify: src/departments/departments.queries.ts — delete gradesForAccessContext
- Modify: src/departments/departments.queries.spec.ts — delete the describe block
- Modify: src/departments/grades.service.ts
- Modify: src/departments/grades.controller.ts
- Modify: src/departments/grades.controller.spec.ts
- Modify: src/departments/index.ts
-
Steps 1–9: Mirror Task 8. Branches from existing
gradesForAccessContext: admin (pass-through), department_head (departmentId IN ctx.departmentIds), hr / principal / teacher / staff (pass-through), referent / student (pass-through). No referent narrowing — grades are configuration. -
Step 10: Commit
Task 10: Migrate Curricula¶
Files:
- Create: src/curriculum/curricula.policy.ts
- Create: src/curriculum/curricula.policy.spec.ts
- Modify: src/curriculum/curriculum.queries.ts — delete curriculaForAccessContext
- Modify: src/curriculum/curriculum.queries.spec.ts — delete the describe block
- Modify: src/curriculum/curriculum.service.ts
- Modify: src/curriculum/curriculum.controller.ts
- Modify: src/curriculum/curriculum.controller.spec.ts
- Modify: src/curriculum/index.ts
-
Steps 1–9: Mirror Task 8. Branches from existing
curriculaForAccessContext: admin (pass-through), department_head (departmentId IN ctx.departmentIds), hr / principal / teacher (pass-through). -
Step 10: Commit
git add src/curriculum/curricula.policy.ts src/curriculum/curricula.policy.spec.ts \
src/curriculum/curriculum.queries.ts src/curriculum/curriculum.queries.spec.ts \
src/curriculum/curriculum.service.ts src/curriculum/curriculum.controller.ts \
src/curriculum/curriculum.controller.spec.ts src/curriculum/index.ts
git commit -m "refactor(curriculum): migrate Curricula to EntityAccessPolicy"
Task 11: Migrate StudyPlans¶
Files:
- Create: src/curriculum/study-plans.policy.ts
- Create: src/curriculum/study-plans.policy.spec.ts
- Modify: src/curriculum/curriculum.queries.ts — delete studyPlansForAccessContext
- Modify: src/curriculum/curriculum.queries.spec.ts — delete the describe block
- Modify: src/curriculum/curriculum.service.ts (already touched in Task 10 — re-edit)
- Modify: src/curriculum/study-plans.controller.ts
- Modify: src/curriculum/study-plans.controller.spec.ts
- Modify: src/curriculum/index.ts
- Steps 1–9: Mirror Task 8. Dept_head branch joins via
grade.departmentId:
{
role: 'department_head',
build: (ctx, base) => {
if (ctx.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, grade: { departmentId: { in: ctx.departmentIds } } };
},
}
- Step 10: Commit
git add src/curriculum
git commit -m "refactor(curriculum): migrate StudyPlans to EntityAccessPolicy"
Task 12: Migrate SelectionWindows¶
Files:
- Create: src/curriculum/selection-windows.policy.ts
- Create: src/curriculum/selection-windows.policy.spec.ts
- Modify: src/curriculum/selection-window.queries.ts — delete selectionWindowsForAccessContext
- Modify: src/curriculum/curriculum.queries.spec.ts — delete the describe block
- Modify: src/curriculum/selection-window.service.ts
- Modify: src/curriculum/selection-window.controller.ts
- Modify: src/curriculum/selection-window.service.spec.ts
- Modify: src/curriculum/index.ts
-
Steps 1–9: Mirror Task 8. Branches: admin (pass-through), department_head (
departmentId IN ctx.departmentIds), hr / principal / teacher (pass-through), referent (pass-through — referents read window status). Per currentselectionWindowsForAccessContext. -
Step 10: Commit
git add src/curriculum
git commit -m "refactor(curriculum): migrate SelectionWindows to EntityAccessPolicy"
Task 13: Migrate Teachers¶
Files:
- Create: src/teachers/teachers.policy.ts
- Create: src/teachers/teachers.policy.spec.ts
- Modify: src/teachers/teachers.queries.ts — delete teachersForAccessContext
- Modify: src/teachers/teachers.queries.spec.ts
- Modify: src/teachers/teachers.service.ts
- Modify: src/teachers/teachers.controller.ts
- Modify: src/teachers/teachers.controller.spec.ts
- Modify: src/teachers/teachers.service.spec.ts
- Modify: src/teachers/index.ts
- Steps 1–9: Mirror Task 8. Dept_head branch is M2M via
TeacherDepartmentAssignment:
{
role: 'department_head',
build: (ctx, base) => {
if (ctx.departmentIds.length === 0) return NEVER_MATCH_WHERE;
return {
...base,
departments: { some: { departmentId: { in: ctx.departmentIds } } },
};
},
}
Import route stays @RequireRoles('admin') — same exception as Students. Confirm the role list of every other route against the current decorator and lift verbatim.
- Step 10: Commit
Task 14: Migrate Staff¶
Files:
- Create: src/staff/staff.policy.ts
- Create: src/staff/staff.policy.spec.ts
- Modify: src/staff/staff.queries.ts — delete staffForAccessContext
- Modify: src/staff/staff.queries.spec.ts
- Modify: src/staff/staff.service.ts
- Modify: src/staff/staff.controller.ts
- Modify: src/staff/staff.controller.spec.ts
- Modify: src/staff/index.ts
-
Steps 1–9: Mirror Task 8. Staff is tenant-flat per chapter 04 — no dept_head record narrowing. Role list per current controller: admin (CRUD), admin/hr (read paths). Import route stays
@RequireRoles('admin'). -
Step 10: Commit
Task 15: Migrate Referents¶
Files:
- Create: src/referents/referents.policy.ts
- Create: src/referents/referents.policy.spec.ts
- Modify: src/referents/referents.controller.ts
- Modify: src/referents/referents.controller.spec.ts
- Modify: src/referents/referents.service.ts (if it calls a record-level helper)
- Modify: src/referents/index.ts
- Steps 1–9: Mirror Task 8, with caveats.
Referents do not have a record-level helper (assertCallerIsSelfOrAdmin is the equivalent runtime gate). The policy here is mostly a role allowlist for the decorator:
export const ReferentsPolicy = definePolicy<Prisma.ReferentWhereInput>({
entityKey: EntityKey.REFERENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{ role: 'teacher', build: 'pass-through' }, // read-only routes
{
role: 'referent',
build: (ctx, base) => ({ ...base, userId: ctx.userId }),
},
],
});
The existing assertCallerIsSelfOrAdmin gate stays — it's the runtime self/admin discriminator and the policy doesn't replace it.
If certain routes only admit a subset (admin only on the bulk endpoints, admin/teacher only on reads), audit per route. Likely policy applies cleanly to most; the bulk-admin routes keep their @RequireRoles('admin') if they're tighter than the policy admits.
- Step 10: Commit
Task 16: Migrate AcademicYears¶
Admin-only entity. The policy is trivial: roles: ['admin'], pass-through base.
Files:
- Create: src/academic-years/academic-years.policy.ts
- Create: src/academic-years/academic-years.policy.spec.ts
- Modify: src/academic-years/academic-years.controller.ts
- Modify: src/academic-years/academic-years.controller.spec.ts
- Modify: src/academic-years/index.ts
- Steps 1–6: Same shape as the larger tasks, but the policy has one branch:
export const AcademicYearsPolicy = definePolicy<Prisma.AcademicYearWhereInput>({
entityKey: EntityKey.ACADEMIC_YEARS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [{ role: 'admin', build: 'pass-through' }],
});
Spec covers: platform admin, admin, unrecognised role (3 tests).
Controller flips every @RequireRoles('admin') to @AppliesPolicy(AcademicYearsPolicy).
- Step 7: Commit
git add src/academic-years
git commit -m "refactor(academic-years): migrate to EntityAccessPolicy (admin-only)"
Task 17: Migrate School¶
Identical trivial-policy shape to Academic Years.
Files:
- Create: src/school/school.policy.ts
- Create: src/school/school.policy.spec.ts
- Modify: src/school/school.controller.ts
- Modify: src/school/school.controller.spec.ts
- Modify: src/school/index.ts
-
Steps 1–7: Mirror Task 16. Policy:
{ entityKey: EntityKey.SCHOOL, roles: ['admin'] }. -
Step 8: Commit
Task 18: Migrate command-center controllers¶
Three controllers, two policies (onboarding + study-plan-selections are admin-only; completeness is admin/referent).
Files:
- Create: src/command-center/onboarding.policy.ts (admin-only)
- Create: src/command-center/study-plan-selections.policy.ts (admin-only)
- Create: src/command-center/completeness.policy.ts (admin + referent)
- Create: src/command-center/onboarding.policy.spec.ts
- Create: src/command-center/study-plan-selections.policy.spec.ts
- Create: src/command-center/completeness.policy.spec.ts
- Modify: src/command-center/onboarding.controller.ts
- Modify: src/command-center/study-plan-selections.controller.ts
- Modify: src/command-center/completeness.controller.ts
- Modify: controllers' spec files
- Modify: src/command-center/index.ts
- Steps 1–7 per controller:
OnboardingPolicy:roles: ['admin'], tenant-onlywhere. Entity key: TBD — confirm with the existing@RequireScopesvalue (likelyEntityKey.SETUPorEntityKey.COMMAND_CENTER; pick what matches the existing route).StudyPlanSelectionsPolicy: same shape.CompletenessPolicy:roles: ['admin', 'referent']. Referent branch narrows tostudentsForCompleteness(ctx)which already wrapsStudentsPolicy.where(ctx)— make sure the import incompleteness.queries.tspoints toStudentsPolicy(Task 5 already swapped this).
Note:
command-center/controllers reuse other entities' visibility (Students). The policies here are not about records owned by command-center; they're route-level allowlists. The trivial-policy shape applies even though thewhereis unused by the service.
- Step 8: Commit
git add src/command-center
git commit -m "refactor(command-center): migrate controllers to EntityAccessPolicy"
Task 19: Migrate Students/curriculum-selection sub-resource¶
The sub-resource controller (src/students/curriculum-selection.controller.ts) has two routes:
- GET /students/:id/curriculum-selection — @RequireRoles('admin', 'teacher', 'referent')
- PATCH /students/:id/curriculum-selection — @RequireRoles('admin', 'referent')
The two role lists are different. Two policies needed.
Files:
- Create: src/students/curriculum-selection.policies.ts (two exports: CurriculumSelectionReadPolicy, CurriculumSelectionWritePolicy)
- Create: src/students/curriculum-selection.policies.spec.ts
- Modify: src/students/curriculum-selection.controller.ts
- Modify: src/students/curriculum-selection.controller.spec.ts
- Modify: src/students/index.ts
- Steps 1–7: Define both policies with
entityKey: EntityKey.STUDENTSand matching branch sets. Thewhereclauses match the underlying student visibility — both delegate toStudentsPolicy.where(ctx):
export const CurriculumSelectionReadPolicy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{ role: 'teacher', build: 'pass-through' },
{
role: 'referent',
build: (ctx, base) => ({
...base,
referents: { some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } } },
}),
},
],
});
export const CurriculumSelectionWritePolicy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{
role: 'referent',
build: (ctx, base) => ({
...base,
referents: { some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } } },
}),
},
],
});
- Step 8: Commit
git add src/students/curriculum-selection.policies.ts \
src/students/curriculum-selection.policies.spec.ts \
src/students/curriculum-selection.controller.ts \
src/students/curriculum-selection.controller.spec.ts \
src/students/index.ts
git commit -m "refactor(students): migrate curriculum-selection sub-resource to EntityAccessPolicy"
Phase 3 — Cleanup, docs, drift specs¶
Task 20: Add the policy-↔-scope drift check¶
A policy declares entityKey. The route declares @RequireScopes(entityKey, ...). Verify they match at boot.
Files:
- Modify: src/common/dto/aggregate-response-invariant.ts — rename to boot-invariants.ts (or add a sibling file) and add assertPolicyScopeAlignment(controllers).
- Modify: src/common/dto/aggregate-response-invariant.spec.ts — add a new describe block for the policy/scope drift check.
- Modify: src/main.ts — call the new assertion.
- Step 1: Write the failing drift test
Fixture: a controller whose method has @RequireScopes(EntityKey.STUDENTS, 'read') + @AppliesPolicy(TeachersPolicy). Expected: the scanner throws naming the offending route.
-
Step 2: Run to verify FAIL.
-
Step 3: Implement
assertPolicyScopeAlignment(controllers):
export function assertPolicyScopeAlignment(controllers: object[], options: { strict?: boolean } = {}) {
const strict = options.strict ?? process.env.NODE_ENV !== 'production';
const violations: string[] = [];
const scanner = new MetadataScanner();
for (const c of controllers) {
const proto = Object.getPrototypeOf(c);
scanner.scanFromPrototype(c, proto, (methodName) => {
const policy = Reflect.getMetadata(POLICY_METADATA, proto, methodName) as
| EntityAccessPolicy<unknown>
| undefined;
const scopeMeta = Reflect.getMetadata(REQUIRE_SCOPES_METADATA, proto, methodName) as
| { entity: EntityKey; action: string }
| undefined;
if (policy && scopeMeta && policy.entityKey !== scopeMeta.entity) {
violations.push(
`${c.constructor.name}.${methodName}: @RequireScopes(${scopeMeta.entity}) does not match @AppliesPolicy(${policy.entityKey})`,
);
}
});
}
if (violations.length === 0) return;
const msg = `[PolicyScopeAlignment] ${violations.length} drift(s):\n${violations.join('\n')}`;
if (strict) throw new Error(msg);
console.error(msg);
}
-
Step 4: Wire into
main.tsnext to the aggregate-response check. -
Step 5: Run drift spec — verify PASS.
-
Step 6: Commit
git add src/common/dto/aggregate-response-invariant.ts \
src/common/dto/aggregate-response-invariant.spec.ts \
src/main.ts
git commit -m "feat(common): boot-time policy↔scope alignment check"
Task 21: Documentation updates¶
Files:
- Modify: docs/04-rbac.md
- Modify: docs/11-workflows.md
- Modify: docs/14-homerooms-subject-groups.md
- Modify: docs/REFERENCE.md
-
Step 1:
docs/04-rbac.md -
Replace every
<entity>ForAccessContextreference with<Entity>Policy.where(ctx). - In the "Decorator Decision Table" (~line 337), add a row showing
@AppliesPolicy(<Policy>)as the role-gate decorator. - In the "Record-Level Access" section, update the canonical example snippet from
studentsForAccessContexttoStudentsPolicy.where(ctx). -
Add a new sub-section "EntityAccessPolicy — single source of truth" near the top of "Record-Level Access" linking to the spec.
-
Step 2:
docs/11-workflows.md -
Update §3 step 11 from "Add
@RequireRoles(...)to each role-gated route" to "Add@AppliesPolicy(<Entity>Policy)to each role-gated route. The policy lives insrc/<domain>/<entity>.policy.ts." - Add a new §13 "Add a new role-aware entity (define a policy)" recipe:
- Create
src/<domain>/<entity>.policy.tsusingdefinePolicy(...). - Create
src/<domain>/<entity>.policy.spec.tscovering each branch + platform admin + unrecognised role. - Export
<Entity>Policyfrom the module barrel. - Apply
@AppliesPolicy(<Entity>Policy)on every controller method that requires a role gate. -
In the service, call
<Entity>Policy.where(ctx)to compose the Prisma WHERE. -
Step 3:
docs/14-homerooms-subject-groups.md
Update the access-context snippet to show HomeroomsPolicy.where(ctx). Same for SubjectGroups.
- Step 4:
docs/REFERENCE.md
In §6 file index, add a row for <entity>.policy.ts under role-aware entities.
- Step 5: Commit
git add docs/04-rbac.md docs/11-workflows.md docs/14-homerooms-subject-groups.md docs/REFERENCE.md
git commit -m "docs: document EntityAccessPolicy across the chapter set"
Task 22: Memory entry¶
Capture the recipe so future work consults the right pattern.
Files:
- Create: C:\Users\unive\.claude\projects\C--Users-unive-Desktop-sis-student-information-system-be\memory\reference_entity_access_policy_recipe.md
- Modify: C:\Users\unive\.claude\projects\C--Users-unive-Desktop-sis-student-information-system-be\memory\MEMORY.md
- Step 1: Write the memory file
---
name: entity-access-policy-recipe
description: One-source recipe for role-gated entities — replace @RequireRoles + <entity>ForAccessContext with definePolicy + @AppliesPolicy
metadata:
type: reference
---
Per-entity record-level visibility lives in `src/<domain>/<entity>.policy.ts` as a single `EntityAccessPolicy` produced by `definePolicy(...)`. The policy bundles `entityKey`, `roles` (branch-derived), and `where(ctx)`.
**At the controller:** `@AppliesPolicy(<Entity>Policy)` on every role-gated route. It composes `@RequireRoles(...policy.roles)` + `SetMetadata(POLICY_METADATA, policy)` — `RolesGuard` keeps working unchanged.
**At the service:** call `<Entity>Policy.where(ctx)` and AND it into the Prisma `where` clause. Returns either a typed `Prisma.<Entity>WhereInput` or `NEVER_MATCH_WHERE` (fails closed).
**Branch order matters.** `department_head` must precede `staff`/`teacher` because every dept_head's JWT carries the synthetic `staff` profile role.
**Don't** add `@RequireRoles(...)` alongside `@AppliesPolicy(...)` — pick one. The policy is the source. Tighter-than-policy routes (e.g. imports stay admin-only) keep raw `@RequireRoles('admin')` instead of a policy.
Canonical example: `src/students/students.policy.ts`. Spec: [[../docs/superpowers/specs/2026-05-26-entity-access-policy-design]]. Plan: [[../docs/superpowers/plans/2026-05-26-entity-access-policy]].
- Step 2: Append to MEMORY.md
Append to the References section:
- [EntityAccessPolicy recipe](reference_entity_access_policy_recipe.md) — `definePolicy` + `@AppliesPolicy` replace `@RequireRoles` + `<entity>ForAccessContext`; the policy is the single source of truth per role-aware entity
- Step 3: No commit — memory is local, not part of the repo.
Self-review¶
Spec coverage¶
- §1 Success criteria → Tasks 1–19 (policy abstraction + every-entity migration), Task 3 (aggregate-response check), Task 4 (year-writability fold). ✅
- §3 Architecture mapping → Task 1 (entityKey field), Tasks 5–19 (no DB changes, no scope/action changes), Task 3 (aggregate-response brand reinforced). ✅
- §7 Divergence ledger → Task 2 (decorator divergence), Task 3 (boot-time check), Task 4 (year-writability). ✅
- §9 Deferrals → respected (no
assignmentdual-surface unwind, no Students split, no scope-groups refactor, no AggregateGet helper). ✅ - §11 Verification plan → every entity has a policy spec; every controller spec gets the decorator-metadata update; existing E2E suites cover behavioural parity. ✅
Placeholder scan¶
- Task 5 step 7: "the eligible-students picker uses its own narrower policy if applicable (audit per route)" — kept as a per-route audit note because role lists for pickers vary; explicit recommendation is "if stable, define a tighter
HomeroomsPickerPolicy; otherwise keep raw@RequireRoles('admin', 'hr', 'department_head')". This is intentional, not a placeholder. - Task 18 step 1: "Entity key: TBD — confirm with the existing
@RequireScopesvalue" — actionable lookup, not a placeholder; the engineer reads the existing decorator and picks the matching entity. - Task 3 step 4
collectControllers: resolved in step 5 by changing the signature to acceptcontrollers: object[]instead of discovering via reflection. No remaining placeholder.
Type consistency¶
EntityAccessPolicy<TWhere>is referenced consistently across tasks 1, 2, 5, 6+. Decorator signature usesEntityAccessPolicy<unknown>(the decorator is generic-erased; tasks 5–19 always reference the specific policy, never the erased form).POLICY_METADATAsymbol is consistent across Tasks 2 (definition), 5 (consumption in test), 20 (drift check).NEVER_MATCH_WHEREis re-exported fromsrc/common(already exists inaccess-context-helpers.ts); every policy spec asserts identity comparison (toBe(NEVER_MATCH_WHERE)) to match howmakeRoleVisibilityResolverreturns the constant.EntityKey.STUDENTS/.HOMEROOMS/.SUBJECT_GROUPS/ etc. are used as policy keys; per Task 18 step 1, command-center entity keys are confirmed against the existing@RequireScopesdecorator value before adoption.
No remaining inconsistencies.
Execution Handoff¶
Plan complete and saved to docs/superpowers/plans/2026-05-26-entity-access-policy.md. Two execution options:
1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration. Phase 1 (Tasks 1–4) runs sequentially because Task 2 depends on Task 1 and Task 3 wires into main.ts. Phase 2 (Tasks 5–19) parallelises after Task 5 lands — Tasks 6, 7, …, 19 can run in parallel subagents because they touch disjoint module folders (only command-center/completeness.queries.ts re-imports StudentsPolicy, which is in place once Task 5 commits). Phase 3 runs after Phase 2.
2. Inline Execution — Execute tasks in this session using superpowers:executing-plans, batch execution with checkpoints for review.
Which approach?