Escape Hatches
runUnscoped and withSchool for jobs, seeders, and operators — reading the current tenant, the how-tos, and the gotchas.
Most code never touches the tenant context directly — the request sets it, the repository reads it, done. But some operations run without a request (background jobs, seeders) or need a tenant other than the caller's (login, operators). Two helpers cover those.
The two helpers
Each helper is a fresh run() block that restores the previous context when it returns:
runUnscoped(fn)— runsfnwithbypass: true. Isolation is off for the duration. Reach for it only when the operation is genuinely global or cross-tenant.withSchool(schoolId, fn)— runsfnpinned to a specific school. Reach for it when you know exactly which tenant you want.
Both are tenant-level only. There is no academic-year escape hatch, because the year was never ambient to begin with — code running outside a request (jobs, AI tools) must pass campusAcademicYearId explicitly to year-scoped services. See Academic-Year Scoping.
export function runUnscoped<R>(fn: () => R): R {
return tenantContextStore.run(
{ schoolId: null, isPlatformOperator: false, bypass: true }, fn);
}
export function withSchool<R>(schoolId: string, fn: () => R): R {
return tenantContextStore.run(
{ schoolId, isPlatformOperator: false, bypass: false }, fn);
}When to reach for which:
Use runUnscoped for | Use withSchool(id, …) for |
|---|---|
Login lookups (email / microsoftSub are globally unique) | Background / worker jobs (no request context) |
| Seeders and bootstrap | Operator endpoints acting on one school :id |
| Operator cross-tenant reads | The Microsoft @Public() webhook callback |
Resolving the caller's own identity (/auth/me) | Impersonation sessions |
Two real examples ground this. /auth/me returns the caller's own identity, so it resolves runUnscoped — any drift between the user's school_id and their JWT's schoolId would otherwise filter them out of their own session:
async getMe(jwtPayload: JwtPayload): Promise<AuthMeResponse> {
const [{ user, profile }, ctx, assignments] = await runUnscoped(() =>
Promise.all([
this.userService.findWithProfile(jwtPayload.sub),
this.abilityFactory.resolveContext(jwtPayload),
this.userXRoleRepository.findAll({ where: { userId: jwtPayload.sub } }),
]),
);
// ...
}And the API-key verifier runs runUnscoped because, by definition, no tenant context exists yet — the key itself is what resolves the school:
const key = await runUnscoped(() =>
this.apiKeyRepository.findOneWhere({ keyHash, revokedAt: IsNull() }),
);runUnscoped turns isolation OFF — it is not 'use another school'
The single most common misuse is reaching for runUnscoped when you actually wanted a different school. runUnscoped removes the tenant filter entirely — a query inside it can read across every tenant. If you just need a specific other school, use withSchool(id, …). Save runUnscoped for genuinely global or operator-level work.
Step 1: Read the current tenant
Most of the time you never need to — the repository handles it. But occasionally you want to know the active tenant inside a service (to build a cache key, log it, or branch on operator vs. school user). Use getTenantContext().
First, import it from the shared library:
import { getTenantContext } from '@app/shared';Then read it, and handle the undefined case — it's undefined whenever you're outside a request and outside any withSchool / runUnscoped block:
const ctx = getTenantContext();
if (!ctx || ctx.schoolId == null) {
// No tenant pinned. Don't guess — either you're outside a request,
// or this is a fail-closed default that a real repo call would reject.
throw new Error('Expected an active tenant context here.');
}
const schoolId = ctx.schoolId; // safe to use nowReading the context is a smell more often than not
If you find yourself reading ctx.schoolId to pass it into a repository call, stop — the repository already injects it. Manually threading schoolId re-introduces the exact mistake the engine exists to prevent. Read the context for logging or cache keys, not to re-scope queries.
Step 2: Run a query outside the scope
Say you're writing a background job — a worker processor that bills every school. There's no request, so there's no context, and a scoped repository call would throw TenantContextMissingError. You have to establish a tenant yourself.
If the job operates on one known school, wrap its body in withSchool. Everything inside runs pinned to that tenant:
import { withSchool } from '@app/shared';
await withSchool(job.data.schoolId, async () => {
const invoices = await this.invoiceRepository.findAll(); // auto-scoped to schoolId
await this.invoiceRepository.create(/* … */); // auto-stamped schoolId
});If the job genuinely spans all tenants — a cross-tenant report, a global cleanup — you have two safe shapes. Either loop over schools and pin each one with withSchool (preferred: each query stays isolated), or, only when you truly need an unfiltered sweep, use runUnscoped:
import { runUnscoped, withSchool } from '@app/shared';
// Preferred: isolate per school
const schools = await runUnscoped(() => this.schoolRepository.findAll());
for (const school of schools) {
await withSchool(school.id, () => this.buildReportForSchool());
}
// Only when a single unfiltered query is genuinely required:
await runUnscoped(() => this.someGlobalRepository.findAll());Public routes don't get a tenant — opt in explicitly
A @Public() route short-circuits JwtAuthGuard, so its context stays the fail-closed default. If a public endpoint reads tenant data — like the Microsoft webhook callback, which wraps its body in withSchool(schoolId, …) using the id from the validated OAuth state — it must call withSchool or runUnscoped itself. It will not inherit a JWT-derived tenant.
Gotchas
The engine removes most of the foot-guns, but a few remain. These are the ones that actually bite.
-
Treat
TenantContextMissingErroras a bug to fix at the source — never swallow it. It's deliberately a500, not a403or an empty list. The fix is always to add the missingwithSchool/runUnscopedwrap, not to catch the error. -
runUnscopedis not "switch schools." Inside it, isolation is off and queries see every tenant. If you wanted one specific other school, usewithSchool. This is the single most dangerous misuse. -
Raw
dataSource/managerbypasses the engine entirely. Any directdataSource.getRepository(X)ormanager.getRepository(X)(common inside provisioning transactions) is plain, unscoped TypeORM — you own theschool_idfilter and stamp there. Grep for.getRepository(and.manager.when something looks unscoped. -
A mismatched
schoolIdon a write throwsCrossTenantWriteError. The engine compares anyschoolIdalready on the payload against the active context. Don't hand-setschoolIdon a write expecting it to "win" — the active context is the source of truth, and a mismatch is rejected. -
@Public()routes stay fail-closed. A new public endpoint that touches tenant data must explicitly establish a context. The default will throw the moment it hits a scoped repository. -
AI tools re-establish tenant scope per RPC call. There is no HTTP middleware on the agent-tool queue, so
execute()rebuilds the CASL ability insideinTenantScopeandrunInToolScopeopens the tenant frame (runUnscopedfor platform admins,withSchool(jwt.schoolId, …)for everyone else). A new RPC tool must go throughexecute()— see Adding an RPC tool.
Where to go next
Repository Scoping
The engine these hatches feed: how BaseRepository reads the context to filter and stamp every query.
Multi-Tenancy Overview
Back to the start: the School as tenant and how the context is set per request.
API Tokens
How API keys resolve their school unscoped, then pin the request to that tenant.
Adding an RPC tool
Why execute() re-establishes tenant scope per call instead of trusting HTTP middleware.
Repository Scoping
How BaseRepository injects the school_id filter and stamp on every read and write — fail-closed — and how @TenantScoped opts an entity in.
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.