Deployment
Production checklist — environments, schema migrations, observability, rollback strategy, and the Fluid Compute / serverless patterns.
A production deployment of @pleach/core has five concerns:
schema migrations against the target database, environment
variable wiring, a runtime-construction pattern that fits your
hosting model, observability hooks, and a rollback strategy.
This page walks each. The goal is to ship without surprises — the substrate has no hidden state, no implicit network calls, and no required external service beyond what you wire.
Environments
The runtime supports three named environments distinguished by
the runtimeMode fingerprint field:
| Mode | What changes |
|---|---|
production | Full ledger writes, cache reads enabled, replay disabled |
replay | Reads from recorded ledger; provider calls go through fingerprint cache |
eval-noncached | Provider calls execute; cache writes disabled (for honest baseline) |
Set via runtime config or environment variable
(HARNESS_RUNTIME_MODE). Production code paths should never
default this — pass it explicitly.
Pre-deployment checklist
- Schema bundle applied to the production database
-
FEATURE_HARNESS_V2_RUNTIME=trueset (or unset — true is the default) - Provider credentials in env (
OPENROUTER_API_KEY,ANTHROPIC_API_KEY,OPENAI_API_KEY, etc.) -
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYset; service key never reaches client bundle - DevTools hook (
useHarnessDevTools) gated behindNODE_ENV !== "production" - Plugin set finalized; versions pinned in
package.json -
enabledSafetyPoliciesreviewed for each tenant - Observability sinks wired (next section)
- Rollback plan documented (last section)
Schema migrations
Two paths to apply the schema bundle.
Supabase CLI
npx pleach init --apply --target ./supabase/migrations --timestamped
supabase db push--timestamped prefixes each file with YYYYMMDDHHMMSS_ so the
files slot cleanly into Supabase's chronological migration
history.
Manual psql
npx pleach init --apply --target ./harness-migrations
for f in harness-migrations/*.sql; do
psql "$DATABASE_URL" -f "$f"
doneBoth paths are idempotent — every file uses CREATE ... IF NOT EXISTS and DROP POLICY IF EXISTS — but they don't migrate
column shapes. Schema evolution lands as new files; running an
old apply against a newer database is safe but won't backfill
missing fields.
See CLI and Schema for the details.
Runtime construction patterns
The right construction pattern depends on the hosting model.
Long-lived process (Node, containers)
Construct one runtime at startup; reuse for every request.
// server.ts
import { SessionRuntime, AiSdkProvider } from "@pleach/core";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY! });
export const runtime = new SessionRuntime({
storage: supabaseAdapter,
checkpointer: supabaseSaver,
provider: new AiSdkProvider({ model: openrouter("anthropic/claude-sonnet-4-5"), maxSteps: 5 }),
plugins: productionPlugins,
// userId / organizationId set per-request via session scoping below
});
// Per-request handler:
app.post("/api/chat", async (req, res) => {
// Scope the runtime to this request's tenant via the call:
const session = await runtime.createSession(/* ... */);
// ...
});For multi-tenant deployments where tenant credentials differ, construct per-request — see Multi-tenant.
Serverless functions (Fluid Compute, Lambda)
Two patterns, depending on cold-start sensitivity.
Pattern A — Construct in handler. Simple; pays runtime construction on every cold start.
export async function POST(req: Request) {
const runtime = new SessionRuntime({/* ... */});
// ... handle request
}Pattern B — Module-scope construct, lazy-init storage. The runtime object is reused across warm invocations; storage and provider clients lazy-init.
// app/api/chat/route.ts
const runtimePromise = (async () => {
const supabase = createClient(/* ... */);
return new SessionRuntime({/* ... */});
})();
export async function POST(req: Request) {
const runtime = await runtimePromise;
// ...
}A full route handler streams the response and turns a client
disconnect into a classified turn abort. Don't pass req.signal
straight through as abortSignal: a bare disconnect is then
indistinguishable from a network drop, and the runtime can't tell
a user Stop from a transport loss. Bind it instead — the client
declares its disconnect intent when it opens the stream, and the
handler reads it (see
Stopping a streamed turn from the client):
// app/api/chat/route.ts
import {
SessionRuntime,
bindDisconnectToTurnAbort,
createTurnAborter,
parseDisconnectIntent,
} from "@pleach/core";
import { SupabaseAdapter } from "@pleach/core/sessions";
import { SupabaseSaver } from "@pleach/core/checkpointing";
export const runtime = "nodejs";
export async function POST(req: Request) {
const { sessionId, message } = (await req.json()) as {
sessionId: string;
message: string;
};
const rt = new SessionRuntime({
storage: new SupabaseAdapter({ client: supabase }),
checkpointer: new SupabaseSaver({ client: supabase }),
organizationId: "org-acme",
userId: "user-7",
});
const abortController = new AbortController();
const binding = bindDisconnectToTurnAbort({
signal: req.signal,
onDisconnect: parseDisconnectIntent({ headers: req.headers, url: req.url }),
abort: createTurnAborter({ runtime: rt, controller: abortController }),
});
const events = rt.executeMessage(sessionId, message, {
abortSignal: abortController.signal,
});
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
const { value, done } = await events.next();
if (done) {
binding.dispose(); // turn over: a later close is not a stop
controller.close();
return;
}
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(value)}\n\n`));
},
cancel() {
binding.disconnect(); // the client went away mid-stream
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" },
});
}If the handler closes the stream itself — a max-duration cap
below the platform limit, an idle backstop — call
binding.serverAbort("timeout") before closing. The first abort
wins, so the close that follows can't be read as a user Stop.
createPleachRoute does all of this for you.
For Vercel Fluid Compute specifically: register waitUntil with
the runtime's durable-flush pipeline so the event log survives
function teardown:
// app/api/chat/route.ts
import { setWaitUntilImpl } from "@pleach/core/eventLog";
export async function POST(req: Request, ctx: { waitUntil: (p: Promise<unknown>) => void }) {
setWaitUntilImpl(ctx.waitUntil.bind(ctx));
// ... handle request
}See Event log for the durable-flush contract.
Runtime requirements
@pleach/core runs on Node.js (server side) or in a browser
(client side). It does not run on Edge servers — Vercel Edge,
Cloudflare Workers, and similar — or on any server runtime without
AsyncLocalStorage.
The reason is isolation. Tool execution carries a per-request
context (who is calling, for which session), and on a server that
context has to be held per request via AsyncLocalStorage from
node:async_hooks. Without it, two concurrent requests would
share one slot and could see each other's identity, so the runtime
fails closed instead.
On an unsupported runtime, entering a tool-execution scope throws
ToolExecutionContextUnsupportedRuntimeError. It carries
code: "PLEACH_TOOL_EXECUTION_CONTEXT_UNSUPPORTED_RUNTIME" and a
runtime field of "edge" or "server-without-async-local-storage".
The quickstart createPleachRoute() handler answers it before any
stream starts, with a non-streaming 501:
{
"error": "unsupported_runtime",
"code": "PLEACH_TOOL_EXECUTION_CONTEXT_UNSUPPORTED_RUNTIME",
"runtime": "edge",
"detail": "..."
}The response carries cache-control: no-store. See
Error codes for the
matching rule.
On Next.js, pin the route to the Node.js runtime
(export const runtime = "nodejs";). On Vercel, that is the default.
Long turns still need headroom. A turn that fans out to three
subagents each running a 20s tool call can exceed the request
budget of a short-lived function. Run those on Fluid Compute or a
long-lived Node process, with waitUntil registered via
setWaitUntilImpl (see above).
Observability
The substrate emits two streams ready for observability sinks:
runtime.on(event)
Every StreamEvent type also fires as a SessionRuntime event.
Wire a long-lived subscriber:
runtime.on("checkpoint.created", (event) => {
metrics.increment("checkpoints", { sessionId: event.checkpoint.sessionId });
});
runtime.on("error", (event) => {
errors.capture(event.error, { code: event.code });
});
runtime.on("subagent.completed", (event) => {
metrics.timing("subagent.duration", event.durationMs);
});The audit ledger
The ProviderDecisionLedger write is the per-call telemetry hook
that always fires (every LLM call, never dropped). Wire a custom
adapter that writes to both your primary store and your
observability sink:
class DualLedger implements ProviderDecisionLedger {
constructor(
private primary: ProviderDecisionLedger,
private telemetry: TelemetryClient,
) {}
async recordCall(call: AuditableCall): Promise<void> {
this.telemetry.record("llm.call", {
model: call.modelId,
family: call.family,
latency: call.latencyMs,
tokens: call.tokenUsage,
});
return this.primary.recordCall(call);
}
}The telemetry write is non-blocking by contract — a failed telemetry call doesn't break the turn.
OpenTelemetry
For OTel-shaped observability, the same pattern wraps the
runtime's events and ledger writes into spans. The
@pleach/gateway SKU ships OTel spans pre-wired; for non-gateway
deployments, build the spans in your custom ledger adapter and
event subscribers.
Logging
The substrate's default loggers write event types and ids — not payloads. For production logging:
- Set log level via your standard mechanism. The runtime respects
LOG_LEVELif your logger does. - Don't log raw
AuditableCallrows from custom adapters unless you've wiredPIIRedactor— raw messages may contain PII. - Stream events that carry user content (
message.delta,message.complete) shouldn't go to long-term logs. Use the audit ledger as the durable record; treat logs as ephemeral.
Rollback strategy
Three layers of rollback, in order of granularity.
1. Per-session checkpoint rollback
The runtime's built-in time-travel. Use during incident response to revert a single session to a prior point:
await runtime.checkpoints.rollback(sessionId, "cp_018f...");Audit-ledger rows for the rolled-back portion stay (append-only
contract); the next turn continues from the restored state. The
rollback itself writes a new AuditableCall row with the rollback
target id in the payload, so a regulator's "show every state
transition for session-018f-7a" query reads both the original
calls and the rollback marker — the history is forward-only even
when the session state isn't.
2. Application-version rollback
Standard deploy rollback. The fingerprint includes
pleachVersion, so rolling back the substrate version
invalidates the cache automatically — no stale-cache risk.
If you've added prompt contributions or safety policies between versions, the fingerprint changes accordingly; old cache entries are invalidated by construction.
3. Schema rollback
Schema rollback is hard and the substrate doesn't try to make it easy. The append-only contract on the audit ledger means a schema "rollback" is really a forward-fix: ship a new migration that restores the prior shape or adds back a removed column.
For schema-shape mistakes caught before production load: drop the table, re-apply the bundle, re-run the migration.
For schema-shape mistakes caught after production data has
landed: forward-fix only. The audit ledger is the source of
truth for what calls were made; the runtime can re-hydrate
session state from harness_event_log if harness_sessions
needs to be rebuilt.
Feature flag rollouts
The substrate has one master switch: FEATURE_HARNESS_V2_RUNTIME.
Setting it to false disables the runtime — all
/api/harness/* routes return 503. Useful for emergency
disable; not a graceful rollback.
For graceful rollouts, wire your own feature flag at runtime construction:
const runtime = featureFlags.harnessEnabled(req.user)
? new SessionRuntime({/* ... */})
: null;
if (!runtime) {
return legacyHandler(req);
}Roll out by tenant, by user cohort, or by traffic percentage — whatever your flag system supports.
Health checks
The /api/harness/health route returns component-level
diagnostics. Cheap; safe to hit from a load balancer:
For container workers, point the readiness probe at the same
route — the worker is unready until storage and provider checks
return ok:
# Dockerfile
CMD ["node", "dist/worker.js"]
# k8s deployment.yaml (excerpt)
readinessProbe:
httpGet: { path: /api/harness/health, port: 3000 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: /api/harness/health, port: 3000 }
periodSeconds: 30{
"ok": true,
"version": "0.1.0",
"components": {
"storage": { "ok": true },
"checkpointer": { "ok": true },
"providers": { "ok": true, "configured": ["anthropic"] }
}
}A non-ok response indicates a misconfiguration — typically a
missing env var, a database connection failure, or a provider
key that doesn't validate. The component-level fields narrow the
diagnosis: components.storage.ok === false means
SupabaseAdapter can't reach Postgres (check
SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY and the project's
connection pooler); components.providers.ok === false with an
empty configured array means no provider env var was visible at
construction time (the substrate doesn't ship default credentials
by design).
Where to go next
LTS roadmap
Today's rolling-release cadence and the v2+ long-term-support branch plan — what v1 gives ISVs, what v2 adds, and the dependency on a Pleach support organization that doesn't exist yet.
Multi-tenant deployments
Operate a multi-tenant `@pleach/core` deployment — RLS in storage, `tenantId` in the fingerprint, per-tenant audit rollups, deployment checklist. The how-to guide.