# Per-district tenant + cost-cap (/docs/use-cases/education/per-district-tenant)



K-12 buyers expect three controls before they sign:

1. Strong tenancy — district A's transcripts and spend cannot
   touch district B's, even if the runtime is shared across
   districts to keep per-seat pricing reasonable.
2. A hard per-session spend ceiling, enforced before the model
   call, so a runaway loop in a classroom can't burn the
   district's monthly quota.
3. An audit trail scoped to one district so end-of-year review
   pulls cleanly without leaking other districts' data.

This page shows how to compose those three controls today.

## The recipe-layer split [#the-recipe-layer-split]

`educationAgent.ask()` does NOT enforce the cost ceiling
internally. The factory registers a `CostCapPolicy` on the
runtime via the `EDUCATION_RUNTIME_TAG` symbol; the consumer's
gateway / accounting layer is the enforcement point.

This split is intentional. The recipe sees user-message text and
the runtime; it does not see per-call USD cost (that lives in
the routing pipeline, behind the model-family matrix). The
gateway sees cost on every routing call and is the right layer
to read-and-veto. Wiring the gateway-side enforcer is one
middleware factory and one `aggregator.recordCost()` call.

## Per-district runtime construction [#per-district-runtime-construction]

Construct one `educationAgent` per district. The factory is cheap
to call — there is no shared global state, so it's safe to build
on every request, on a startup loop, or per-tenant in a long-lived
pool.

```ts
import { educationAgent } from "@pleach/recipes/education";

function buildDistrictAgent(districtTenantId: string) {
  return educationAgent({
    districtId: districtTenantId,
    complianceProfile: "ferpa+coppa",
    contentSafety: ["self-harm", "profanity"],
    costCapUsdPerSession: 0.25,
    perDistrictMonthlyBudgetUsd: 1500,
  });
}

// In your request handler:
const bot = buildDistrictAgent(req.tenantId);
const reply = await bot.ask(req.body.message);
```

The `districtId` is stamped on the runtime tag and is the value
your downstream code (audit ledger, gateway aggregator, billing
pipeline) should read for per-district scoping.

## Cost-cap enforcement at the gateway boundary [#cost-cap-enforcement-at-the-gateway-boundary]

`@pleach/gateway/swarm` ships `CostCeilingMiddleware` — a
pre-call guard that wraps any routing call and throws
`CostCeilingExceededError` before transport if the per-root-turn
ceiling is already breached.

```ts
import {
  createCostCeilingMiddleware,
  createRootTurnCostAggregator,
} from "@pleach/gateway/swarm";

const aggregator = createRootTurnCostAggregator();

const middleware = createCostCeilingMiddleware({
  aggregator,
  maxCostUsdPerRootTurn: 0.25, // matches educationAgent's cap
  warnCostUsdPerRootTurn: 0.15,
  onBreach: async (event) => {
    await auditLedger.recordBreach({
      tenantId: event.tenantId,
      rootTurnId: event.rootTurnId,
      severity: event.severity, // "warn" | "breach"
      spentUsd: event.accumulatedUsd,
      ceilingUsd: event.ceilingUsd,
    });
  },
});

// Wrap every routing call:
const result = await middleware.guard({
  rootTurnId: req.rootTurnId,
  invoke: () => gatewayClient.route(routingInput),
});
```

The middleware enforces the ceiling at the routing-call boundary;
the `RootTurnCostAggregator` accumulates spend across every
descendant sub-agent under the same `rootTurnId`. A swarm
fan-out (multiple sub-agents under one user turn) cannot escape
the ceiling by spawning more children — the aggregator key is
the root turn, not the child.

For the per-root-turn-cap primitive itself (independent of the
gateway middleware), see
`createRootTurnCostCapPolicy` at
`packages/core/src/spawn/rootTurnCostCap.ts`:

```ts
import { createRootTurnCostCapPolicy } from "@pleach/core/spawn";

const policy = createRootTurnCostCapPolicy({
  ceilingUsd: 25,
  onBreach: async ({ rootTurnId, spentUsd }) => {
    await notifyOperator({ rootTurnId, spentUsd, action: "swarm-paused" });
  },
});

if (!policy.canSpend(rootTurnId, projectedCostUsd)) {
  throw new Error("root-turn cost-cap reached");
}
await policy.recordSpend(rootTurnId, actualCostUsd);
```

