Roster sync and the academic calendar
Where a school's pupil and staff records come from, when they are refreshed, and what a term start sets in motion. Last updated 2026-09-06.
Read alongside the roster mapping protocol, which says what a vendor is told once these records exist.
1. Why this exists
The roster is the load-bearing thing on the platform. A pupil's externalId is what every vendor keys its accounts on, so a roster that drifts — a leaver who still has access, a pupil in last term's class, a re-import that renumbers everybody — shows up as broken accounts in somebody else's product, weeks later, where nobody can trace it.
Until now the only way in was a CSV upload, done by hand, whenever somebody remembered. This replaces "whenever somebody remembered" with a schedule, a button, and a record of every run.
2. The academic calendar
A school defines its academic years and the terms inside them at School Settings → Academic calendar.
Without one the platform guesses a year from the date — 2026/27 for anything in 2026 — which is right for a January school and wrong for every September one. That guess is the label stamped on every class, and the label a vendor sees, so a school with a real calendar gets real labels and a school without one keeps working on the guess.
Rules the platform enforces: a term sits inside its year, years do not overlap each other, and terms do not overlap each other. A school is in one academic year at a time.
3. What a term start triggers
The start of a term opens a checklist — five tasks, generated once, ticked off by the school:
| Task | Due | Why |
|---|---|---|
| Bring the roster up to date | day 0 | ids must stay exactly as they were; a renumbered pupil is a new person to every vendor and nobody can undo it |
| Check classes and who is in them | day 3 | this is what vendors place people by |
| End access for leavers | day 7 | their vendor accounts stop the moment they leave the roster |
| Review which classes use which apps | day 7 | last term's scope carries over silently |
| Check staff accounts and roles | day 14 | new staff, changed responsibilities, people who should no longer have access |
It is generated, not merely displayed: a checklist that appears and disappears with the date tells nobody afterwards whether the work was done. Where the school has a sync connected, the term start also pulls the roster.
Generation is idempotent on (school, term, task), so a scheduler firing twice, or catching up after an outage, cannot duplicate it.
4. Connecting a student information system
School Settings → Roster sync. One connection per school.
| Provider | Needs | State |
|---|---|---|
| CSV export over HTTPS | the https URL of the export | works, and is exercised by tests/sis-sync.test.ts |
| iSAMS | host URL and a Batch API key scoped to pupils and forms | written from published documentation, not yet run against a live school |
| Wonde | https://api.wonde.com, the school id, the access token | same |
The two unverified adapters say so on the settings page, in those words. An integration that claims to work and does not is discovered by a school at the worst possible moment, and the fix for that is honesty in the interface, not optimism.
Adding a provider is a case in lib/sis/adapters.ts plus a row in lib/sis/providers.ts. Every adapter does the same narrow job — fetch, and return rows in the roster template's shape — so a provider that sends something unexpected produces rejected rows rather than a half-written roster.
No adapter may invent an externalId. A source with no stable pupil code fails the sync outright, with that reason, because a generated id would make every pupil a stranger to every vendor on the very next run.
5. Running a sync
| Trigger | When |
|---|---|
| Manual | somebody presses *Sync now*, or *Preview* |
| Scheduled | daily or weekly at an hour the school picks, in UTC |
| Term start | the first poll of a new term |
Deliberately coarse: a roster changes a handful of times a term, and a tight poll loop is a good way to be rate-limited by a school's SIS supplier. The schedule can be off entirely, and the button still works.
Preview changes nothing. It fetches, validates and reports exactly what a real sync would do — added, updated, ended, rejected — and writes not one row. It is the first thing to try against an unverified provider.
What a sync does, and the one thing it never does
- a pupil in the export who is new here is created;
- a pupil who exists is updated, keeping their id, and re-enrolled if their class changed;
- a pupil absent from the export is ended, not deleted.
That last rule is the important one. A bad export — a truncated file, a filter left on, a supplier outage returning an empty list — would otherwise erase a year of history, engagement and audit trail that no vendor can hand back. Ending is reversible: put them back in the export, or edit the record. A sync that ends anybody sends the school a notification saying how many and why.
The record
Every run is a row, successful or not, with the trigger, who pressed it, the counts, and up to fifty rejected rows with their reason. That is what answers *"why did half my pupils change class last night"*, and it is why a failed run is kept rather than swallowed.
Domain events on the school's audit chain: school.sis_configured, school.sis_synced, school.sis_sync_previewed, school.sis_sync_failed, school.sis_disconnected. The credential never appears in any of them — whether one is set is the useful fact — and the Postgres capture trigger masks the column as it does every other secret.
6. Permissions
Two modules (docs/rbac-spec.md):
| Module | VIEW | EDIT |
|---|---|---|
school.calendar | see years, terms and the checklist | change them, tick tasks off |
school.roster_sync *(sensitive)* | see the connection, the schedule and every run | change the connection, run a sync |
A teacher holds school.calendar VIEW by default: term dates decide their classes and the checklist is work they do. The sync itself is a school administrator's, and it is separable from the rest of settings so an IT lead can run it without also holding sign-on and subscription.
7. Operations
Point a scheduler at POST /api/internal/roster with header x-audit-key: $AUDIT_SERVICE_SECRET, hourly. It polls every school whose next run has come round and opens the checklist for any term that started in the last day. Both halves are idempotent.
Scheduled in production since 2026-09-06 as eduspaze-roster-poll, hourly (docs/deployment.md §3). A school whose polling is set to *Manual only* is untouched by it; *Sync now* still works for everybody either way.
