Naalya Handbook
Academic-Year Scoping

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.

Almost every record in this system belongs to a school year. An enrollment is for a particular year. So is a grade, an exam, a report card, a term fee.

That sounds obvious, but it has a consequence you have to handle in every feature you build: when someone asks for "the enrollments", the system has to know which year they mean. This page explains how it knows.

The problem, concretely

It is October 2026. A teacher opens the gradebook.

  • Most of the time they want this year's grades.
  • But sometimes they need last year's — a student transferred in, and they want to see their history.
  • And an admin might still be finishing a report from a year that already ended.

So the system cannot just assume "always show the current year". Staff legitimately move between years, and the software has to let them.

The solution: the year is sent with every request, as an ordinary field called campusAcademicYearId. The frontend says which year it is asking about, every time. Nothing is assumed.

Three walls, and only one is your job

Before any query reaches the database, three separate filters narrow it down. Two happen automatically. The third is the one you have to remember.

One query, three walls — what stands between a request and a row
automatic
School wall — school_id
Added for you by BaseRepository. Answers: which school?
forget it? impossible — no school means a 500, never a wide read
automatic
Campus wall — permissions
Each user's rules become row filters. Answers: which campuses may they see?
rows outside their scope are filtered out, or a 403
yours
Year wall — campusAcademicYearId
You require it on the DTO and filter by it. Answers: which year's records?
forget it and validation returns 400 — never a silent all-years read
↓ only then does the query reach the table — links: Multi-Tenancy · RBAC & Scopes

Why is the year the manual one? Because the other two never change during a request. You are always one user, in one school, with one set of campus permissions — so the system can apply those silently. But the year genuinely changes from screen to screen, so only the request itself knows it.

The safety net is that the field is required. If you forget to send it, validation rejects the request with a 400. It never quietly falls back to "all years" — which would show a teacher four years of grades at once.

What a "campus academic year" actually is

Here is the part that trips people up. Records do not point at a year like "2025/2026". They point at one campus running one year.

Three tables, three jobs:

TableWhat it represents
academic_yearThe year as a concept — "2025/2026". One row for the whole platform.
campus_x_academic_yearOne campus's run of that year. Its own start and end dates, its own lock. This is what records point at.
campus_termA term inside one campus's year, so a term is automatically specific to both.

Why not just point at the year? Because campuses run the same year differently. Lugazi might still be entering results for 2024/2025 while Kampala has finished and sealed it. If records pointed at the shared year, one campus finishing would affect the other.

You will see this called 'the cay'

In code and in conversation, campus_x_academic_year is shortened to cay. If someone says "pass the cay id", they mean campusAcademicYearId.

What decides whether a write is allowed

Each campus year has two separate flags, and they are constantly confused:

FlagWhat it meansDoes it block writes?
isCurrent"This is the year the app shows by default"No. It is a display pointer, nothing more.
lockedAt"This year is sealed — the records are final"Yes. This is the only write gate.

So a teacher can still correct last year's grades while looking at this year's screen — right up until an admin locks last year. Locking is a deliberate, explicit act.

Never gate a write on isCurrent

It is tempting to write "only allow edits if this is the current year". That is wrong, and it breaks a real workflow: staff routinely finish last year's paperwork in the first weeks of a new year. Check lockedAt.

Which year gets checked

There is a subtlety here worth slowing down for.

When you create something, the year comes from the request — that is the year the new record will belong to.

When you change something that already exists, the check is against that record's own year, not whatever year the user happens to be viewing. Otherwise someone viewing an unlocked 2026 could edit a locked 2025 record just by having the right screen open.

The mental model

Five questions, five answers. If you remember this table you have the whole concept.

QuestionAnswerWho enforces it
Which year is this request about?The campusAcademicYearId it carriesA required DTO field, checked by validation
Is this write allowed?Only if that year is unlockedCampusAcademicYearScopeService.assertNotLocked()
Which year does a change check against?The record's own year, not the viewer'sThe service loads the record first
Is a year past, current, or future?Compare its order number to the current year'sWorked out on read — never stored as a flag
Can a year be deleted?Not while any record still points at itCAMPUS_YEAR_RESOURCES409 listing what is attached

That last row is worth noting: there is no stored isPast or isFuture. Those were deliberately removed, because a stored flag drifts out of date the moment a new year starts. Ordering is compared instead.

Where to go next

On this page