Request Scoping
How campusAcademicYearId travels on every year-scoped request — the required-DTO rule, the one exemption, and the scope service behind every write.
The year is request data, not ambient context. Where the tenant rides invisibly in AsyncLocalStorage (see Multi-Tenancy), campusAcademicYearId is carried explicitly on each request — a query param on lists, a body field on writes — and every layer between HTTP and the database has a specific job in making that safe. This page walks that path.
Step 1: The DTO makes the year unforgettable
A DTO (Data Transfer Object — the typed, validated shape of a request) is where the rule lives. On every year-scoped list and create DTO, campusAcademicYearId is a required @IsUUID() with no @IsOptional:
@IsUUID()
@ApiProperty()
campusAcademicYearId: string;The global ValidationPipe enforces this at the HTTP boundary, which changes the failure mode in the way that matters: a client that forgets the year gets a 400 Validation failed — never a silent all-years response. Compare that with tenant scoping, where forgetting is impossible because the engine injects the filter; here forgetting is impossible because the request won't parse. Two mechanisms, same fail-closed philosophy.
This is why there's no repository magic to learn: once the DTO guarantees the field exists, the service simply threads it into the query —
// reads: the year is just another predicate you merge into where
return this.gradeRepository.findAll({ where: { campusAcademicYearId, termId } });BaseRepository adds school_id on top of whatever you pass; it never adds the year. If you leave campusAcademicYearId out of a where, you will get cross-year rows — the DTO rule is what makes that a can't-happen for HTTP callers.
Step 2: The one sanctioned exemption
There is exactly one read allowed to omit the year: a person-history view — one student's records across years, e.g. the enrollment history on a student's profile. EnrollmentListQueryDto encodes it with @ValidateIf, and the shape is worth reading closely because you must copy it exactly if you ever add another exemption:
// Required for every list except a single student's cross-year history —
// the person-record view is the one legitimate unscoped read. Still
// format-validated whenever a value is present, even alongside studentId.
@ValidateIf(
(o: EnrollmentListQueryDto) =>
!o.studentId || o.campusAcademicYearId !== undefined,
)
@IsUUID()
@ApiPropertyOptional({ description: 'Required unless studentId is provided.' })
campusAcademicYearId?: string;Read the condition twice: the year is validated when there's no studentId (so it's effectively required), and also whenever a value was actually sent (so a malformed UUID next to a studentId still fails). If you copy this pattern, you must be able to say why your read qualifies as a person-history view — "the list is annoying to scope" does not qualify.
Step 3: The scope service gates the write
CampusAcademicYearScopeService (apps/server/src/app/academic-year/campus-academic-year-scope.service.ts) is injected into every year-scoped service. It's small enough to read whole — three asserts:
/** Write gate for year-scoped mutations: year must belong to campus and be unlocked. */
async assertNotLocked(campusAcademicYearId: string, campusId: string) {
const cay = await this.assertBelongsToCampus(campusAcademicYearId, campusId);
if (cay.lockedAt) {
throw new BadRequestException('Academic year is locked');
}
return cay;
}| Method | Checks | Throws |
|---|---|---|
assertBelongsToCampus(cayId, campusId) | Binding exists and belongs to the campus | 404 / 400 |
assertNotLocked(cayId, campusId) | The above, then lockedAt == null | 400 "Academic year is locked" |
assertTermMatchesYear(termId, cayId) | The term belongs to that campus year | 404 / 400 |
The quiet security property: assertBelongsToCampus loads the binding through the tenant-scoped repository. A campusAcademicYearId belonging to another school simply doesn't exist from this tenant's point of view — the lookup 404s. That makes the scope service double as the IDOR guard (IDOR = Insecure Direct Object Reference: reaching another tenant's row by guessing its id) with zero extra code.
Step 4: Gate on the right year
The subtle rule, and the one reviewers check first. Creates gate on the request's year; mutations of existing rows gate on the row's own year. Both appear in GradeService.create:
// the enrollment must live in the same year the request claims
if (enrollment.campusAcademicYearId !== payload.campusAcademicYearId) {
throw new BadRequestException(
'Enrollment does not belong to the selected campus academic year',
);
}
await this.campusAcademicYearScope.assertNotLocked(
payload.campusAcademicYearId, // ← the REQUEST's year: you're writing INTO it
enrollment.campusId,
);
await this.campusAcademicYearScope.assertTermMatchesYear(
payload.termId,
payload.campusAcademicYearId, // ← term must belong to the same year
);…while a mutation loads the row first and gates on what it finds:
/** Rejects mutations when the enrollment's campus academic year is locked. */
private async assertEnrollmentYearUnlocked(enrollment: EnrollmentEntity) {
await this.campusAcademicYearScope.assertNotLocked(
enrollment.campusAcademicYearId, // ← the ROW's own year, not the viewer's
enrollment.campusId,
);
}Why the split matters: a teacher viewing the current year can still fix a record from last year — the write is judged by last year's lock, not by which year they happen to be looking at. And nothing anywhere gates on isCurrent; the lock is the only write authority (see Locking & Lifecycle).
Internal callers bypass the pipe
The ValidationPipe only guards HTTP. AI tools, cron jobs, and any code calling a service method directly skip DTO validation entirely — they must pass campusAcademicYearId themselves. A bare service.list({}) from an RPC tool silently returns cross-year data; that exact gap is why year-scoped agent tools take the year as a required input. See Adding an RPC tool.
Where to go next
Locking and Lifecycle
The lock model behind assertNotLocked — and the endpoints that create, start, and seal years.
Scoped Entities
Which entities carry campusAcademicYearId, and which deliberately do not.
Add a Year-Scoped Entity
Apply all four steps to a new resource, end to end.
DTOs and Validation
How the global ValidationPipe and DTO decorators work in general.
Academic-Year Scoping
Why every school record belongs to a school year, how that year travels with each request, and why a lock — not the current year — decides whether a write is allowed.
Locking & Lifecycle
Creating, starting, and sealing academic years — the lock write-gate, transactional set-current, order-derived phase, and delete protection.