Naalya Handbook
Multi-Tenancy & AsyncLocalStorage

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) — runs fn with bypass: true. Isolation is off for the duration. Reach for it only when the operation is genuinely global or cross-tenant.
  • withSchool(schoolId, fn) — runs fn pinned 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.

libs/shared/src/database/tenant/tenant-context.store.ts
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 forUse withSchool(id, …) for
Login lookups (email / microsoftSub are globally unique)Background / worker jobs (no request context)
Seeders and bootstrapOperator endpoints acting on one school :id
Operator cross-tenant readsThe 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:

apps/server/src/app/auth/auth.service.ts
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:

apps/server/src/app/api-key/api-key.service.ts
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:

your.service.ts
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:

your.service.ts
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 now

Reading 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:

apps/worker/src/processors/billing.processor.ts
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:

apps/worker/src/processors/report.processor.ts
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 TenantContextMissingError as a bug to fix at the source — never swallow it. It's deliberately a 500, not a 403 or an empty list. The fix is always to add the missing withSchool / runUnscoped wrap, not to catch the error.

  • runUnscoped is not "switch schools." Inside it, isolation is off and queries see every tenant. If you wanted one specific other school, use withSchool. This is the single most dangerous misuse.

  • Raw dataSource / manager bypasses the engine entirely. Any direct dataSource.getRepository(X) or manager.getRepository(X) (common inside provisioning transactions) is plain, unscoped TypeORM — you own the school_id filter and stamp there. Grep for .getRepository( and .manager. when something looks unscoped.

  • A mismatched schoolId on a write throws CrossTenantWriteError. The engine compares any schoolId already on the payload against the active context. Don't hand-set schoolId on 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 inside inTenantScope and runInToolScope opens the tenant frame (runUnscoped for platform admins, withSchool(jwt.schoolId, …) for everyone else). A new RPC tool must go through execute() — see Adding an RPC tool.

Where to go next

On this page