Per-district tenant + cost-cap
Composing educationAgent per-district with hard cost ceilings enforced at the gateway boundary via @pleach/gateway/swarm's CostCeilingMiddleware, plus audit ledger scoping by districtTenantId via @pleach/compliance/audit.
K-12 buyers expect three controls before they sign:
- 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.
- 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.
- 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
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
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.
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
@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.
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:
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
| 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
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:
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
perDistrictMonthlyBudgetUsd is recorded only. The substrate
does NOT enforce a monthly kill-switch today. Two workable paths:
- Roll it up in your billing pipeline — query the audit ledger
by
tenantId = districtIdat month-roll and disable the district's API token at your application layer. - Compose a second
CostCeilingMiddlewarewith aRootTurnCostAggregatorscoped 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
@pleach/compliance/audit exposes a vendor-neutral
queryAudit() projector that pulls a filtered slice of audit
rows from any storage backend that satisfies AuditStoreLike:
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/complianceversion + 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
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
packages/recipes/src/education.tspackages/core/src/spawn/rootTurnCostCap.tspackages/gateway/src/swarm/CostCeilingMiddleware.tspackages/compliance/src/audit/{queryAudit,exportPdf}.ts
See also
- Education — the landing page.
- FERPA / COPPA scope — what the compliance substitution does and doesn't cover.
- Audit ledger — the underlying hash-chained event log.
@pleach/gateway— the routing layer where the cost ceiling lives.- Multi-tenant SaaS agent — the generic multi-tenant shape; districts are one instance of it.
- Subagents — when one user turn fans out into many child calls.
FERPA / COPPA scope
Honest framing of what @pleach/compliance covers for FERPA and COPPA today, what the educationAgent factory substitutes when you ask for those profiles, and the concrete gap list you should bring to district counsel before signing.
Redistribution rights
What FSL-1.1-Apache-2.0 lets you do when embedding Pleach into your commercial product, the per-SKU license matrix, and the 2-year Apache 2.0 transition.