This is the substrate the gateway middleware composes. Use it
directly when you're enforcing the cap outside the gateway
(e.g. in a custom routing pipeline that doesn't go through
`@pleach/gateway`).

## Per-session vs per-root-turn: which to use [#per-session-vs-per-root-turn-which-to-use]

| Scope              | Primitive                     | Source                         | When to use                                                             |
| ------------------ | ----------------------------- | ------------------------------ | ----------------------------------------------------------------------- |
| Per chat session   | `createCostCapPolicy`         | `@pleach/compliance/education` | One student, one tutoring session, no sub-agent fan-out.                |
| Per root user turn | `createRootTurnCostCapPolicy` | `@pleach/core/spawn`           | Sub-agent swarms; one user turn can fan out into many child calls.      |
| Per gateway call   | `createCostCeilingMiddleware` | `@pleach/gateway/swarm`        | Production — the pre-call guard that actually rejects before transport. |

`educationAgent` registers the per-session policy by default
(`costCapUsdPerSession`, default `$0.50`). Layer the per-root-turn
cap on top when one user turn fans out into sub-agents.

## Driving the per-session cap from your turn gate [#driving-the-per-session-cap-from-your-turn-gate]

The per-session `CostCapPolicy` is registered on the runtime
tag — read it via the `EDUCATION_RUNTIME_TAG` symbol and drive
it from your per-turn gate:

```ts
import { EDUCATION_RUNTIME_TAG } from "@pleach/recipes/education";

const bot = educationAgent({
  districtId: "district-1234",
  costCapUsdPerSession: 0.25,
});

const tag = (bot.runtime as unknown as Record<symbol, any>)[
  EDUCATION_RUNTIME_TAG
];

// In your per-turn handler:
const projectedCostUsd = estimateTurnCost(message); // your projector
if (!tag.costCap.canSpend(sessionId, projectedCostUsd)) {
  throw new Error("classroom session cost-cap reached");
}

const reply = await bot.ask(message);

// After the model returns, record the actual cost:
await tag.costCap.recordSpend(sessionId, actualCostUsd);
```

The policy is in-memory and single-process — lossy across
restarts. Production deployments back this with a tenant DB
(Postgres / DynamoDB / Redis) by implementing the same
`CostCapPolicy` interface directly against the same store the
billing pipeline reads. The shape is intentionally narrow
(`canSpend` / `recordSpend` / `spentFor` / `reset`) so a custom
implementation drops in.

## Per-district monthly budget [#per-district-monthly-budget]

`perDistrictMonthlyBudgetUsd` is **recorded only**. The substrate
does NOT enforce a monthly kill-switch today. Two workable paths:

1. Roll it up in your billing pipeline — query the audit ledger
   by `tenantId = districtId` at month-roll and disable the
   district's API token at your application layer.
2. Compose a second `CostCeilingMiddleware` with a
   `RootTurnCostAggregator` scoped to month-bucket keys
   (`rootTurnId: ${districtId}-${yyyymm}`). Hackier but stays
   inside the gateway layer.

A first-class per-district monthly budget primitive is tracked
under an open scoping decision.

## Audit ledger scoped by `districtTenantId` [#audit-ledger-scoped-by-districttenantid]

`@pleach/compliance/audit` exposes a vendor-neutral
`queryAudit()` projector that pulls a filtered slice of audit
rows from any storage backend that satisfies `AuditStoreLike`:

```ts
import { queryAudit, exportAuditPdf } from "@pleach/compliance/audit";

const result = await queryAudit({
  store, // your AuditStoreLike (Postgres / Supabase / SQLite / in-memory)
  tenantId: "district-1234", // scope to one district
  from: "2026-01-01T00:00:00Z",
  to: "2026-02-01T00:00:00Z",
  limit: 1000,
});

if (!result.chainValid) {
  throw new Error("Audit log tampered with — chain broken.");
}

// Render to a single-file PDF for distribution to district counsel:
const pdfBuffer = await exportAuditPdf({
  rows: result.rows,
  metadata: {
    tenantId: "district-1234",
    framework: "FERPA+COPPA (mapped to GDPR bundle)",
    title: "District 1234 — January 2026 AI Tutor audit",
    from: "2026-01-01T00:00:00Z",
    to: "2026-02-01T00:00:00Z",
    chainValid: result.chainValid,
    notes: "Substitution: requested ferpa+coppa, applied gdpr bundle.",
  },
  format: "letter",
});
```

