Skip to content
pleach
Operate

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:

ModeWhat changes
productionFull ledger writes, cache reads enabled, replay disabled
replayReads from recorded ledger; provider calls go through fingerprint cache
eval-noncachedProvider 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=true set (or unset — true is the default)
  • Provider credentials in env (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
  • SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY set; service key never reaches client bundle
  • DevTools hook (useHarnessDevTools) gated behind NODE_ENV !== "production"
  • Plugin set finalized; versions pinned in package.json
  • enabledSafetyPolicies reviewed 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"
done

Both 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_LEVEL if your logger does.
  • Don't log raw AuditableCall rows from custom adapters unless you've wired PIIRedactor — 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