The PDF carries:

* Header — tenant, framework, time range, generated-at.
* Chain-validity badge — OK / TAMPERED at the top.
* Per-row table — sequence, recorded-at, event-type, chat,
  hash prefix.
* Footer — `@pleach/compliance` version + page numbers.

`pdfkit` is an OPTIONAL PEER. If you haven't installed it,
`exportAuditPdf` throws `MissingOptionalPeerError("pdfkit")` so
the caller can either install the peer or fall back to JSON /
Markdown via `produceAuditReport()`.

The hash-chain verification (`result.chainValid`) is the
load-bearing piece for a district auditor: it certifies that
between `recordedAt` time and now, no row in the projection was
mutated or removed.

## Putting it together — production-shape sketch [#putting-it-together--production-shape-sketch]

```ts
import { educationAgent, EDUCATION_RUNTIME_TAG } from "@pleach/recipes/education";
import {
  createCostCeilingMiddleware,
  createRootTurnCostAggregator,
} from "@pleach/gateway/swarm";
import { queryAudit, exportAuditPdf } from "@pleach/compliance/audit";

const aggregator = createRootTurnCostAggregator();

const middleware = createCostCeilingMiddleware({
  aggregator,
  maxCostUsdPerRootTurn: 0.25,
  warnCostUsdPerRootTurn: 0.15,
  onBreach: (event) => auditStore.recordBreach(event),
});

async function handleTutorTurn(req: Request) {
  const bot = educationAgent({
    districtId: req.districtId,
    complianceProfile: "ferpa+coppa",
    costCapUsdPerSession: 0.25,
  });
  const tag = (bot.runtime as any)[EDUCATION_RUNTIME_TAG];
  if (!tag.costCap.canSpend(req.sessionId, estimateTurnCost(req.message))) {
    return new Response("session cost-cap reached", { status: 429 });
  }
  return middleware.guard({
    rootTurnId: req.rootTurnId,
    invoke: async () => {
      const reply = await bot.ask(req.message);
      const actual = await readActualCostFromGateway();
      await tag.costCap.recordSpend(req.sessionId, actual);
      return {
        result: new Response(reply),
        costEvent: { tenantId: req.districtId, usd: actual, rootTurnId: req.rootTurnId },
      };
    },
  });
}

async function monthlyDistrictAudit(districtId: string, month: string) {
  const result = await queryAudit({
    store: auditStore,
    tenantId: districtId,
    from: `${month}-01T00:00:00Z`,
    to: `${month}-31T23:59:59Z`,
  });
  return exportAuditPdf({
    rows: result.rows,
    metadata: {
      tenantId: districtId,
      framework: "FERPA+COPPA (mapped to GDPR bundle)",
      chainValid: result.chainValid,
    },
  });
}
```

## Source [#source]

* `packages/recipes/src/education.ts`
* `packages/core/src/spawn/rootTurnCostCap.ts`
* `packages/gateway/src/swarm/CostCeilingMiddleware.ts`
* `packages/compliance/src/audit/{queryAudit,exportPdf}.ts`

## See also [#see-also]

* [Education](/docs/use-cases/education) — the landing page.
* [FERPA / COPPA scope](/docs/use-cases/education/ferpa-coppa) —
  what the compliance substitution does and doesn't cover.
* [Audit ledger](/docs/audit-ledger) — the underlying
  hash-chained event log.
* [`@pleach/gateway`](/docs/gateway) — the routing layer where
  the cost ceiling lives.
* [Multi-tenant SaaS agent](/docs/multi-tenant-saas-agent) —
  the generic multi-tenant shape; districts are one instance of
  it.
* [Subagents](/docs/subagents) — when one user turn fans out
  into many child calls.
